From 9b8719e2d56a098502475bb5b2295c7a376d4caa Mon Sep 17 00:00:00 2001 From: Moxie Marlinspike Date: Sun, 26 Feb 2017 14:36:43 -0800 Subject: [PATCH] Support for website distribution build with auto-updating APK // FREEBIE --- .gitignore | 1 + AndroidManifest.xml | 4 +- build.gradle | 68 +++++ res/values/strings.xml | 4 + .../securesms/ApplicationContext.java | 5 + .../securesms/jobs/UpdateApkJob.java | 253 ++++++++++++++++++ .../service/UpdateApkReadyListener.java | 117 ++++++++ .../service/UpdateApkRefreshListener.java | 47 ++++ .../securesms/util/FileUtils.java | 20 ++ .../securesms/util/TextSecurePreferences.java | 27 ++ website/AndroidManifest.xml | 18 ++ 11 files changed, 561 insertions(+), 3 deletions(-) create mode 100644 src/org/thoughtcrime/securesms/jobs/UpdateApkJob.java create mode 100644 src/org/thoughtcrime/securesms/service/UpdateApkReadyListener.java create mode 100644 src/org/thoughtcrime/securesms/service/UpdateApkRefreshListener.java create mode 100644 website/AndroidManifest.xml diff --git a/.gitignore b/.gitignore index 3f472765ac..6d35cbb047 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,4 @@ ffpr test/androidTestEspresso/res/values/arrays.xml obj/ jni/libspeex/.deps/ +*.sh diff --git a/AndroidManifest.xml b/AndroidManifest.xml index f12069bdba..40317f0e41 100644 --- a/AndroidManifest.xml +++ b/AndroidManifest.xml @@ -1,9 +1,7 @@ + package="org.thoughtcrime.securesms"> diff --git a/build.gradle b/build.gradle index 94cb1baec7..37633d6156 100644 --- a/build.gradle +++ b/build.gradle @@ -1,3 +1,5 @@ +import java.security.MessageDigest + buildscript { repositories { maven { @@ -194,11 +196,15 @@ android { } defaultConfig { + versionCode 244 + versionName "3.30.4" + minSdkVersion 9 targetSdkVersion 22 multiDexEnabled true vectorDrawables.useSupportLibrary = true + project.ext.set("archivesBaseName", "Signal"); buildConfigField "long", "BUILD_TIMESTAMP", getLastCommitTimestamp() + "L" buildConfigField "String", "SIGNAL_URL", "\"https://textsecure-service.whispersystems.org\"" @@ -257,6 +263,28 @@ android { } } + productFlavors { + play { + ext.websiteUpdateUrl = "null" + buildConfigField "boolean", "PLAY_STORE_DISABLED", "false" + buildConfigField "String", "NOPLAY_UPDATE_URL", "$ext.websiteUpdateUrl" + } + + website { + ext.websiteUpdateUrl = "https://updates.signal.org/android" + buildConfigField "boolean", "PLAY_STORE_DISABLED", "true" + buildConfigField "String", "NOPLAY_UPDATE_URL", "\"$ext.websiteUpdateUrl\"" + } + } + + applicationVariants.all { variant -> + variant.outputs.each { output -> + output.outputFile = new File( + output.outputFile.parent, + output.outputFile.name.replace(".apk", "-${variant.versionName}.apk")) + } + } + sourceSets { main { manifest.srcFile 'AndroidManifest.xml' @@ -274,6 +302,8 @@ android { test { java.srcDirs = ['test/unitTest/java'] } + + website.manifest.srcFile 'website/AndroidManifest.xml' } lintOptions { @@ -281,10 +311,48 @@ android { } } +task assembleWebsiteDescriptor << { + android.applicationVariants.all { variant -> + if (variant.name.equals("websiteDebug") || + variant.name.equals("websiteRelease")) + { + File file = new File(variant.outputs[0].outputFile.path) + + if (file.exists()) { + MessageDigest md = MessageDigest.getInstance("SHA-256"); + file.eachByte 4096, {bytes, size -> + md.update(bytes, 0, size); + } + + String digest = md.digest().collect {String.format "%02x", it}.join(); + String url = variant.productFlavors.get(0).ext.websiteUpdateUrl + String apkName = variant.outputs[0].outputFile.name + + String descriptor = "{" + + "\"versionCode\" : $project.android.defaultConfig.versionCode," + + "\"versionName\" : \"$project.android.defaultConfig.versionName\"," + + "\"sha256sum\" : \"$digest\"," + + "\"url\" : \"$url/$apkName\"" + + "}" + + File descriptorFile = new File(variant.outputs[0].outputFile.parent, apkName.replace(".apk", ".json")) + + descriptorFile.write(descriptor) + } + } + } +} + tasks.whenTaskAdded { task -> if (task.name.equals("lint")) { task.enabled = false } + + if (task.name.equals("assembleWebsiteDebug") || + task.name.equals("assembleWebsiteRelease")) + { + task.finalizedBy assembleWebsiteDescriptor + } } def getLastCommitTimestamp() { diff --git a/res/values/strings.xml b/res/values/strings.xml index c230428c63..dcdc964769 100644 --- a/res/values/strings.xml +++ b/res/values/strings.xml @@ -580,6 +580,10 @@ Disappearing message time set to %s Your safety number with %s has changed. + + Signal update + A new version of Signal is available, tap to update + Your contact is running an old version of Signal. Please ask them to update before verifying your safety number. Your contact is running a newer version of Signal with an incompatible QR code format. Please update to compare. diff --git a/src/org/thoughtcrime/securesms/ApplicationContext.java b/src/org/thoughtcrime/securesms/ApplicationContext.java index 88e82e48fb..b8ab68f620 100644 --- a/src/org/thoughtcrime/securesms/ApplicationContext.java +++ b/src/org/thoughtcrime/securesms/ApplicationContext.java @@ -42,6 +42,7 @@ import org.thoughtcrime.securesms.push.SignalServiceNetworkAccess; import org.thoughtcrime.securesms.service.DirectoryRefreshListener; import org.thoughtcrime.securesms.service.ExpiringMessageManager; import org.thoughtcrime.securesms.service.RotateSignedPreKeyListener; +import org.thoughtcrime.securesms.service.UpdateApkRefreshListener; import org.thoughtcrime.securesms.util.TextSecurePreferences; import org.webrtc.PeerConnectionFactory; import org.whispersystems.jobqueue.JobManager; @@ -169,6 +170,10 @@ public class ApplicationContext extends MultiDexApplication implements Dependenc private void initializePeriodicTasks() { RotateSignedPreKeyListener.schedule(this); DirectoryRefreshListener.schedule(this); + + if (BuildConfig.PLAY_STORE_DISABLED) { + UpdateApkRefreshListener.schedule(this); + } } private void initializeCircumvention() { diff --git a/src/org/thoughtcrime/securesms/jobs/UpdateApkJob.java b/src/org/thoughtcrime/securesms/jobs/UpdateApkJob.java new file mode 100644 index 0000000000..e550b99269 --- /dev/null +++ b/src/org/thoughtcrime/securesms/jobs/UpdateApkJob.java @@ -0,0 +1,253 @@ +package org.thoughtcrime.securesms.jobs; + + +import android.app.DownloadManager; +import android.content.Context; +import android.content.Intent; +import android.content.pm.PackageInfo; +import android.content.pm.PackageManager; +import android.database.Cursor; +import android.net.Uri; +import android.os.Build; +import android.support.annotation.Nullable; +import android.util.Log; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import org.thoughtcrime.securesms.BuildConfig; +import org.thoughtcrime.securesms.service.UpdateApkReadyListener; +import org.thoughtcrime.securesms.util.FileUtils; +import org.thoughtcrime.securesms.util.Hex; +import org.thoughtcrime.securesms.util.JsonUtils; +import org.thoughtcrime.securesms.util.TextSecurePreferences; +import org.whispersystems.jobqueue.JobParameters; +import org.whispersystems.jobqueue.requirements.NetworkRequirement; + +import java.io.FileInputStream; +import java.io.IOException; +import java.security.MessageDigest; + +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.Response; + +public class UpdateApkJob extends ContextJob { + + private static final String TAG = UpdateApkJob.class.getSimpleName(); + + public UpdateApkJob(Context context) { + super(context, JobParameters.newBuilder() + .withGroupId(UpdateApkJob.class.getSimpleName()) + .withRequirement(new NetworkRequirement(context)) + .withWakeLock(true) + .withRetryCount(2) + .create()); + } + + @Override + public void onAdded() {} + + @Override + public void onRun() throws IOException, PackageManager.NameNotFoundException { + if (!BuildConfig.PLAY_STORE_DISABLED) return; + + Log.w(TAG, "Checking for APK update..."); + + OkHttpClient client = new OkHttpClient(); + Request request = new Request.Builder().url(String.format("%s/latest.json", BuildConfig.NOPLAY_UPDATE_URL)).build(); + + Response response = client.newCall(request).execute(); + + if (!response.isSuccessful()) { + throw new IOException("Bad response: " + response.message()); + } + + UpdateDescriptor updateDescriptor = JsonUtils.fromJson(response.body().string(), UpdateDescriptor.class); + byte[] digest = Hex.fromStringCondensed(updateDescriptor.getDigest()); + + Log.w(TAG, "Got descriptor: " + updateDescriptor); + + if (updateDescriptor.getVersionCode() > getVersionCode()) { + DownloadStatus downloadStatus = getDownloadStatus(updateDescriptor.getUrl(), digest); + + Log.w(TAG, "Download status: " + downloadStatus.getStatus()); + + if (downloadStatus.getStatus() == DownloadStatus.Status.COMPLETE) { + Log.w(TAG, "Download status complete, notifying..."); + handleDownloadNotify(downloadStatus.getDownloadId()); + } else if (downloadStatus.getStatus() == DownloadStatus.Status.MISSING) { + Log.w(TAG, "Download status missing, starting download..."); + handleDownloadStart(updateDescriptor.getUrl(), updateDescriptor.getVersionName(), digest); + } + } + } + + @Override + public boolean onShouldRetry(Exception e) { + return e instanceof IOException; + } + + @Override + public void onCanceled() { + Log.w(TAG, "Update check failed"); + } + + private int getVersionCode() throws PackageManager.NameNotFoundException { + PackageManager packageManager = context.getPackageManager(); + PackageInfo packageInfo = packageManager.getPackageInfo(context.getPackageName(), 0); + + return packageInfo.versionCode; + } + + private DownloadStatus getDownloadStatus(String uri, byte[] theirDigest) { + DownloadManager downloadManager = (DownloadManager) context.getSystemService(Context.DOWNLOAD_SERVICE); + DownloadManager.Query query = new DownloadManager.Query(); + + query.setFilterByStatus(DownloadManager.STATUS_PAUSED | DownloadManager.STATUS_PENDING | DownloadManager.STATUS_RUNNING | DownloadManager.STATUS_SUCCESSFUL); + + long pendingDownloadId = TextSecurePreferences.getUpdateApkDownloadId(context); + byte[] pendingDigest = getPendingDigest(context); + Cursor cursor = downloadManager.query(query); + + try { + DownloadStatus status = new DownloadStatus(DownloadStatus.Status.MISSING, -1); + + while (cursor != null && cursor.moveToNext()) { + int jobStatus = cursor.getInt(cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_STATUS)); + String jobRemoteUri = cursor.getString(cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_URI)); + long downloadId = cursor.getLong(cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_ID)); + byte[] digest = getDigestForDownloadId(downloadId); + + if (jobRemoteUri != null && jobRemoteUri.equals(uri) && downloadId == pendingDownloadId) { + + if (jobStatus == DownloadManager.STATUS_SUCCESSFUL && + digest != null && pendingDigest != null && + MessageDigest.isEqual(pendingDigest, theirDigest) && + MessageDigest.isEqual(digest, theirDigest)) + { + return new DownloadStatus(DownloadStatus.Status.COMPLETE, downloadId); + } else if (jobStatus != DownloadManager.STATUS_SUCCESSFUL) { + status = new DownloadStatus(DownloadStatus.Status.PENDING, downloadId); + } + } + } + + return status; + } finally { + if (cursor != null) cursor.close(); + } + } + + private void handleDownloadStart(String uri, String versionName, byte[] digest) { + DownloadManager downloadManager = (DownloadManager) context.getSystemService(Context.DOWNLOAD_SERVICE); + DownloadManager.Request downloadRequest = new DownloadManager.Request(Uri.parse(uri)); + + downloadRequest.setAllowedNetworkTypes(DownloadManager.Request.NETWORK_WIFI); + downloadRequest.setTitle("Downloading Signal update"); + downloadRequest.setDescription("Downloading Signal " + versionName); + downloadRequest.setVisibleInDownloadsUi(false); + downloadRequest.setDestinationInExternalFilesDir(context, null, "signal-update.apk"); + + if (Build.VERSION.SDK_INT >= 11) { + downloadRequest.setNotificationVisibility(DownloadManager.Request.VISIBILITY_HIDDEN); + } + + long downloadId = downloadManager.enqueue(downloadRequest); + TextSecurePreferences.setUpdateApkDownloadId(context, downloadId); + TextSecurePreferences.setUpdateApkDigest(context, Hex.toStringCondensed(digest)); + } + + private void handleDownloadNotify(long downloadId) { + Intent intent = new Intent(DownloadManager.ACTION_DOWNLOAD_COMPLETE); + intent.putExtra(DownloadManager.EXTRA_DOWNLOAD_ID, downloadId); + + new UpdateApkReadyListener().onReceive(context, intent); + } + + private @Nullable byte[] getDigestForDownloadId(long downloadId) { + try { + DownloadManager downloadManager = (DownloadManager) context.getSystemService(Context.DOWNLOAD_SERVICE); + FileInputStream fin = new FileInputStream(downloadManager.openDownloadedFile(downloadId).getFileDescriptor()); + byte[] digest = FileUtils.getFileDigest(fin); + + fin.close(); + + return digest; + } catch (IOException e) { + Log.w(TAG, e); + return null; + } + } + + private @Nullable byte[] getPendingDigest(Context context) { + try { + String encodedDigest = TextSecurePreferences.getUpdateApkDigest(context); + + if (encodedDigest == null) return null; + + return Hex.fromStringCondensed(encodedDigest); + } catch (IOException e) { + Log.w(TAG, e); + return null; + } + } + + private static class UpdateDescriptor { + @JsonProperty + private int versionCode; + + @JsonProperty + private String versionName; + + @JsonProperty + private String url; + + @JsonProperty + private String sha256sum; + + + public int getVersionCode() { + return versionCode; + } + + public String getVersionName() { + return versionName; + } + + public String getUrl() { + return url; + } + + public String toString() { + return "[" + versionCode + ", " + versionName + ", " + url + "]"; + } + + public String getDigest() { + return sha256sum; + } + } + + private static class DownloadStatus { + enum Status { + PENDING, + COMPLETE, + MISSING + } + + private final Status status; + private final long downloadId; + + DownloadStatus(Status status, long downloadId) { + this.status = status; + this.downloadId = downloadId; + } + + public Status getStatus() { + return status; + } + + public long getDownloadId() { + return downloadId; + } + } +} diff --git a/src/org/thoughtcrime/securesms/service/UpdateApkReadyListener.java b/src/org/thoughtcrime/securesms/service/UpdateApkReadyListener.java new file mode 100644 index 0000000000..d1e476e979 --- /dev/null +++ b/src/org/thoughtcrime/securesms/service/UpdateApkReadyListener.java @@ -0,0 +1,117 @@ +package org.thoughtcrime.securesms.service; + + +import android.app.DownloadManager; +import android.app.Notification; +import android.app.PendingIntent; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.database.Cursor; +import android.net.Uri; +import android.support.annotation.Nullable; +import android.support.v4.app.NotificationCompat; +import android.util.Log; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.util.FileUtils; +import org.thoughtcrime.securesms.util.Hex; +import org.thoughtcrime.securesms.util.ServiceUtil; +import org.thoughtcrime.securesms.util.TextSecurePreferences; + +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.security.MessageDigest; + +public class UpdateApkReadyListener extends BroadcastReceiver { + + private static final String TAG = UpdateApkReadyListener.class.getSimpleName(); + + @Override + public void onReceive(Context context, Intent intent) { + Log.w(TAG, "onReceive()"); + + if (DownloadManager.ACTION_DOWNLOAD_COMPLETE.equals(intent.getAction())) { + long downloadId = intent.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, -2); + + if (downloadId == TextSecurePreferences.getUpdateApkDownloadId(context)) { + Uri uri = getLocalUriForDownloadId(context, downloadId); + String encodedDigest = TextSecurePreferences.getUpdateApkDigest(context); + + if (uri == null) { + Log.w(TAG, "Downloaded local URI is null?"); + return; + } + + if (isMatchingDigest(context, downloadId, encodedDigest)) { + displayInstallNotification(context, uri); + } else { + Log.w(TAG, "Downloaded APK doesn't match digest..."); + } + } + } + } + + private void displayInstallNotification(Context context, Uri uri) { + Intent intent = new Intent(Intent.ACTION_VIEW); + intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + intent.setDataAndType(uri, "application/vnd.android.package-archive"); + + PendingIntent pendingIntent = PendingIntent.getActivity(context, 0, intent, 0); + + Notification notification = new NotificationCompat.Builder(context) + .setOngoing(true) + .setContentTitle(context.getString(R.string.UpdateApkReadyListener_Signal_update)) + .setContentText(context.getString(R.string.UpdateApkReadyListener_a_new_version_of_signal_is_available_tap_to_update)) + .setSmallIcon(R.drawable.icon_notification) + .setColor(context.getResources().getColor(R.color.textsecure_primary)) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setCategory(NotificationCompat.CATEGORY_REMINDER) + .setContentIntent(pendingIntent) + .build(); + + ServiceUtil.getNotificationManager(context).notify(666, notification); + } + + private @Nullable Uri getLocalUriForDownloadId(Context context, long downloadId) { + DownloadManager downloadManager = (DownloadManager) context.getSystemService(Context.DOWNLOAD_SERVICE); + DownloadManager.Query query = new DownloadManager.Query(); + query.setFilterById(downloadId); + + Cursor cursor = downloadManager.query(query); + + try { + if (cursor != null && cursor.moveToFirst()) { + String localUri = cursor.getString(cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_LOCAL_URI)); + + if (localUri != null) { + File localFile = new File(Uri.parse(localUri).getPath()); + return Uri.fromFile(localFile); + } + } + } finally { + if (cursor != null) cursor.close(); + } + + return null; + } + + private boolean isMatchingDigest(Context context, long downloadId, String theirEncodedDigest) { + try { + if (theirEncodedDigest == null) return false; + + byte[] theirDigest = Hex.fromStringCondensed(theirEncodedDigest); + DownloadManager downloadManager = (DownloadManager) context.getSystemService(Context.DOWNLOAD_SERVICE); + FileInputStream fin = new FileInputStream(downloadManager.openDownloadedFile(downloadId).getFileDescriptor()); + byte[] ourDigest = FileUtils.getFileDigest(fin); + + fin.close(); + + return MessageDigest.isEqual(ourDigest, theirDigest); + } catch (IOException e) { + Log.w(TAG, e); + return false; + } + } +} diff --git a/src/org/thoughtcrime/securesms/service/UpdateApkRefreshListener.java b/src/org/thoughtcrime/securesms/service/UpdateApkRefreshListener.java new file mode 100644 index 0000000000..e352afbdef --- /dev/null +++ b/src/org/thoughtcrime/securesms/service/UpdateApkRefreshListener.java @@ -0,0 +1,47 @@ +package org.thoughtcrime.securesms.service; + + +import android.content.Context; +import android.content.Intent; +import android.util.Log; + +import org.thoughtcrime.securesms.ApplicationContext; +import org.thoughtcrime.securesms.BuildConfig; +import org.thoughtcrime.securesms.jobs.UpdateApkJob; +import org.thoughtcrime.securesms.util.TextSecurePreferences; + +import java.util.concurrent.TimeUnit; + +public class UpdateApkRefreshListener extends PersistentAlarmManagerListener { + + private static final String TAG = UpdateApkRefreshListener.class.getSimpleName(); + + private static final long INTERVAL = TimeUnit.HOURS.toMillis(6); + + @Override + protected long getNextScheduledExecutionTime(Context context) { + return TextSecurePreferences.getUpdateApkRefreshTime(context); + } + + @Override + protected long onAlarm(Context context, long scheduledTime) { + Log.w(TAG, "onAlarm..."); + + if (scheduledTime != 0 && BuildConfig.PLAY_STORE_DISABLED) { + Log.w(TAG, "Queueing APK update job..."); + ApplicationContext.getInstance(context) + .getJobManager() + .add(new UpdateApkJob(context)); + } + + long newTime = System.currentTimeMillis() + INTERVAL; + TextSecurePreferences.setUpdateApkRefreshTime(context, newTime); + + return newTime; + } + + public static void schedule(Context context) { + new UpdateApkRefreshListener().onReceive(context, new Intent()); + } + +} diff --git a/src/org/thoughtcrime/securesms/util/FileUtils.java b/src/org/thoughtcrime/securesms/util/FileUtils.java index 275186f74c..fe94e1fab9 100644 --- a/src/org/thoughtcrime/securesms/util/FileUtils.java +++ b/src/org/thoughtcrime/securesms/util/FileUtils.java @@ -1,6 +1,10 @@ package org.thoughtcrime.securesms.util; import java.io.FileDescriptor; +import java.io.FileInputStream; +import java.io.IOException; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; public class FileUtils { @@ -10,4 +14,20 @@ public class FileUtils { public static native int getFileDescriptorOwner(FileDescriptor fileDescriptor); + public static byte[] getFileDigest(FileInputStream fin) throws IOException { + try { + MessageDigest digest = MessageDigest.getInstance("SHA256"); + + byte[] buffer = new byte[4096]; + int read = 0; + + while ((read = fin.read(buffer, 0, buffer.length)) != -1) { + digest.update(buffer, 0, read); + } + + return digest.digest(); + } catch (NoSuchAlgorithmException e) { + throw new AssertionError(e); + } + } } diff --git a/src/org/thoughtcrime/securesms/util/TextSecurePreferences.java b/src/org/thoughtcrime/securesms/util/TextSecurePreferences.java index 21f184a3cc..a34b87aea2 100644 --- a/src/org/thoughtcrime/securesms/util/TextSecurePreferences.java +++ b/src/org/thoughtcrime/securesms/util/TextSecurePreferences.java @@ -73,6 +73,9 @@ public class TextSecurePreferences { private static final String PROMPTED_SHARE_PREF = "pref_prompted_share"; private static final String SIGNALING_KEY_PREF = "pref_signaling_key"; private static final String DIRECTORY_FRESH_TIME_PREF = "pref_directory_refresh_time"; + private static final String UPDATE_APK_REFRESH_TIME_PREF = "pref_update_apk_refresh_time"; + private static final String UPDATE_APK_DOWNLOAD_ID = "pref_update_apk_download_id"; + private static final String UPDATE_APK_DIGEST = "pref_update_apk_digest"; private static final String SIGNED_PREKEY_ROTATION_TIME_PREF = "pref_signed_pre_key_rotation_time"; private static final String IN_THREAD_NOTIFICATION_PREF = "pref_key_inthread_notifications"; private static final String BLOCKING_IDENTITY_CHANGES_PREF = "pref_blocking_identity_changes"; @@ -264,6 +267,30 @@ public class TextSecurePreferences { setLongPreference(context, DIRECTORY_FRESH_TIME_PREF, value); } + public static long getUpdateApkRefreshTime(Context context) { + return getLongPreference(context, UPDATE_APK_REFRESH_TIME_PREF, 0L); + } + + public static void setUpdateApkRefreshTime(Context context, long value) { + setLongPreference(context, UPDATE_APK_REFRESH_TIME_PREF, value); + } + + public static void setUpdateApkDownloadId(Context context, long value) { + setLongPreference(context, UPDATE_APK_DOWNLOAD_ID, value); + } + + public static long getUpdateApkDownloadId(Context context) { + return getLongPreference(context, UPDATE_APK_DOWNLOAD_ID, -1); + } + + public static void setUpdateApkDigest(Context context, String value) { + setStringPreference(context, UPDATE_APK_DIGEST, value); + } + + public static String getUpdateApkDigest(Context context) { + return getStringPreference(context, UPDATE_APK_DIGEST, null); + } + public static String getLocalNumber(Context context) { return getStringPreference(context, LOCAL_NUMBER_PREF, "No Stored Number"); } diff --git a/website/AndroidManifest.xml b/website/AndroidManifest.xml new file mode 100644 index 0000000000..cc91be6ad9 --- /dev/null +++ b/website/AndroidManifest.xml @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file