From a9c88bb5d76d72a1fc2ddd92f89a5d351540f686 Mon Sep 17 00:00:00 2001 From: Hans-Christoph Steiner Date: Fri, 2 May 2014 20:37:52 -0400 Subject: [PATCH] build a local repo from a list of installed APKs Wire up the "Setup Repo" button on the Local Repo view to generate an FDroid repo on the device that is hosted with the local webserver. This also generates an index.html for when people navigate to the local repo via a browser. This index.html will allow them to both download FDroid and to setup the repo on the device running the webserver on the local FDroid. refs #3204 https://dev.guardianproject.info/issues/3204 refs #2668 https://dev.guardianproject.info/issues/2668 --- assets/index.template.html | 65 ++ res/menu/local_repo_activity.xml | 5 + res/values/strings.xml | 7 + src/org/fdroid/fdroid/FDroidApp.java | 4 + src/org/fdroid/fdroid/Utils.java | 70 +- src/org/fdroid/fdroid/data/Apk.java | 6 +- src/org/fdroid/fdroid/data/App.java | 9 +- .../fdroid/localrepo/LocalRepoManager.java | 675 ++++++++++++++++++ .../fdroid/net/WifiStateChangeService.java | 4 + .../fdroid/views/LocalRepoActivity.java | 91 ++- 10 files changed, 910 insertions(+), 26 deletions(-) create mode 100644 assets/index.template.html create mode 100644 src/org/fdroid/fdroid/localrepo/LocalRepoManager.java diff --git a/assets/index.template.html b/assets/index.template.html new file mode 100644 index 000000000..fe48db91f --- /dev/null +++ b/assets/index.template.html @@ -0,0 +1,65 @@ + + + + + {{REPO_URL}} local FDroid repo + + + + + + + + +

Kerplapp Bootstrap

+
    +
  1. Find a Kerplapp Repo
  2. +
  3. Download F-Droid client
  4. +
  5. Install F-Droid client
  6. +
  7. Add Kerplapp Repo to F-Droid client
  8. +
  9. Kerplapp an App!
  10. +
