|
1 | | - |
2 | 1 | package com.ast.ouyalaunch; |
3 | 2 |
|
4 | 3 | import android.content.Context; |
|
9 | 8 | import android.graphics.Bitmap; |
10 | 9 | import android.graphics.BitmapFactory; |
11 | 10 | import android.util.Log; |
| 11 | + |
12 | 12 | import java.io.File; |
13 | 13 | import java.io.FileOutputStream; |
14 | 14 | import java.io.InputStream; |
15 | 15 | import java.util.ArrayList; |
| 16 | +import java.util.Collections; |
| 17 | +import java.util.Comparator; |
| 18 | +import java.util.Enumeration; |
| 19 | +import java.util.HashMap; |
| 20 | +import java.util.HashSet; |
16 | 21 | import java.util.List; |
| 22 | +import java.util.Locale; |
| 23 | +import java.util.Map; |
| 24 | +import java.util.Set; |
17 | 25 | import java.util.zip.ZipEntry; |
18 | 26 | import java.util.zip.ZipFile; |
19 | 27 |
|
| 28 | +/** |
| 29 | + * Scans launchable apps and builds a cache of OUYA-compatible entries, identified by the presence |
| 30 | + * of an "ouya_icon.png" inside the APK. Soft rule: if both a package and its ".launcher" twin exist, |
| 31 | + * the base package (without ".launcher") is ignored for the icon scan and only the ".launcher" app |
| 32 | + * is considered. This avoids duplicate entries and ensures the correct OUYA icon is used. |
| 33 | + * |
| 34 | + * NOTE: Keep Java 7 compatible (no lambdas). |
| 35 | + */ |
20 | 36 | public class AppScanner { |
21 | 37 |
|
| 38 | + private static final String TAG = "AppScanner"; |
| 39 | + private static final String ICON_FILE_NAME = "ouya_icon.png"; |
| 40 | + private static final String LAUNCHER_SUFFIX = ".launcher"; |
| 41 | + |
| 42 | + /** Build the full list of OUYA-compatible apps (has ouya_icon.png). */ |
22 | 43 | public static List<AppEntry> buildCache(Context ctx) { |
23 | | - List<AppEntry> out = new ArrayList<>(); |
| 44 | + List<AppEntry> out = new ArrayList<AppEntry>(); |
24 | 45 | PackageManager pm = ctx.getPackageManager(); |
25 | 46 |
|
26 | 47 | try { |
| 48 | + // 1) Get all launchable activities |
27 | 49 | Intent intent = new Intent(Intent.ACTION_MAIN, null); |
28 | 50 | intent.addCategory(Intent.CATEGORY_LAUNCHER); |
29 | 51 | List<ResolveInfo> infos = pm.queryIntentActivities(intent, 0); |
| 52 | + if (infos == null) infos = new ArrayList<ResolveInfo>(); |
30 | 53 |
|
31 | | - if (infos == null || infos.isEmpty()) { |
32 | | - Log.w("AppScanner", "Keine Apps gefunden."); |
33 | | - java.util.Collections.sort(out, new java.util.Comparator<AppEntry>() { |
34 | | - @Override |
35 | | - public int compare(AppEntry a, AppEntry b) { |
36 | | - return a.title.toLowerCase().compareTo(b.title.toLowerCase()); |
37 | | - } |
38 | | -}); |
| 54 | + // 2) Collect unique package names |
| 55 | + Set<String> packages = new HashSet<String>(); |
| 56 | + for (int i = 0; i < infos.size(); i++) { |
| 57 | + ResolveInfo ri = infos.get(i); |
| 58 | + if (ri == null || ri.activityInfo == null) continue; |
| 59 | + String pkg = ri.activityInfo.packageName; |
| 60 | + if (pkg != null && pkg.length() > 0) { |
| 61 | + packages.add(pkg); |
| 62 | + } |
| 63 | + } |
| 64 | + |
| 65 | + // 3) Compute skip set for base packages when a ".launcher" twin exists |
| 66 | + Set<String> skipBase = new HashSet<String>(); |
| 67 | + for (String pkg : packages) { |
| 68 | + if (pkg.endsWith(LAUNCHER_SUFFIX)) { |
| 69 | + String base = pkg.substring(0, pkg.length() - LAUNCHER_SUFFIX.length()); |
| 70 | + if (packages.contains(base)) { |
| 71 | + skipBase.add(base); |
| 72 | + } |
| 73 | + } |
| 74 | + } |
| 75 | + if (!skipBase.isEmpty()) { |
| 76 | + Log.i(TAG, "Launcher-pairs detected, will ignore bases for icon scan: " + skipBase); |
| 77 | + } |
39 | 78 |
|
40 | | -return out; |
| 79 | + // 4) Prepare icon cache dir |
| 80 | + File iconDir = new File(ctx.getFilesDir(), "icons"); |
| 81 | + if (!iconDir.exists()) { |
| 82 | + //noinspection ResultOfMethodCallIgnored |
| 83 | + iconDir.mkdirs(); |
41 | 84 | } |
42 | 85 |
|
43 | | - for (ResolveInfo ri : infos) { |
44 | | - String pkg = ri.activityInfo.packageName; |
45 | | - String label = ri.loadLabel(pm).toString(); |
| 86 | + // 5) Iterate packages, apply soft rule, look for ouya_icon.png, and cache |
| 87 | + for (String pkg : packages) { |
| 88 | + // Soft rule: if base is paired with ".launcher", skip base |
| 89 | + if (skipBase.contains(pkg)) { |
| 90 | + Log.d(TAG, "Skip base package due to .launcher twin: " + pkg); |
| 91 | + continue; |
| 92 | + } |
46 | 93 |
|
47 | 94 | try { |
48 | 95 | ApplicationInfo ai = pm.getApplicationInfo(pkg, 0); |
49 | | - String apkPath = ai.sourceDir; |
50 | | - |
51 | | - boolean hasOuyaIcon = false; |
52 | | - Bitmap bmp = null; |
53 | | - |
54 | | - ZipFile zipFile = new ZipFile(apkPath); |
55 | | - for (ZipEntry entry : java.util.Collections.list(zipFile.entries())) { |
56 | | - if (entry.getName().toLowerCase().contains("drawable") && |
57 | | - entry.getName().toLowerCase().endsWith("ouya_icon.png")) { |
58 | | - hasOuyaIcon = true; |
59 | | - InputStream is = zipFile.getInputStream(entry); |
60 | | - bmp = BitmapFactory.decodeStream(is); |
61 | | - is.close(); |
62 | | - break; |
63 | | - } |
64 | | - } |
65 | | - zipFile.close(); |
| 96 | + if (ai == null || ai.sourceDir == null) continue; |
66 | 97 |
|
67 | | - if (!hasOuyaIcon || bmp == null) { |
68 | | - Log.i("AppScanner", "Überspringe " + pkg + " (kein ouya_icon.png in APK)"); |
| 98 | + // Search inside APK zip for ouya_icon.png (any density path) |
| 99 | + IconMatch match = findOuyaIconInApk(ai.sourceDir); |
| 100 | + if (match == null) { |
| 101 | + // No OUYA icon → not considered OUYA-compatible |
69 | 102 | continue; |
70 | 103 | } |
71 | 104 |
|
72 | | - Bitmap scaled = Bitmap.createScaledBitmap(bmp, 528, 297, true); |
73 | | - File iconFile = new File(ctx.getFilesDir(), pkg + "_icon.png"); |
74 | | - FileOutputStream fos = new FileOutputStream(iconFile); |
75 | | - scaled.compress(Bitmap.CompressFormat.PNG, 100, fos); |
| 105 | + // Decode and cache icon file |
| 106 | + InputStream is = match.open(); |
| 107 | + if (is == null) continue; |
| 108 | + Bitmap bmp = BitmapFactory.decodeStream(is); |
| 109 | + is.close(); |
| 110 | + if (bmp == null) continue; |
| 111 | + |
| 112 | + File outPng = new File(iconDir, pkg + ".png"); |
| 113 | + FileOutputStream fos = new FileOutputStream(outPng); |
| 114 | + bmp.compress(Bitmap.CompressFormat.PNG, 100, fos); |
| 115 | + fos.flush(); |
76 | 116 | fos.close(); |
77 | 117 |
|
78 | | - AppEntry entry = new AppEntry(pkg, label, "Casual", false, iconFile.getAbsolutePath()); |
79 | | - out.add(entry); |
| 118 | + // Build entry |
| 119 | + CharSequence label = pm.getApplicationLabel(ai); |
| 120 | + String title = label != null ? label.toString() : pkg; |
| 121 | + |
| 122 | + AppEntry e = new AppEntry(); |
| 123 | + e.packageName = pkg; |
| 124 | + e.title = title; |
| 125 | + e.genre = "Casual"; // default; user can reassign, DataStore preserves |
| 126 | + e.favorite = false; |
| 127 | + e.iconPath = outPng.getAbsolutePath(); |
80 | 128 |
|
81 | | - Log.i("AppScanner", "Gefunden: " + label + " (" + pkg + ")"); |
| 129 | + out.add(e); |
82 | 130 |
|
83 | | - } catch (Throwable t) { |
84 | | - Log.w("AppScanner", "Fehler bei " + pkg + ": " + t.getMessage()); |
| 131 | + Log.d(TAG, "Added OUYA app: " + pkg + " (" + title + "), icon=" + match.entryName); |
| 132 | + } catch (Throwable perApp) { |
| 133 | + Log.w(TAG, "Failed scanning " + pkg + ": " + perApp.getMessage()); |
85 | 134 | } |
86 | 135 | } |
87 | | - |
88 | 136 | } catch (Throwable t) { |
89 | | - Log.e("AppScanner", "Fehler beim App-Scan: " + t.getMessage(), t); |
| 137 | + Log.e(TAG, "Fehler beim App-Scan: " + t.getMessage(), t); |
| 138 | + } |
| 139 | + |
| 140 | + Log.i(TAG, "Scan abgeschlossen, " + out.size() + " OUYA-kompatible Apps gefunden."); |
| 141 | + // Sort A–Z by title |
| 142 | + Collections.sort(out, new Comparator<AppEntry>() { |
| 143 | + @Override public int compare(AppEntry a, AppEntry b) { |
| 144 | + String ta = (a != null && a.title != null) ? a.title : ""; |
| 145 | + String tb = (b != null && b.title != null) ? b.title : ""; |
| 146 | + return ta.toLowerCase(Locale.US).compareTo(tb.toLowerCase(Locale.US)); |
| 147 | + } |
| 148 | + }); |
| 149 | + return out; |
| 150 | + } |
| 151 | + |
| 152 | + /** Represents a lazy-open match inside the APK. */ |
| 153 | + private static class IconMatch { |
| 154 | + final String apkPath; |
| 155 | + final String entryName; |
| 156 | + IconMatch(String apkPath, String entryName) { this.apkPath = apkPath; this.entryName = entryName; } |
| 157 | + InputStream open() { |
| 158 | + try { |
| 159 | + ZipFile zf = new ZipFile(apkPath); |
| 160 | + ZipEntry ze = zf.getEntry(entryName); |
| 161 | + if (ze == null) return null; |
| 162 | + // NOTE: The caller decodes & closes the stream; we keep ZipFile referenced |
| 163 | + // until stream is closed by framework. |
| 164 | + return zf.getInputStream(ze); |
| 165 | + } catch (Throwable t) { |
| 166 | + Log.w(TAG, "open() failed for " + entryName + " in " + apkPath + ": " + t.getMessage()); |
| 167 | + return null; |
| 168 | + } |
90 | 169 | } |
| 170 | + } |
91 | 171 |
|
92 | | - Log.i("AppScanner", "Scan abgeschlossen, " + out.size() + " OUYA-kompatible Apps gefunden."); |
93 | | - java.util.Collections.sort(out, new java.util.Comparator<AppEntry>() { |
94 | | - @Override |
95 | | - public int compare(AppEntry a, AppEntry b) { |
96 | | - return a.title.toLowerCase().compareTo(b.title.toLowerCase()); |
| 172 | + /** |
| 173 | + * Find the best-matching "ouya_icon.png" entry within the given APK zip path. |
| 174 | + * Preference order: xxxhdpi > xxhdpi > xhdpi > hdpi > mdpi > any. |
| 175 | + */ |
| 176 | + private static IconMatch findOuyaIconInApk(String apkPath) { |
| 177 | + ZipFile zf = null; |
| 178 | + try { |
| 179 | + zf = new ZipFile(apkPath); |
| 180 | + // Collect candidates |
| 181 | + List<String> candidates = new ArrayList<String>(); |
| 182 | + Enumeration<? extends ZipEntry> en = zf.entries(); |
| 183 | + while (en.hasMoreElements()) { |
| 184 | + ZipEntry ze = en.nextElement(); |
| 185 | + if (ze == null) continue; |
| 186 | + String name = ze.getName(); |
| 187 | + if (name == null) continue; |
| 188 | + String lower = name.toLowerCase(Locale.US); |
| 189 | + if (lower.endsWith("/" + ICON_FILE_NAME) || lower.equals(ICON_FILE_NAME)) { |
| 190 | + candidates.add(name); |
| 191 | + } |
| 192 | + } |
| 193 | + if (candidates.isEmpty()) return null; |
| 194 | + |
| 195 | + // Rank by density hints if available |
| 196 | + int bestScore = -1; |
| 197 | + String best = null; |
| 198 | + for (int i = 0; i < candidates.size(); i++) { |
| 199 | + String n = candidates.get(i); |
| 200 | + String ln = n.toLowerCase(Locale.US); |
| 201 | + int score = densityScore(ln); |
| 202 | + if (score > bestScore) { |
| 203 | + bestScore = score; |
| 204 | + best = n; |
| 205 | + } |
| 206 | + } |
| 207 | + if (best == null) best = candidates.get(0); |
| 208 | + // Don't keep the ZipFile open; IconMatch#open will reopen on demand |
| 209 | + zf.close(); |
| 210 | + return new IconMatch(apkPath, best); |
| 211 | + } catch (Throwable t) { |
| 212 | + try { if (zf != null) zf.close(); } catch (Throwable ignore) {} |
| 213 | + return null; |
| 214 | + } |
97 | 215 | } |
98 | | -}); |
99 | 216 |
|
100 | | -return out; |
| 217 | + private static int densityScore(String pathLower) { |
| 218 | + // Higher score = higher preference |
| 219 | + if (pathLower.contains("xxxhdpi")) return 5; |
| 220 | + if (pathLower.contains("xxhdpi")) return 4; |
| 221 | + if (pathLower.contains("xhdpi")) return 3; |
| 222 | + if (pathLower.contains("hdpi")) return 2; |
| 223 | + if (pathLower.contains("mdpi")) return 1; |
| 224 | + return 0; |
101 | 225 | } |
102 | 226 | } |
0 commit comments