package org.fdroid.fdroid.localrepo;
import android.content.Context;
import android.content.pm.ApplicationInfo;
import android.content.pm.PackageManager;
import android.content.res.AssetManager;
import android.graphics.Bitmap;
import android.graphics.Bitmap.CompressFormat;
import android.graphics.Bitmap.Config;
import android.graphics.Canvas;
import android.graphics.drawable.BitmapDrawable;
import android.graphics.drawable.Drawable;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import android.text.TextUtils;
import android.util.Log;
import org.fdroid.fdroid.FDroidApp;
import org.fdroid.fdroid.Hasher;
import org.fdroid.fdroid.Preferences;
import org.fdroid.fdroid.Utils;
import org.fdroid.fdroid.data.Apk;
import org.fdroid.fdroid.data.App;
import org.fdroid.fdroid.data.SanitizedFile;
import org.xmlpull.v1.XmlPullParserException;
import org.xmlpull.v1.XmlPullParserFactory;
import org.xmlpull.v1.XmlSerializer;
import java.io.BufferedOutputStream;
import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.io.OutputStreamWriter;
import java.security.cert.CertificateEncodingException;
import java.text.DateFormat;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.jar.JarEntry;
import java.util.jar.JarOutputStream;
/**
* The {@link SwapService} deals with managing the entire workflow from selecting apps to
* swap, to invoking this class to prepare the webroot, to enabling various communication protocols.
* This class deals specifically with the webroot side of things, ensuring we have a valid index.jar
* and the relevant .apk and icon files available.
*/
public final class LocalRepoManager {
private static final String TAG = "LocalRepoManager";
private final Context context;
private final PackageManager pm;
private final AssetManager assetManager;
private final String fdroidPackageName;
private static final String[] WEB_ROOT_ASSET_FILES = {
"swap-icon.png",
"swap-tick-done.png",
"swap-tick-not-done.png",
};
private final Map<String, App> apps = new HashMap<>();
private final SanitizedFile xmlIndexJar;
private final SanitizedFile xmlIndexJarUnsigned;
private final SanitizedFile webRoot;
private final SanitizedFile fdroidDir;
private final SanitizedFile fdroidDirCaps;
private final SanitizedFile repoDir;
private final SanitizedFile repoDirCaps;
private final SanitizedFile iconsDir;
@Nullable
private static LocalRepoManager localRepoManager;
@NonNull
public static LocalRepoManager get(Context context) {
if (localRepoManager == null) {
localRepoManager = new LocalRepoManager(context);
}
return localRepoManager;
}
private LocalRepoManager(Context c) {
context = c.getApplicationContext();
pm = c.getPackageManager();
assetManager = c.getAssets();
fdroidPackageName = c.getPackageName();
webRoot = SanitizedFile.knownSanitized(c.getFilesDir());
/* /fdroid/repo is the standard path for user repos */
fdroidDir = new SanitizedFile(webRoot, "fdroid");
fdroidDirCaps = new SanitizedFile(webRoot, "FDROID");
repoDir = new SanitizedFile(fdroidDir, "repo");
repoDirCaps = new SanitizedFile(fdroidDirCaps, "REPO");
iconsDir = new SanitizedFile(repoDir, "icons");
xmlIndexJar = new SanitizedFile(repoDir, "index.jar");
xmlIndexJarUnsigned = new SanitizedFile(repoDir, "index.unsigned.jar");
if (!fdroidDir.exists() && !fdroidDir.mkdir()) {
Log.e(TAG, "Unable to create empty base: " + fdroidDir);
}
if (!repoDir.exists() && !repoDir.mkdir()) {
Log.e(TAG, "Unable to create empty repo: " + repoDir);
}
if (!iconsDir.exists() && !iconsDir.mkdir()) {
Log.e(TAG, "Unable to create icons folder: " + iconsDir);
}
}
private String writeFdroidApkToWebroot() {
ApplicationInfo appInfo;
String fdroidClientURL = "https://f-droid.org/FDroid.apk";
try {
appInfo = pm.getApplicationInfo(fdroidPackageName, PackageManager.GET_META_DATA);
SanitizedFile apkFile = SanitizedFile.knownSanitized(appInfo.publicSourceDir);
SanitizedFile fdroidApkLink = new SanitizedFile(webRoot, "fdroid.client.apk");
attemptToDelete(fdroidApkLink);
if (Utils.symlinkOrCopyFileQuietly(apkFile, fdroidApkLink)) {
fdroidClientURL = "/" + fdroidApkLink.getName();
}
} catch (PackageManager.NameNotFoundException e) {
Log.e(TAG, "Could not set up F-Droid apk in the webroot", e);
}
return fdroidClientURL;
}
public void writeIndexPage(String repoAddress) {
final String fdroidClientURL = writeFdroidApkToWebroot();
try {
File indexHtml = new File(webRoot, "index.html");
BufferedReader in = new BufferedReader(
new InputStreamReader(assetManager.open("index.template.html"), "UTF-8"));
BufferedWriter out = new BufferedWriter(new OutputStreamWriter(
new FileOutputStream(indexHtml)));
String line;
while ((line = in.readLine()) != null) {
line = line.replaceAll("\\{\\{REPO_URL\\}\\}", repoAddress);
line = line.replaceAll("\\{\\{CLIENT_URL\\}\\}", fdroidClientURL);
out.write(line);
}
in.close();
out.close();
for (final String file : WEB_ROOT_ASSET_FILES) {
InputStream assetIn = assetManager.open(file);
OutputStream assetOut = new FileOutputStream(new File(webRoot, file));
Utils.copy(assetIn, assetOut);
assetIn.close();
assetOut.close();
}
// make symlinks/copies in each subdir of the repo to make sure that
// the user will always find the bootstrap page.
symlinkEntireWebRootElsewhere("../", fdroidDir);
symlinkEntireWebRootElsewhere("../../", repoDir);
// add in /FDROID/REPO to support bad QR Scanner apps
attemptToMkdir(fdroidDirCaps);
attemptToMkdir(repoDirCaps);
symlinkEntireWebRootElsewhere("../", fdroidDirCaps);
symlinkEntireWebRootElsewhere("../../", repoDirCaps);
} catch (IOException e) {
Log.e(TAG, "Error writing local repo index", e);
}
}
private static void attemptToMkdir(@NonNull File dir) throws IOException {
if (dir.exists()) {
if (dir.isDirectory()) {
return;
}
throw new IOException("Can't make directory " + dir + " - it is already a file.");
}
if (!dir.mkdir()) {
throw new IOException("An error occurred trying to create directory " + dir);
}
}
private static void attemptToDelete(@NonNull File file) {
if (!file.delete()) {
Log.e(TAG, "Could not delete \"" + file.getAbsolutePath() + "\".");
}
}
private void symlinkEntireWebRootElsewhere(String symlinkPrefix, File directory) {
symlinkFileElsewhere("index.html", symlinkPrefix, directory);
for (final String fileName : WEB_ROOT_ASSET_FILES) {
symlinkFileElsewhere(fileName, symlinkPrefix, directory);
}
}
private void symlinkFileElsewhere(String fileName, String symlinkPrefix, File directory) {
SanitizedFile index = new SanitizedFile(directory, fileName);
attemptToDelete(index);
Utils.symlinkOrCopyFileQuietly(new SanitizedFile(new File(directory, symlinkPrefix), fileName), index);
}
private void deleteContents(File path) {
if (path.exists()) {
for (File file : path.listFiles()) {
if (file.isDirectory()) {
deleteContents(file);
} else {
attemptToDelete(file);
}
}
}
}
/**
* Get the {@code index.jar} file that represents the local swap repo.
*/
public File getIndexJar() {
return xmlIndexJar;
}
public void deleteRepo() {
deleteContents(repoDir);
}
public void copyApksToRepo() {
copyApksToRepo(new ArrayList<>(apps.keySet()));
}
private void copyApksToRepo(List<String> appsToCopy) {
for (final String packageName : appsToCopy) {
final App app = apps.get(packageName);
if (app.installedApk != null) {
SanitizedFile outFile = new SanitizedFile(repoDir, app.installedApk.apkName);
if (Utils.symlinkOrCopyFileQuietly(app.installedApk.installedFile, outFile)) {
continue;
}
}
// if we got here, something went wrong
throw new IllegalStateException("Unable to copy APK");
}
}
public void addApp(Context context, String packageName) {
App app;
try {
app = SwapService.getAppFromCache(packageName);
if (app == null) {
app = new App(context.getApplicationContext(), pm, packageName);
}
if (!app.isValid()) {
return;
}
} catch (PackageManager.NameNotFoundException | CertificateEncodingException | IOException e) {
Log.e(TAG, "Error adding app to local repo", e);
return;
}
Utils.debugLog(TAG, "apps.put: " + packageName);
apps.put(packageName, app);
}
public void copyIconsToRepo() {
ApplicationInfo appInfo;
for (final App app : apps.values()) {
if (app.installedApk != null) {
try {
appInfo = pm.getApplicationInfo(app.packageName, PackageManager.GET_META_DATA);
copyIconToRepo(appInfo.loadIcon(pm), app.packageName, app.installedApk.versionCode);
} catch (PackageManager.NameNotFoundException e) {
Log.e(TAG, "Error getting app icon", e);
}
}
}
}
/**
* Extracts the icon from an APK and writes it to the repo as a PNG
*/
private void copyIconToRepo(Drawable drawable, String packageName, int versionCode) {
Bitmap bitmap;
if (drawable instanceof BitmapDrawable) {
bitmap = ((BitmapDrawable) drawable).getBitmap();
} else {
bitmap = Bitmap.createBitmap(drawable.getIntrinsicWidth(),
drawable.getIntrinsicHeight(), Config.ARGB_8888);
Canvas canvas = new Canvas(bitmap);
drawable.setBounds(0, 0, canvas.getWidth(), canvas.getHeight());
drawable.draw(canvas);
}
File png = getIconFile(packageName, versionCode);
OutputStream out;
try {
out = new BufferedOutputStream(new FileOutputStream(png));
bitmap.compress(CompressFormat.PNG, 100, out);
out.close();
} catch (Exception e) {
Log.e(TAG, "Error copying icon to repo", e);
}
}
private File getIconFile(String packageName, int versionCode) {
return new File(iconsDir, App.getIconName(packageName, versionCode));
}
/**
* Helper class to aid in constructing index.xml file.
*/
public static final class IndexXmlBuilder {
@NonNull
private final XmlSerializer serializer;
@NonNull
private final DateFormat dateToStr = new SimpleDateFormat("yyyy-MM-dd", Locale.US);
private IndexXmlBuilder() throws XmlPullParserException {
serializer = XmlPullParserFactory.newInstance().newSerializer();
}
public void build(Context context, Map<String, App> apps, OutputStream output) throws IOException, LocalRepoKeyStore.InitException {
serializer.setOutput(output, "UTF-8");
serializer.startDocument(null, null);
serializer.startTag("", "fdroid");
// <repo> block
serializer.startTag("", "repo");
serializer.attribute("", "icon", "blah.png");
serializer.attribute("", "name", Preferences.get().getLocalRepoName() + " on " + FDroidApp.ipAddressString);
serializer.attribute("", "pubkey", Hasher.hex(LocalRepoKeyStore.get(context).getCertificate()));
long timestamp = System.currentTimeMillis() / 1000L;
serializer.attribute("", "timestamp", String.valueOf(timestamp));
serializer.attribute("", "version", "10");
tag("description", "A local FDroid repo generated from apps installed on " + Preferences.get().getLocalRepoName());
serializer.endTag("", "repo");
// <application> blocks
for (Map.Entry<String, App> entry : apps.entrySet()) {
tagApplication(entry.getValue());
}
serializer.endTag("", "fdroid");
serializer.endDocument();
output.close();
}
/**
* Helper function to start a tag called "name", fill it with text "text", and then
* end the tag in a more concise manner.
*/
private void tag(String name, String text) throws IOException {
serializer.startTag("", name).text(text).endTag("", name);
}
/**
* Alias for {@link org.fdroid.fdroid.localrepo.LocalRepoManager.IndexXmlBuilder#tag(String, String)}
* That accepts a number instead of string.
*
* @see IndexXmlBuilder#tag(String, String)
*/
private void tag(String name, long number) throws IOException {
tag(name, String.valueOf(number));
}
/**
* Alias for {@link org.fdroid.fdroid.localrepo.LocalRepoManager.IndexXmlBuilder#tag(String, String)}
* that accepts a date instead of a string.
*
* @see IndexXmlBuilder#tag(String, String)
*/
private void tag(String name, Date date) throws IOException {
tag(name, dateToStr.format(date));
}
private void tagApplication(App app) throws IOException {
serializer.startTag("", "application");
serializer.attribute("", "id", app.packageName);
tag("id", app.packageName);
tag("added", app.added);
tag("lastupdated", app.lastUpdated);
tag("name", app.name);
tag("summary", app.summary);
tag("icon", app.icon);
tag("desc", app.description);
tag("license", "Unknown");
tag("categories", "LocalRepo," + Preferences.get().getLocalRepoName());
tag("category", "LocalRepo," + Preferences.get().getLocalRepoName());
tag("web", "web");
tag("source", "source");
tag("tracker", "tracker");
tag("marketversion", app.installedApk.versionName);
tag("marketvercode", app.installedApk.versionCode);
tagPackage(app);
serializer.endTag("", "application");
}
private void tagPackage(App app) throws IOException {
serializer.startTag("", "package");
tag("version", app.installedApk.versionName);
tag("versioncode", app.installedApk.versionCode);
tag("apkname", app.installedApk.apkName);
tagHash(app);
tag("sig", app.installedApk.sig.toLowerCase(Locale.US));
tag("size", app.installedApk.installedFile.length());
tag("added", app.installedApk.added);
if (app.installedApk.minSdkVersion > Apk.SDK_VERSION_MIN_VALUE) {
tag("sdkver", app.installedApk.minSdkVersion);
}
if (app.installedApk.targetSdkVersion > app.installedApk.minSdkVersion) {
tag("targetSdkVersion", app.installedApk.targetSdkVersion);
}
if (app.installedApk.maxSdkVersion < Apk.SDK_VERSION_MAX_VALUE) {
tag("maxsdkver", app.installedApk.maxSdkVersion);
}
tagFeatures(app);
tagPermissions(app);
tagNativecode(app);
serializer.endTag("", "package");
}
private void tagPermissions(App app) throws IOException {
serializer.startTag("", "permissions");
if (app.installedApk.requestedPermissions != null) {
StringBuilder buff = new StringBuilder();
for (String permission : app.installedApk.requestedPermissions) {
buff.append(permission.replace("android.permission.", ""));
buff.append(',');
}
String out = buff.toString();
if (!TextUtils.isEmpty(out)) {
serializer.text(out.substring(0, out.length() - 1));
}
}
serializer.endTag("", "permissions");
}
private void tagFeatures(App app) throws IOException {
serializer.startTag("", "features");
if (app.installedApk.features != null) {
serializer.text(TextUtils.join(",", app.installedApk.features));
}
serializer.endTag("", "features");
}
private void tagNativecode(App app) throws IOException {
if (app.installedApk.nativecode != null) {
serializer.startTag("", "nativecode");
serializer.text(TextUtils.join(",", app.installedApk.nativecode));
serializer.endTag("", "nativecode");
}
}
private void tagHash(App app) throws IOException {
serializer.startTag("", "hash");
serializer.attribute("", "type", app.installedApk.hashType);
serializer.text(app.installedApk.hash);
serializer.endTag("", "hash");
}
}
public void writeIndexJar() throws IOException, XmlPullParserException, LocalRepoKeyStore.InitException {
BufferedOutputStream bo = new BufferedOutputStream(new FileOutputStream(xmlIndexJarUnsigned));
JarOutputStream jo = new JarOutputStream(bo);
JarEntry je = new JarEntry("index.xml");
jo.putNextEntry(je);
new IndexXmlBuilder().build(context, apps, jo);
jo.close();
bo.close();
try {
LocalRepoKeyStore.get(context).signZip(xmlIndexJarUnsigned, xmlIndexJar);
} catch (LocalRepoKeyStore.InitException e) {
throw new IOException("Could not sign index - keystore failed to initialize");
} finally {
attemptToDelete(xmlIndexJarUnsigned);
}
}
}