fdroidclient / F-Droid / src / org / fdroid / fdroid / localrepo / LocalRepoManager.java @ 645f9fc5
History | View | Annotate | Download (19.6 KB)
1 |
package org.fdroid.fdroid.localrepo; |
---|---|
2 |
|
3 |
import android.content.Context; |
4 |
import android.content.SharedPreferences; |
5 |
import android.content.pm.ApplicationInfo; |
6 |
import android.content.pm.PackageInfo; |
7 |
import android.content.pm.PackageManager; |
8 |
import android.content.res.AssetManager; |
9 |
import android.graphics.Bitmap; |
10 |
import android.graphics.Bitmap.CompressFormat; |
11 |
import android.graphics.Bitmap.Config; |
12 |
import android.graphics.Canvas; |
13 |
import android.graphics.drawable.BitmapDrawable; |
14 |
import android.graphics.drawable.Drawable; |
15 |
import android.preference.PreferenceManager; |
16 |
import android.support.annotation.NonNull; |
17 |
import android.support.annotation.Nullable; |
18 |
import android.text.TextUtils; |
19 |
import android.util.Log; |
20 |
import android.widget.Toast; |
21 |
|
22 |
import org.fdroid.fdroid.FDroidApp; |
23 |
import org.fdroid.fdroid.Hasher; |
24 |
import org.fdroid.fdroid.Preferences; |
25 |
import org.fdroid.fdroid.R; |
26 |
import org.fdroid.fdroid.Utils; |
27 |
import org.fdroid.fdroid.data.App; |
28 |
import org.fdroid.fdroid.data.SanitizedFile; |
29 |
import org.xmlpull.v1.XmlPullParserException; |
30 |
import org.xmlpull.v1.XmlPullParserFactory; |
31 |
import org.xmlpull.v1.XmlSerializer; |
32 |
|
33 |
import java.io.BufferedInputStream; |
34 |
import java.io.BufferedOutputStream; |
35 |
import java.io.BufferedReader; |
36 |
import java.io.BufferedWriter; |
37 |
import java.io.File; |
38 |
import java.io.FileInputStream; |
39 |
import java.io.FileOutputStream; |
40 |
import java.io.FileWriter; |
41 |
import java.io.IOException; |
42 |
import java.io.InputStream; |
43 |
import java.io.InputStreamReader; |
44 |
import java.io.OutputStream; |
45 |
import java.io.OutputStreamWriter; |
46 |
import java.io.Writer; |
47 |
import java.security.cert.CertificateEncodingException; |
48 |
import java.text.DateFormat; |
49 |
import java.text.SimpleDateFormat; |
50 |
import java.util.ArrayList; |
51 |
import java.util.Date; |
52 |
import java.util.HashMap; |
53 |
import java.util.List; |
54 |
import java.util.Locale; |
55 |
import java.util.Map; |
56 |
import java.util.jar.JarEntry; |
57 |
import java.util.jar.JarOutputStream; |
58 |
|
59 |
/**
|
60 |
* The {@link SwapService} deals with managing the entire workflow from selecting apps to
|
61 |
* swap, to invoking this class to prepare the webroot, to enabling various communication protocols.
|
62 |
* This class deals specifically with the webroot side of things, ensuring we have a valid index.jar
|
63 |
* and the relevant .apk and icon files available.
|
64 |
*/
|
65 |
public class LocalRepoManager { |
66 |
private static final String TAG = "LocalRepoManager"; |
67 |
|
68 |
// For ref, official F-droid repo presently uses a maxage of 14 days
|
69 |
private static final String DEFAULT_REPO_MAX_AGE_DAYS = "14"; |
70 |
|
71 |
private final Context context; |
72 |
private final PackageManager pm; |
73 |
private final AssetManager assetManager; |
74 |
private final String fdroidPackageName; |
75 |
|
76 |
private static final String[] WEB_ROOT_ASSET_FILES = { |
77 |
"swap-icon.png",
|
78 |
"swap-tick-done.png",
|
79 |
"swap-tick-not-done.png"
|
80 |
}; |
81 |
|
82 |
private final Map<String, App> apps = new HashMap<>(); |
83 |
|
84 |
public final SanitizedFile xmlIndex; |
85 |
private SanitizedFile xmlIndexJar = null; |
86 |
private SanitizedFile xmlIndexJarUnsigned = null; |
87 |
public final SanitizedFile webRoot; |
88 |
public final SanitizedFile fdroidDir; |
89 |
public final SanitizedFile fdroidDirCaps; |
90 |
public final SanitizedFile repoDir; |
91 |
public final SanitizedFile repoDirCaps; |
92 |
public final SanitizedFile iconsDir; |
93 |
|
94 |
@Nullable
|
95 |
private static LocalRepoManager localRepoManager; |
96 |
|
97 |
@NonNull
|
98 |
public static LocalRepoManager get(Context context) { |
99 |
if (localRepoManager == null) |
100 |
localRepoManager = new LocalRepoManager(context);
|
101 |
return localRepoManager;
|
102 |
} |
103 |
|
104 |
private LocalRepoManager(Context c) { |
105 |
context = c.getApplicationContext(); |
106 |
pm = c.getPackageManager(); |
107 |
assetManager = c.getAssets(); |
108 |
fdroidPackageName = c.getPackageName(); |
109 |
|
110 |
webRoot = SanitizedFile.knownSanitized(c.getFilesDir()); |
111 |
/* /fdroid/repo is the standard path for user repos */
|
112 |
fdroidDir = new SanitizedFile(webRoot, "fdroid"); |
113 |
fdroidDirCaps = new SanitizedFile(webRoot, "FDROID"); |
114 |
repoDir = new SanitizedFile(fdroidDir, "repo"); |
115 |
repoDirCaps = new SanitizedFile(fdroidDirCaps, "REPO"); |
116 |
iconsDir = new SanitizedFile(repoDir, "icons"); |
117 |
xmlIndex = new SanitizedFile(repoDir, "index.xml"); |
118 |
xmlIndexJar = new SanitizedFile(repoDir, "index.jar"); |
119 |
xmlIndexJarUnsigned = new SanitizedFile(repoDir, "index.unsigned.jar"); |
120 |
|
121 |
if (!fdroidDir.exists())
|
122 |
if (!fdroidDir.mkdir())
|
123 |
Log.e(TAG, "Unable to create empty base: " + fdroidDir);
|
124 |
|
125 |
if (!repoDir.exists())
|
126 |
if (!repoDir.mkdir())
|
127 |
Log.e(TAG, "Unable to create empty repo: " + repoDir);
|
128 |
|
129 |
if (!iconsDir.exists())
|
130 |
if (!iconsDir.mkdir())
|
131 |
Log.e(TAG, "Unable to create icons folder: " + iconsDir);
|
132 |
} |
133 |
|
134 |
private String writeFdroidApkToWebroot() { |
135 |
ApplicationInfo appInfo; |
136 |
String fdroidClientURL = "https://f-droid.org/FDroid.apk"; |
137 |
|
138 |
try {
|
139 |
appInfo = pm.getApplicationInfo(fdroidPackageName, PackageManager.GET_META_DATA); |
140 |
SanitizedFile apkFile = SanitizedFile.knownSanitized(appInfo.publicSourceDir); |
141 |
SanitizedFile fdroidApkLink = new SanitizedFile(webRoot, "fdroid.client.apk"); |
142 |
attemptToDelete(fdroidApkLink); |
143 |
if (Utils.symlinkOrCopyFileQuietly(apkFile, fdroidApkLink))
|
144 |
fdroidClientURL = "/" + fdroidApkLink.getName();
|
145 |
} catch (PackageManager.NameNotFoundException e) {
|
146 |
Log.e(TAG, "Could not set up F-Droid apk in the webroot", e);
|
147 |
} |
148 |
return fdroidClientURL;
|
149 |
} |
150 |
|
151 |
public void writeIndexPage(String repoAddress) { |
152 |
final String fdroidClientURL = writeFdroidApkToWebroot(); |
153 |
try {
|
154 |
File indexHtml = new File(webRoot, "index.html"); |
155 |
BufferedReader in = new BufferedReader( |
156 |
new InputStreamReader(assetManager.open("index.template.html"), "UTF-8")); |
157 |
BufferedWriter out = new BufferedWriter(new OutputStreamWriter( |
158 |
new FileOutputStream(indexHtml))); |
159 |
|
160 |
String line;
|
161 |
while ((line = in.readLine()) != null) { |
162 |
line = line.replaceAll("\\{\\{REPO_URL\\}\\}", repoAddress);
|
163 |
line = line.replaceAll("\\{\\{CLIENT_URL\\}\\}", fdroidClientURL);
|
164 |
out.write(line); |
165 |
} |
166 |
in.close(); |
167 |
out.close(); |
168 |
|
169 |
for (final String file : WEB_ROOT_ASSET_FILES) { |
170 |
InputStream assetIn = assetManager.open(file);
|
171 |
OutputStream assetOut = new FileOutputStream(new File(webRoot, file)); |
172 |
Utils.copy(assetIn, assetOut); |
173 |
assetIn.close(); |
174 |
assetOut.close(); |
175 |
} |
176 |
|
177 |
// make symlinks/copies in each subdir of the repo to make sure that
|
178 |
// the user will always find the bootstrap page.
|
179 |
symlinkEntireWebRootElsewhere("../", fdroidDir);
|
180 |
symlinkEntireWebRootElsewhere("../../", repoDir);
|
181 |
|
182 |
// add in /FDROID/REPO to support bad QR Scanner apps
|
183 |
attemptToMkdir(fdroidDirCaps); |
184 |
attemptToMkdir(repoDirCaps); |
185 |
|
186 |
symlinkEntireWebRootElsewhere("../", fdroidDirCaps);
|
187 |
symlinkEntireWebRootElsewhere("../../", repoDirCaps);
|
188 |
|
189 |
} catch (IOException e) { |
190 |
Log.e(TAG, "Error writing local repo index", e);
|
191 |
} |
192 |
} |
193 |
|
194 |
private static void attemptToMkdir(@NonNull File dir) throws IOException { |
195 |
if (dir.exists()) {
|
196 |
if (dir.isDirectory()) {
|
197 |
return;
|
198 |
} |
199 |
throw new IOException("Can't make directory " + dir + " - it is already a file."); |
200 |
} |
201 |
|
202 |
if (!dir.mkdir()) {
|
203 |
throw new IOException("An error occurred trying to create directory " + dir); |
204 |
} |
205 |
} |
206 |
|
207 |
private static void attemptToDelete(@NonNull File file) { |
208 |
if (!file.delete()) {
|
209 |
Log.e(TAG, "Could not delete \"" + file.getAbsolutePath() + "\"."); |
210 |
} |
211 |
} |
212 |
|
213 |
private void symlinkEntireWebRootElsewhere(String symlinkPrefix, File directory) { |
214 |
symlinkFileElsewhere("index.html", symlinkPrefix, directory);
|
215 |
for (final String fileName : WEB_ROOT_ASSET_FILES) { |
216 |
symlinkFileElsewhere(fileName, symlinkPrefix, directory); |
217 |
} |
218 |
} |
219 |
|
220 |
private void symlinkFileElsewhere(String fileName, String symlinkPrefix, File directory) { |
221 |
SanitizedFile index = new SanitizedFile(directory, fileName);
|
222 |
attemptToDelete(index); |
223 |
Utils.symlinkOrCopyFileQuietly(new SanitizedFile(new File(directory, symlinkPrefix), fileName), index); |
224 |
} |
225 |
|
226 |
private void deleteContents(File path) { |
227 |
if (path.exists()) {
|
228 |
for (File file : path.listFiles()) { |
229 |
if (file.isDirectory()) {
|
230 |
deleteContents(file); |
231 |
} else {
|
232 |
attemptToDelete(file); |
233 |
} |
234 |
} |
235 |
} |
236 |
} |
237 |
|
238 |
public void deleteRepo() { |
239 |
deleteContents(repoDir); |
240 |
} |
241 |
|
242 |
public void copyApksToRepo() { |
243 |
copyApksToRepo(new ArrayList<>(apps.keySet())); |
244 |
} |
245 |
|
246 |
public void copyApksToRepo(List<String> appsToCopy) { |
247 |
for (final String packageName : appsToCopy) { |
248 |
final App app = apps.get(packageName);
|
249 |
|
250 |
if (app.installedApk != null) { |
251 |
SanitizedFile outFile = new SanitizedFile(repoDir, app.installedApk.apkName);
|
252 |
if (Utils.symlinkOrCopyFileQuietly(app.installedApk.installedFile, outFile))
|
253 |
continue;
|
254 |
} |
255 |
// if we got here, something went wrong
|
256 |
throw new IllegalStateException("Unable to copy APK"); |
257 |
} |
258 |
} |
259 |
|
260 |
public void addApp(Context context, String packageName) { |
261 |
App app; |
262 |
try {
|
263 |
app = new App(context.getApplicationContext(), pm, packageName);
|
264 |
if (!app.isValid())
|
265 |
return;
|
266 |
PackageInfo packageInfo = pm.getPackageInfo(packageName, PackageManager.GET_META_DATA); |
267 |
app.icon = getIconFile(packageName, packageInfo.versionCode).getName(); |
268 |
} catch (PackageManager.NameNotFoundException | CertificateEncodingException | IOException e) { |
269 |
Log.e(TAG, "Error adding app to local repo", e);
|
270 |
return;
|
271 |
} |
272 |
Utils.DebugLog(TAG, "apps.put: " + packageName);
|
273 |
apps.put(packageName, app); |
274 |
} |
275 |
|
276 |
public List<String> getApps() { |
277 |
return new ArrayList<>(apps.keySet()); |
278 |
} |
279 |
|
280 |
public void copyIconsToRepo() { |
281 |
ApplicationInfo appInfo; |
282 |
for (final App app : apps.values()) { |
283 |
if (app.installedApk != null) { |
284 |
try {
|
285 |
appInfo = pm.getApplicationInfo(app.id, PackageManager.GET_META_DATA); |
286 |
copyIconToRepo(appInfo.loadIcon(pm), app.id, app.installedApk.vercode); |
287 |
} catch (PackageManager.NameNotFoundException e) {
|
288 |
Log.e(TAG, "Error getting app icon", e);
|
289 |
} |
290 |
} |
291 |
} |
292 |
} |
293 |
|
294 |
/**
|
295 |
* Extracts the icon from an APK and writes it to the repo as a PNG
|
296 |
*/
|
297 |
public void copyIconToRepo(Drawable drawable, String packageName, int versionCode) { |
298 |
Bitmap bitmap; |
299 |
if (drawable instanceof BitmapDrawable) { |
300 |
bitmap = ((BitmapDrawable) drawable).getBitmap(); |
301 |
} else {
|
302 |
bitmap = Bitmap.createBitmap(drawable.getIntrinsicWidth(), |
303 |
drawable.getIntrinsicHeight(), Config.ARGB_8888); |
304 |
Canvas canvas = new Canvas(bitmap); |
305 |
drawable.setBounds(0, 0, canvas.getWidth(), canvas.getHeight()); |
306 |
drawable.draw(canvas); |
307 |
} |
308 |
File png = getIconFile(packageName, versionCode);
|
309 |
OutputStream out;
|
310 |
try {
|
311 |
out = new BufferedOutputStream(new FileOutputStream(png)); |
312 |
bitmap.compress(CompressFormat.PNG, 100, out);
|
313 |
out.close(); |
314 |
} catch (Exception e) { |
315 |
Log.e(TAG, "Error copying icon to repo", e);
|
316 |
} |
317 |
} |
318 |
|
319 |
private File getIconFile(String packageName, int versionCode) { |
320 |
return new File(iconsDir, packageName + "_" + versionCode + ".png"); |
321 |
} |
322 |
|
323 |
/**
|
324 |
* Helper class to aid in constructing index.xml file.
|
325 |
* It uses the PullParser API, because the DOM api is only able to be serialized from
|
326 |
* API 8 upwards, but we support 7 at time of implementation.
|
327 |
*/
|
328 |
public static class IndexXmlBuilder { |
329 |
|
330 |
@NonNull
|
331 |
private final XmlSerializer serializer; |
332 |
|
333 |
@NonNull
|
334 |
private final Map<String, App> apps; |
335 |
|
336 |
@NonNull
|
337 |
private final Context context; |
338 |
|
339 |
@NonNull
|
340 |
private final DateFormat dateToStr = new SimpleDateFormat("yyyy-MM-dd", Locale.US); |
341 |
|
342 |
public IndexXmlBuilder(@NonNull Context context, @NonNull Map<String, App> apps) throws XmlPullParserException, IOException { |
343 |
this.context = context;
|
344 |
this.apps = apps;
|
345 |
serializer = XmlPullParserFactory.newInstance().newSerializer(); |
346 |
} |
347 |
|
348 |
public void build(Writer output) throws IOException, LocalRepoKeyStore.InitException { |
349 |
serializer.setOutput(output); |
350 |
serializer.startDocument(null, null); |
351 |
tagFdroid(); |
352 |
serializer.endDocument(); |
353 |
} |
354 |
|
355 |
private void tagFdroid() throws IOException, LocalRepoKeyStore.InitException { |
356 |
serializer.startTag("", "fdroid"); |
357 |
tagRepo(); |
358 |
for (Map.Entry<String, App> entry : apps.entrySet()) { |
359 |
tagApplication(entry.getValue()); |
360 |
} |
361 |
serializer.endTag("", "fdroid"); |
362 |
} |
363 |
|
364 |
private void tagRepo() throws IOException, LocalRepoKeyStore.InitException { |
365 |
|
366 |
SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); |
367 |
|
368 |
// max age is an EditTextPreference, which is always a String
|
369 |
int repoMaxAge = Float.valueOf(prefs.getString("max_repo_age_days", DEFAULT_REPO_MAX_AGE_DAYS)).intValue(); |
370 |
|
371 |
serializer.startTag("", "repo"); |
372 |
|
373 |
serializer.attribute("", "icon", "blah.png"); |
374 |
serializer.attribute("", "maxage", String.valueOf(repoMaxAge)); |
375 |
serializer.attribute("", "name", Preferences.get().getLocalRepoName() + " on " + FDroidApp.ipAddressString); |
376 |
serializer.attribute("", "pubkey", Hasher.hex(LocalRepoKeyStore.get(context).getCertificate())); |
377 |
long timestamp = System.currentTimeMillis() / 1000L; |
378 |
serializer.attribute("", "timestamp", String.valueOf(timestamp)); |
379 |
|
380 |
tag("description", "A local FDroid repo generated from apps installed on " + Preferences.get().getLocalRepoName()); |
381 |
|
382 |
serializer.endTag("", "repo"); |
383 |
|
384 |
} |
385 |
|
386 |
/**
|
387 |
* Helper function to start a tag called "name", fill it with text "text", and then
|
388 |
* end the tag in a more concise manner.
|
389 |
*/
|
390 |
private void tag(String name, String text) throws IOException { |
391 |
serializer.startTag("", name).text(text).endTag("", name); |
392 |
} |
393 |
|
394 |
/**
|
395 |
* Alias for {@link org.fdroid.fdroid.localrepo.LocalRepoManager.IndexXmlBuilder#tag(String, String)}
|
396 |
* That accepts a number instead of string.
|
397 |
* @see IndexXmlBuilder#tag(String, String)
|
398 |
*/
|
399 |
private void tag(String name, long number) throws IOException { |
400 |
tag(name, String.valueOf(number));
|
401 |
} |
402 |
|
403 |
/**
|
404 |
* Alias for {@link org.fdroid.fdroid.localrepo.LocalRepoManager.IndexXmlBuilder#tag(String, String)}
|
405 |
* that accepts a date instead of a string.
|
406 |
* @see IndexXmlBuilder#tag(String, String)
|
407 |
*/
|
408 |
private void tag(String name, Date date) throws IOException { |
409 |
tag(name, dateToStr.format(date)); |
410 |
} |
411 |
|
412 |
private void tagApplication(App app) throws IOException { |
413 |
serializer.startTag("", "application"); |
414 |
serializer.attribute("", "id", app.id); |
415 |
|
416 |
tag("id", app.id);
|
417 |
tag("added", app.added);
|
418 |
tag("lastupdated", app.lastUpdated);
|
419 |
tag("name", app.name);
|
420 |
tag("summary", app.summary);
|
421 |
tag("icon", app.icon);
|
422 |
tag("desc", app.description);
|
423 |
tag("license", "Unknown"); |
424 |
tag("categories", "LocalRepo," + Preferences.get().getLocalRepoName()); |
425 |
tag("category", "LocalRepo," + Preferences.get().getLocalRepoName()); |
426 |
tag("web", "web"); |
427 |
tag("source", "source"); |
428 |
tag("tracker", "tracker"); |
429 |
tag("marketversion", app.installedApk.version);
|
430 |
tag("marketvercode", app.installedApk.vercode);
|
431 |
|
432 |
tagPackage(app); |
433 |
|
434 |
serializer.endTag("", "application"); |
435 |
} |
436 |
|
437 |
private void tagPackage(App app) throws IOException { |
438 |
serializer.startTag("", "package"); |
439 |
|
440 |
tag("version", app.installedApk.version);
|
441 |
tag("versioncode", app.installedApk.vercode);
|
442 |
tag("apkname", app.installedApk.apkName);
|
443 |
tagHash(app); |
444 |
tag("sig", app.installedApk.sig.toLowerCase(Locale.US)); |
445 |
tag("size", app.installedApk.installedFile.length());
|
446 |
tag("sdkver", app.installedApk.minSdkVersion);
|
447 |
tag("maxsdkver", app.installedApk.maxSdkVersion);
|
448 |
tag("added", app.installedApk.added);
|
449 |
tagFeatures(app); |
450 |
tagPermissions(app); |
451 |
|
452 |
serializer.endTag("", "package"); |
453 |
} |
454 |
|
455 |
private void tagPermissions(App app) throws IOException { |
456 |
serializer.startTag("", "permissions"); |
457 |
if (app.installedApk.permissions != null) { |
458 |
StringBuilder buff = new StringBuilder(); |
459 |
|
460 |
for (String permission : app.installedApk.permissions) { |
461 |
buff.append(permission.replace("android.permission.", "")); |
462 |
buff.append(',');
|
463 |
} |
464 |
String out = buff.toString();
|
465 |
if (!TextUtils.isEmpty(out))
|
466 |
serializer.text(out.substring(0, out.length() - 1)); |
467 |
} |
468 |
serializer.endTag("", "permissions"); |
469 |
} |
470 |
|
471 |
private void tagFeatures(App app) throws IOException { |
472 |
serializer.startTag("", "features"); |
473 |
if (app.installedApk.features != null) |
474 |
serializer.text(Utils.CommaSeparatedList.str(app.installedApk.features)); |
475 |
serializer.endTag("", "features"); |
476 |
} |
477 |
|
478 |
private void tagHash(App app) throws IOException { |
479 |
serializer.startTag("", "hash"); |
480 |
serializer.attribute("", "type", app.installedApk.hashType); |
481 |
serializer.text(app.installedApk.hash.toLowerCase(Locale.US));
|
482 |
serializer.endTag("", "hash"); |
483 |
} |
484 |
} |
485 |
|
486 |
public void writeIndexJar() throws IOException { |
487 |
try {
|
488 |
new IndexXmlBuilder(context, apps).build(new FileWriter(xmlIndex)); |
489 |
} catch (Exception e) { |
490 |
Log.e(TAG, "Could not write index jar", e);
|
491 |
Toast.makeText(context, R.string.failed_to_create_index, Toast.LENGTH_LONG).show(); |
492 |
return;
|
493 |
} |
494 |
|
495 |
BufferedOutputStream bo = new BufferedOutputStream(new FileOutputStream(xmlIndexJarUnsigned)); |
496 |
JarOutputStream jo = new JarOutputStream(bo); |
497 |
|
498 |
BufferedInputStream bi = new BufferedInputStream(new FileInputStream(xmlIndex)); |
499 |
|
500 |
JarEntry je = new JarEntry("index.xml"); |
501 |
jo.putNextEntry(je); |
502 |
|
503 |
byte[] buf = new byte[1024]; |
504 |
int bytesRead;
|
505 |
|
506 |
while ((bytesRead = bi.read(buf)) != -1) { |
507 |
jo.write(buf, 0, bytesRead);
|
508 |
} |
509 |
|
510 |
bi.close(); |
511 |
jo.close(); |
512 |
bo.close(); |
513 |
|
514 |
try {
|
515 |
LocalRepoKeyStore.get(context).signZip(xmlIndexJarUnsigned, xmlIndexJar); |
516 |
} catch (LocalRepoKeyStore.InitException e) {
|
517 |
throw new IOException("Could not sign index - keystore failed to initialize"); |
518 |
} finally {
|
519 |
attemptToDelete(xmlIndexJarUnsigned); |
520 |
} |
521 |
|
522 |
} |
523 |
|
524 |
} |