+ + diff --git a/res/menu/local_repo_activity.xml b/res/menu/local_repo_activity.xml index 823701dac..462e4fe64 100644 --- a/res/menu/local_repo_activity.xml +++ b/res/menu/local_repo_activity.xml @@ -1,6 +1,11 @@ + Discovering local FDroid repos… Your local FDroid repo is accessible. Touch to setup your local repo. + Updating… + Deleting current repo… + Adding %s to repo… + Writing raw index file (index.xml)… + Linking APKs into the repo… + Copying app icons into the repo… + Finished updating local repo Fingerprint: WiFi Network: Enable WiFi diff --git a/src/org/fdroid/fdroid/FDroidApp.java b/src/org/fdroid/fdroid/FDroidApp.java index 10c51528f..1b5bfcf35 100644 --- a/src/org/fdroid/fdroid/FDroidApp.java +++ b/src/org/fdroid/fdroid/FDroidApp.java @@ -55,6 +55,7 @@ import org.fdroid.fdroid.compat.PRNGFixes; import org.fdroid.fdroid.data.AppProvider; import org.fdroid.fdroid.data.InstalledAppCacheUpdater; import org.fdroid.fdroid.data.Repo; +import org.fdroid.fdroid.localrepo.LocalRepoManager; import org.fdroid.fdroid.localrepo.LocalRepoService; import org.fdroid.fdroid.net.WifiStateChangeService; import org.thoughtcrime.ssl.pinning.PinningTrustManager; @@ -83,6 +84,7 @@ public class FDroidApp extends Application { public static String ssid = ""; public static String bssid = ""; public static Repo repo = new Repo(); + public static LocalRepoManager localRepo = null; static Set selectedApps = new HashSet(); private static Messenger localRepoServiceMessenger = null; @@ -125,6 +127,8 @@ public class FDroidApp extends Application { //Apply the Google PRNG fixes to properly seed SecureRandom PRNGFixes.apply(); + localRepo = new LocalRepoManager(getApplicationContext()); + // Check that the installed app cache hasn't gotten out of sync somehow. // e.g. if we crashed/ran out of battery half way through responding // to a package installed intent. It doesn't really matter where diff --git a/src/org/fdroid/fdroid/Utils.java b/src/org/fdroid/fdroid/Utils.java index 6d0957028..8111aace3 100644 --- a/src/org/fdroid/fdroid/Utils.java +++ b/src/org/fdroid/fdroid/Utils.java @@ -20,8 +20,7 @@ package org.fdroid.fdroid; import android.content.Context; import android.net.Uri; -import android.net.wifi.WifiInfo; -import android.net.wifi.WifiManager; +import android.os.Build; import android.text.TextUtils; import android.util.DisplayMetrics; import android.util.Log; @@ -30,13 +29,10 @@ import com.nostra13.universalimageloader.utils.StorageUtils; import org.fdroid.fdroid.data.Repo; -import java.io.Closeable; -import java.io.File; -import java.io.FileReader; -import java.io.IOException; -import java.io.InputStream; -import java.io.OutputStream; +import java.io.*; +import java.math.BigInteger; import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; import java.security.cert.Certificate; import java.security.cert.CertificateEncodingException; import java.text.SimpleDateFormat; @@ -315,4 +311,62 @@ public final class Utils { } } + // this is all new stuff being added + public static String hashBytes(byte[] input, String algo) { + try { + MessageDigest md = MessageDigest.getInstance(algo); + byte[] hashBytes = md.digest(input); + String hash = toHexString(hashBytes); + + md.reset(); + return hash; + } catch (NoSuchAlgorithmException e) { + Log.e("FDroid", "Device does not support " + algo + " MessageDisgest algorithm"); + return null; + } + } + + public static String getBinaryHash(File apk, String algo) { + FileInputStream fis = null; + BufferedInputStream bis = null; + try { + MessageDigest md = MessageDigest.getInstance(algo); + fis = new FileInputStream(apk); + bis = new BufferedInputStream(fis); + + byte[] dataBytes = new byte[524288]; + int nread = 0; + + while ((nread = bis.read(dataBytes)) != -1) + md.update(dataBytes, 0, nread); + + byte[] mdbytes = md.digest(); + return toHexString(mdbytes); + } catch (IOException e) { + Log.e("FDroid", "Error reading \"" + apk.getAbsolutePath() + + "\" to compute " + algo + " hash."); + return null; + } catch (NoSuchAlgorithmException e) { + Log.e("FDroid", "Device does not support " + algo + " MessageDisgest algorithm"); + return null; + } finally { + closeQuietly(fis); + } + } + + /** + * Computes the base 16 representation of the byte array argument. + * + * @param bytes an array of bytes. + * @return the bytes represented as a string of hexadecimal digits. + */ + public static String toHexString(byte[] bytes) { + BigInteger bi = new BigInteger(1, bytes); + return String.format("%0" + (bytes.length << 1) + "X", bi); + } + + public static String getDefaultRepoName() { + return (Build.BRAND + " " + Build.MODEL).replaceAll(" ", "-"); + } + } diff --git a/src/org/fdroid/fdroid/data/Apk.java b/src/org/fdroid/fdroid/data/Apk.java index 95ff15f65..a6d08edb4 100644 --- a/src/org/fdroid/fdroid/data/Apk.java +++ b/src/org/fdroid/fdroid/data/Apk.java @@ -4,7 +4,8 @@ import android.content.ContentValues; import android.database.Cursor; import org.fdroid.fdroid.Utils; -import java.util.*; +import java.io.File; +import java.util.Date; public class Apk extends ValueObject implements Comparable { @@ -31,7 +32,8 @@ public class Apk extends ValueObject implements Comparable { // True if compatible with the device. public boolean compatible; - public String apkName; + public String apkName; // F-Droid style APK name + public File installedFile; // the .apk file on this device's filesystem // If not null, this is the name of the source tarball for the // application. Null indicates that it's a developer's binary diff --git a/src/org/fdroid/fdroid/data/App.java b/src/org/fdroid/fdroid/data/App.java index b5ffb8292..19a3cc889 100644 --- a/src/org/fdroid/fdroid/data/App.java +++ b/src/org/fdroid/fdroid/data/App.java @@ -1,20 +1,20 @@ package org.fdroid.fdroid.data; import android.content.ContentValues; -import android.content.Context; import android.content.pm.ApplicationInfo; -import android.content.pm.PackageInfo; import android.database.Cursor; + import org.fdroid.fdroid.AppFilter; import org.fdroid.fdroid.Utils; import java.util.Date; -import java.util.Map; +import java.util.List; public class App extends ValueObject implements Comparable { // True if compatible with the device (i.e. if at least one apk is) public boolean compatible; + public boolean includeInRepo = false; public String id = "unknown"; public String name = "Unknown"; @@ -83,6 +83,9 @@ public class App extends ValueObject implements Comparable { public int installedVersionCode; + public ApplicationInfo appInfo; + public List apks; + @Override public int compareTo(App app) { return name.compareToIgnoreCase(app.name); diff --git a/src/org/fdroid/fdroid/localrepo/LocalRepoManager.java b/src/org/fdroid/fdroid/localrepo/LocalRepoManager.java new file mode 100644 index 000000000..240216da0 --- /dev/null +++ b/src/org/fdroid/fdroid/localrepo/LocalRepoManager.java @@ -0,0 +1,675 @@ + +package org.fdroid.fdroid.localrepo; + +import android.annotation.TargetApi; +import android.content.Context; +import android.content.SharedPreferences; +import android.content.pm.ApplicationInfo; +import android.content.pm.FeatureInfo; +import android.content.pm.PackageInfo; +import android.content.pm.PackageManager; +import android.content.pm.PackageManager.NameNotFoundException; +import android.content.res.AssetManager; +import android.content.res.XmlResourceParser; +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.os.Build; +import android.preference.PreferenceManager; +import android.text.TextUtils; +import android.util.Log; + +import org.fdroid.fdroid.Utils; +import org.fdroid.fdroid.data.Apk; +import org.fdroid.fdroid.data.App; +import org.w3c.dom.Document; +import org.w3c.dom.Element; +import org.xmlpull.v1.XmlPullParser; +import org.xmlpull.v1.XmlPullParserException; + +import java.io.BufferedOutputStream; +import java.io.BufferedReader; +import java.io.BufferedWriter; +import java.io.File; +import java.io.FileInputStream; +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.Certificate; +import java.security.cert.CertificateEncodingException; +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Date; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Map.Entry; +import java.util.jar.JarEntry; +import java.util.jar.JarFile; + +import javax.xml.parsers.DocumentBuilder; +import javax.xml.parsers.DocumentBuilderFactory; +import javax.xml.transform.Transformer; +import javax.xml.transform.TransformerFactory; +import javax.xml.transform.dom.DOMSource; +import javax.xml.transform.stream.StreamResult; + +public class LocalRepoManager { + private static final String TAG = "LocalRepoManager"; + + // For ref, official F-droid repo presently uses a maxage of 14 days + private static final String DEFAULT_REPO_MAX_AGE_DAYS = "14"; + + private final PackageManager pm; + private final AssetManager assetManager; + private final SharedPreferences prefs; + private final String fdroidPackageName; + + private String ipAddressString = "UNSET"; + private String uriString = "UNSET"; + + private Map apps = new HashMap(); + + public final File xmlIndex; + public final File webRoot; + public final File fdroidDir; + public final File repoDir; + public final File iconsDir; + + public LocalRepoManager(Context c) { + pm = c.getPackageManager(); + assetManager = c.getAssets(); + prefs = PreferenceManager.getDefaultSharedPreferences(c); + fdroidPackageName = c.getPackageName(); + + webRoot = c.getFilesDir(); + /* /fdroid/repo is the standard path for user repos */ + fdroidDir = new File(webRoot, "fdroid"); + repoDir = new File(fdroidDir, "repo"); + iconsDir = new File(repoDir, "icons"); + xmlIndex = new File(repoDir, "index.xml"); + + if (!fdroidDir.exists()) + if (!fdroidDir.mkdir()) + Log.e(TAG, "Unable to create empty base: " + fdroidDir); + + if (!repoDir.exists()) + if (!repoDir.mkdir()) + Log.e(TAG, "Unable to create empty repo: " + repoDir); + + if (!iconsDir.exists()) + if (!iconsDir.mkdir()) + Log.e(TAG, "Unable to create icons folder: " + iconsDir); + } + + public void setUriString(String uriString) { + this.uriString = uriString; + } + + public void writeIndexPage(String repoAddress) { + ApplicationInfo appInfo; + + String fdroidClientURL = "https://f-droid.org/FDroid.apk"; + + try { + appInfo = pm.getApplicationInfo(fdroidPackageName, PackageManager.GET_META_DATA); + File apkFile = new File(appInfo.publicSourceDir); + File fdroidApkLink = new File(webRoot, "fdroid.client.apk"); + fdroidApkLink.delete(); + if (symlinkOrCopyFile(apkFile, fdroidApkLink)) + fdroidClientURL = "/" + fdroidApkLink.getName(); + } catch (NameNotFoundException e) { + e.printStackTrace(); + } + + 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(); + // make symlinks/copies in each subdir of the repo to make sure that + // the user will always find the bootstrap page. + File fdroidDirIndex = new File(fdroidDir, "index.html"); + fdroidDirIndex.delete(); + symlinkOrCopyFile(indexHtml, fdroidDirIndex); + File repoDirIndex = new File(repoDir, "index.html"); + repoDirIndex.delete(); + symlinkOrCopyFile(indexHtml, repoDirIndex); + // add in /FDROID/REPO to support bad QR Scanner apps + File fdroidCAPS = new File(fdroidDir.getParentFile(), "FDROID"); + fdroidCAPS.mkdir(); + File repoCAPS = new File(fdroidCAPS, "REPO"); + repoCAPS.mkdir(); + File fdroidCAPSIndex = new File(fdroidCAPS, "index.html"); + fdroidCAPSIndex.delete(); + symlinkOrCopyFile(indexHtml, fdroidCAPSIndex); + File repoCAPSIndex = new File(repoCAPS, "index.html"); + repoCAPSIndex.delete(); + symlinkOrCopyFile(indexHtml, repoCAPSIndex); + } catch (IOException e) { + e.printStackTrace(); + } + } + + private void deleteContents(File path) { + if (path.exists()) { + for (File file : path.listFiles()) { + if (file.isDirectory()) { + deleteContents(file); + } else { + file.delete(); + } + } + } + } + + public void deleteRepo() { + deleteContents(repoDir); + } + + public void copyApksToRepo() { + copyApksToRepo(new ArrayList(apps.keySet())); + } + + public void copyApksToRepo(List appsToCopy) { + for (String packageName : appsToCopy) { + App app = apps.get(packageName); + + for (Apk apk : app.apks) { + File outFile = new File(repoDir, apk.apkName); + if (!symlinkOrCopyFile(apk.installedFile, outFile)) { + throw new IllegalStateException("Unable to copy APK"); + } + } + } + } + + /** + * use symlinks if they are available, otherwise fall back to copying + */ + public static boolean symlinkOrCopyFile(File inFile, File outFile) { + if (new File("/system/bin/ln").exists()) { + return doSymLink(inFile, outFile); + } else { + return doCopyFile(inFile, outFile); + } + } + + public static boolean doSymLink(File inFile, File outFile) { + int exitCode = -1; + try { + Process sh = Runtime.getRuntime().exec("sh"); + OutputStream out = sh.getOutputStream(); + String command = "/system/bin/ln -s " + inFile.getAbsolutePath() + " " + outFile + + "\nexit\n"; + Log.i(TAG, "Running: " + command); + out.write(command.getBytes("ASCII")); + + final char buf[] = new char[40]; + InputStreamReader reader = new InputStreamReader(sh.getInputStream()); + while (reader.read(buf) != -1) + throw new IOException("stdout: " + new String(buf)); + reader = new InputStreamReader(sh.getErrorStream()); + while (reader.read(buf) != -1) + throw new IOException("stderr: " + new String(buf)); + + exitCode = sh.waitFor(); + } catch (IOException e) { + e.printStackTrace(); + return false; + } catch (InterruptedException e) { + e.printStackTrace(); + return false; + } + Log.i(TAG, "symlink exitcode: " + exitCode); + return exitCode == 0; + } + + public static boolean doCopyFile(File inFile, File outFile) { + InputStream inStream = null; + OutputStream outStream = null; + + try { + inStream = new FileInputStream(inFile); + outStream = new FileOutputStream(outFile); + + return doCopyStream(inStream, outStream); + } catch (IOException e) { + e.printStackTrace(); + return false; + } + } + + public static boolean doCopyStream(InputStream inStream, OutputStream outStream) + { + byte[] buf = new byte[1024]; + int readBytes; + try + { + while ((readBytes = inStream.read(buf)) > 0) { + outStream.write(buf, 0, readBytes); + } + inStream.close(); + outStream.close(); + return true; + } catch (IOException e) { + e.printStackTrace(); + return false; + } + } + + public interface ScanListener { + public void processedApp(String packageName, int index, int total); + } + + @TargetApi(9) + public App addApp(Context context, String packageName) { + ApplicationInfo appInfo; + PackageInfo packageInfo; + try { + appInfo = pm.getApplicationInfo(packageName, PackageManager.GET_META_DATA); + packageInfo = pm.getPackageInfo(packageName, PackageManager.GET_SIGNATURES + | PackageManager.GET_PERMISSIONS); + } catch (NameNotFoundException e) { + e.printStackTrace(); + return null; + } + + App app = new App(); + app.name = (String) appInfo.loadLabel(pm); + app.summary = (String) appInfo.loadDescription(pm); + app.icon = getIconFile(packageName, packageInfo.versionCode).getName(); + app.id = appInfo.packageName; + if (Build.VERSION.SDK_INT > 8) { + app.added = new Date(packageInfo.firstInstallTime); + app.lastUpdated = new Date(packageInfo.lastUpdateTime); + } else { + app.added = new Date(System.currentTimeMillis()); + app.lastUpdated = app.added; + } + app.appInfo = appInfo; + app.apks = new ArrayList(); + + // TODO: use pm.getInstallerPackageName(packageName) for something + + File apkFile = new File(appInfo.publicSourceDir); + Apk apk = new Apk(); + apk.version = packageInfo.versionName; + apk.vercode = packageInfo.versionCode; + apk.hashType = "sha256"; + apk.hash = Utils.getBinaryHash(apkFile, apk.hashType); + apk.added = app.added; + apk.minSdkVersion = getMinSdkVersion(context, packageName); + apk.id = app.id; + apk.installedFile = apkFile; + if (packageInfo.requestedPermissions == null) + apk.permissions = null; + else + apk.permissions = Utils.CommaSeparatedList.make( + Arrays.asList(packageInfo.requestedPermissions)); + apk.apkName = apk.id + "_" + apk.vercode + ".apk"; + + FeatureInfo[] features = packageInfo.reqFeatures; + + if (features != null && features.length > 0) { + List featureNames = new ArrayList(features.length); + + for (int i = 0; i < features.length; i++) + featureNames.add(features[i].name); + + apk.features = Utils.CommaSeparatedList.make(featureNames); + } + + // Signature[] sigs = pkgInfo.signatures; + + byte[] rawCertBytes; + try { + JarFile apkJar = new JarFile(apkFile); + JarEntry aSignedEntry = (JarEntry) apkJar.getEntry("AndroidManifest.xml"); + + if (aSignedEntry == null) { + apkJar.close(); + return null; + } + + InputStream tmpIn = apkJar.getInputStream(aSignedEntry); + byte[] buff = new byte[2048]; + while (tmpIn.read(buff, 0, buff.length) != -1) { + // NOP - apparently have to READ from the JarEntry before you + // can call + // getCerficates() and have it return != null. Yay Java. + } + tmpIn.close(); + + if (aSignedEntry.getCertificates() == null + || aSignedEntry.getCertificates().length == 0) { + apkJar.close(); + return null; + } + + Certificate signer = aSignedEntry.getCertificates()[0]; + rawCertBytes = signer.getEncoded(); + + apkJar.close(); + + /* + * I don't fully understand the loop used here. I've copied it + * verbatim from getsig.java bundled with FDroidServer. I *believe* + * it is taking the raw byte encoding of the certificate & + * converting it to a byte array of the hex representation of the + * original certificate byte array. This is then MD5 sum'd. It's a + * really bad way to be doing this if I'm right... If I'm not right, + * I really don't know! see lines 67->75 in getsig.java bundled with + * Fdroidserver + */ + byte[] fdroidSig = new byte[rawCertBytes.length * 2]; + for (int j = 0; j < rawCertBytes.length; j++) { + byte v = rawCertBytes[j]; + int d = (v >> 4) & 0xF; + fdroidSig[j * 2] = (byte) (d >= 10 ? ('a' + d - 10) : ('0' + d)); + d = v & 0xF; + fdroidSig[j * 2 + 1] = (byte) (d >= 10 ? ('a' + d - 10) : ('0' + d)); + } + apk.sig = Utils.hashBytes(fdroidSig, "md5"); + + } catch (CertificateEncodingException e) { + return null; + } catch (IOException e) { + return null; + } + + app.apks.add(apk); + + if (!validApp(app)) + return null; + + apps.put(packageName, app); + return app; + } + + /* PackageManager doesn't give us minSdkVersion, so we have to parse it */ + public int getMinSdkVersion(Context context, String packageName) { + try { + AssetManager am = context.createPackageContext(packageName, 0).getAssets(); + XmlResourceParser xml = am.openXmlResourceParser("AndroidManifest.xml"); + int eventType = xml.getEventType(); + while (eventType != XmlPullParser.END_DOCUMENT) { + if (eventType == XmlPullParser.START_TAG) { + if (xml.getName().equals("uses-sdk")) { + for (int j = 0; j < xml.getAttributeCount(); j++) { + if (xml.getAttributeName(j).equals("minSdkVersion")) { + return Integer.parseInt(xml.getAttributeValue(j)); + } + } + } + } + eventType = xml.nextToken(); + } + } catch (NameNotFoundException e) { + e.printStackTrace(); + } catch (IOException e) { + e.printStackTrace(); + } catch (XmlPullParserException e) { + e.printStackTrace(); + } + return 8; // some kind of hopeful default + } + + public void removeApp(String packageName) { + apps.remove(packageName); + } + + public List getApps() { + return new ArrayList(apps.keySet()); + } + + public boolean validApp(App app) { + if (app == null) + return false; + + if (app.name == null || app.name.equals("")) + return false; + + if (app.id == null | app.id.equals("")) + return false; + + if (app.apks == null || app.apks.size() != 1) + return false; + + File apkFile = app.apks.get(0).installedFile; + if (apkFile == null || !apkFile.canRead()) + return false; + + return true; + } + + public void copyIconsToRepo() { + for (App app : apps.values()) { + if (app.apks.size() > 0) { + Apk apk = app.apks.get(0); + copyIconToRepo(app.appInfo.loadIcon(pm), app.id, apk.vercode); + } + } + } + + /** + * Extracts the icon from an APK and writes it to the repo as a PNG + * + * @return path to the PNG file + */ + public 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) { + e.printStackTrace(); + } + } + + private File getIconFile(String packageName, int versionCode) { + return new File(iconsDir, packageName + "_" + versionCode + ".png"); + } + + // TODO this needs to be ported to < android-8 + @TargetApi(8) + public void writeIndexXML() throws Exception { + DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); + DocumentBuilder builder = factory.newDocumentBuilder(); + + Document doc = builder.newDocument(); + Element rootElement = doc.createElement("fdroid"); + doc.appendChild(rootElement); + + // max age is an EditTextPreference, which is always a String + int repoMaxAge = Float.valueOf(prefs.getString("max_repo_age_days", + DEFAULT_REPO_MAX_AGE_DAYS)).intValue(); + + String repoName = prefs.getString("repo_name", Utils.getDefaultRepoName()); + + Element repo = doc.createElement("repo"); + repo.setAttribute("icon", "blah.png"); + repo.setAttribute("maxage", String.valueOf(repoMaxAge)); + repo.setAttribute("name", repoName + " on " + ipAddressString); + long timestamp = System.currentTimeMillis() / 1000L; + repo.setAttribute("timestamp", String.valueOf(timestamp)); + repo.setAttribute("url", uriString); + rootElement.appendChild(repo); + + Element repoDesc = doc.createElement("description"); + repoDesc.setTextContent("A repo generated from apps installed on " + repoName); + repo.appendChild(repoDesc); + + SimpleDateFormat dateToStr = new SimpleDateFormat("yyyy-MM-dd", Locale.US); + for (Entry entry : apps.entrySet()) { + String latestVersion = "0"; + String latestVerCode = "0"; + App app = entry.getValue(); + Element application = doc.createElement("application"); + application.setAttribute("id", app.id); + rootElement.appendChild(application); + + Element appID = doc.createElement("id"); + appID.setTextContent(app.id); + application.appendChild(appID); + + Element added = doc.createElement("added"); + added.setTextContent(dateToStr.format(app.added)); + application.appendChild(added); + + Element lastUpdated = doc.createElement("lastupdated"); + lastUpdated.setTextContent(dateToStr.format(app.lastUpdated)); + application.appendChild(lastUpdated); + + Element name = doc.createElement("name"); + name.setTextContent(app.name); + application.appendChild(name); + + Element summary = doc.createElement("summary"); + summary.setTextContent(app.name); + application.appendChild(summary); + + Element description = doc.createElement("description"); + description.setTextContent(app.name); + application.appendChild(description); + + Element desc = doc.createElement("desc"); + desc.setTextContent(app.name); + application.appendChild(desc); + + Element icon = doc.createElement("icon"); + icon.setTextContent(app.icon); + application.appendChild(icon); + + Element license = doc.createElement("license"); + license.setTextContent("Unknown"); + application.appendChild(license); + + Element categories = doc.createElement("categories"); + categories.setTextContent("LocalRepo," + repoName); + application.appendChild(categories); + + Element category = doc.createElement("category"); + category.setTextContent("LocalRepo," + repoName); + application.appendChild(category); + + Element web = doc.createElement("web"); + application.appendChild(web); + + Element source = doc.createElement("source"); + application.appendChild(source); + + Element tracker = doc.createElement("tracker"); + application.appendChild(tracker); + + Element marketVersion = doc.createElement("marketversion"); + application.appendChild(marketVersion); + + Element marketVerCode = doc.createElement("marketvercode"); + application.appendChild(marketVerCode); + + for (Apk apk : app.apks) { + Element packageNode = doc.createElement("package"); + + Element version = doc.createElement("version"); + latestVersion = apk.version; + version.setTextContent(apk.version); + packageNode.appendChild(version); + + // F-Droid unfortunately calls versionCode versioncode... + Element versioncode = doc.createElement("versioncode"); + latestVerCode = String.valueOf(apk.vercode); + versioncode.setTextContent(latestVerCode); + packageNode.appendChild(versioncode); + + Element apkname = doc.createElement("apkname"); + apkname.setTextContent(apk.apkName); + packageNode.appendChild(apkname); + + Element hash = doc.createElement("hash"); + hash.setAttribute("type", apk.hashType); + hash.setTextContent(apk.hash.toLowerCase(Locale.US)); + packageNode.appendChild(hash); + + Element sig = doc.createElement("sig"); + sig.setTextContent(apk.sig.toLowerCase(Locale.US)); + packageNode.appendChild(sig); + + Element size = doc.createElement("size"); + size.setTextContent(String.valueOf(apk.installedFile.length())); + packageNode.appendChild(size); + + Element sdkver = doc.createElement("sdkver"); + sdkver.setTextContent(String.valueOf(apk.minSdkVersion)); + packageNode.appendChild(sdkver); + + Element apkAdded = doc.createElement("added"); + apkAdded.setTextContent(dateToStr.format(apk.added)); + packageNode.appendChild(apkAdded); + + Element features = doc.createElement("features"); + if (apk.features != null) + features.setTextContent(Utils.CommaSeparatedList.str(apk.features)); + packageNode.appendChild(features); + + Element permissions = doc.createElement("permissions"); + if (apk.permissions != null) { + StringBuilder buff = new StringBuilder(); + + for (String permission : apk.permissions) { + buff.append(permission.replace("android.permission.", "")); + buff.append(","); + } + String out = buff.toString(); + if (!TextUtils.isEmpty(out)) + permissions.setTextContent(out.substring(0, out.length() - 1)); + } + packageNode.appendChild(permissions); + + application.appendChild(packageNode); + } + + // now mark the latest version in the feed for this particular app + marketVersion.setTextContent(latestVersion); + marketVerCode.setTextContent(latestVerCode); + } + + TransformerFactory transformerFactory = TransformerFactory.newInstance(); + Transformer transformer = transformerFactory.newTransformer(); + + DOMSource domSource = new DOMSource(doc); + StreamResult result = new StreamResult(xmlIndex); + + transformer.transform(domSource, result); + } +} diff --git a/src/org/fdroid/fdroid/net/WifiStateChangeService.java b/src/org/fdroid/fdroid/net/WifiStateChangeService.java index afab55923..3b646f71a 100644 --- a/src/org/fdroid/fdroid/net/WifiStateChangeService.java +++ b/src/org/fdroid/fdroid/net/WifiStateChangeService.java @@ -14,6 +14,7 @@ import android.support.v4.content.LocalBroadcastManager; import android.util.Log; import org.fdroid.fdroid.FDroidApp; +import org.fdroid.fdroid.Utils; import java.util.Locale; @@ -66,6 +67,9 @@ public class WifiStateChangeService extends Service { scheme = "http"; FDroidApp.repo.address = String.format(Locale.ENGLISH, "%s://%s:%d/fdroid/repo", scheme, FDroidApp.ipAddressString, FDroidApp.port); + FDroidApp.localRepo.setUriString(FDroidApp.repo.address); + FDroidApp.localRepo.writeIndexPage( + Utils.getSharingUri(context, FDroidApp.repo).toString()); } catch (InterruptedException e) { e.printStackTrace(); } diff --git a/src/org/fdroid/fdroid/views/LocalRepoActivity.java b/src/org/fdroid/fdroid/views/LocalRepoActivity.java index 2c3ec3713..c15248314 100644 --- a/src/org/fdroid/fdroid/views/LocalRepoActivity.java +++ b/src/org/fdroid/fdroid/views/LocalRepoActivity.java @@ -5,32 +5,25 @@ import android.annotation.TargetApi; import android.app.Activity; import android.app.Dialog; import android.app.ProgressDialog; -import android.content.BroadcastReceiver; -import android.content.Context; -import android.content.Intent; -import android.content.IntentFilter; +import android.content.*; import android.content.res.Configuration; +import android.net.Uri; import android.net.wifi.WifiManager; import android.nfc.NdefMessage; import android.nfc.NdefRecord; import android.nfc.NfcAdapter; +import android.os.AsyncTask; import android.os.Build; import android.os.Bundle; import android.support.v4.content.LocalBroadcastManager; import android.text.TextUtils; import android.util.Log; -import android.view.Menu; -import android.view.MenuInflater; -import android.view.MenuItem; -import android.view.View; +import android.view.*; import android.widget.TextView; +import android.widget.Toast; import android.widget.ToggleButton; -import org.fdroid.fdroid.FDroidApp; -import org.fdroid.fdroid.PreferencesActivity; -import org.fdroid.fdroid.QrGenAsyncTask; -import org.fdroid.fdroid.R; -import org.fdroid.fdroid.Utils; +import org.fdroid.fdroid.*; import org.fdroid.fdroid.net.WifiStateChangeService; import java.util.Locale; @@ -104,12 +97,21 @@ public class LocalRepoActivity extends Activity { public boolean onCreateOptionsMenu(Menu menu) { MenuInflater inflater = getMenuInflater(); inflater.inflate(R.menu.local_repo_activity, menu); + if (Build.VERSION.SDK_INT < 11) // TODO remove after including appcompat-v7 + menu.findItem(R.id.menu_setup_repo).setVisible(false); return true; } @Override public boolean onOptionsItemSelected(MenuItem item) { switch (item.getItemId()) { + case R.id.menu_setup_repo: + setUIFromWifi(); + String[] packages = new String[2]; + packages[0] = getPackageName(); + packages[1] = "com.android.bluetooth"; + new UpdateAsyncTask(this, packages).execute(); + return true; case R.id.menu_settings: startActivityForResult(new Intent(this, PreferencesActivity.class), SET_IP_ADDRESS); return true; @@ -206,4 +208,67 @@ public class LocalRepoActivity extends Activity { // ignore orientation/keyboard change super.onConfigurationChanged(newConfig); } + + class UpdateAsyncTask extends AsyncTask { + private static final String TAG = "UpdateAsyncTask"; + private ProgressDialog progressDialog; + private String[] selectedApps; + private Uri sharingUri; + + public UpdateAsyncTask(Context c, String[] apps) { + selectedApps = apps; + progressDialog = new ProgressDialog(c); + progressDialog.setProgressStyle(ProgressDialog.STYLE_SPINNER); + progressDialog.setTitle(R.string.updating); + sharingUri = Utils.getSharingUri(c, FDroidApp.repo); + } + + @Override + protected void onPreExecute() { + progressDialog.show(); + } + + @Override + protected Void doInBackground(Void... params) { + try { + publishProgress(getString(R.string.deleting_repo)); + FDroidApp.localRepo.deleteRepo(); + for (String app : selectedApps) { + publishProgress(String.format(getString(R.string.adding_apks_format), app)); + FDroidApp.localRepo.addApp(getApplicationContext(), app); + } + FDroidApp.localRepo.writeIndexPage(sharingUri.toString()); + publishProgress(getString(R.string.writing_index_xml)); + FDroidApp.localRepo.writeIndexXML(); + publishProgress(getString(R.string.linking_apks)); + FDroidApp.localRepo.copyApksToRepo(); + publishProgress(getString(R.string.copying_icons)); + // run the icon copy without progress, its not a blocker + new AsyncTask() { + + @Override + protected Void doInBackground(Void... params) { + FDroidApp.localRepo.copyIconsToRepo(); + return null; + } + }.execute(); + } catch (Exception e) { + e.printStackTrace(); + } + return null; + } + + @Override + protected void onProgressUpdate(String... progress) { + super.onProgressUpdate(progress); + progressDialog.setMessage(progress[0]); + } + + @Override + protected void onPostExecute(Void result) { + progressDialog.dismiss(); + Toast.makeText(getBaseContext(), R.string.updated_local_repo, Toast.LENGTH_SHORT) + .show(); + } + } }