diff --git a/mastodon/build.gradle b/mastodon/build.gradle
index 4eeede964..07bb5f2e9 100644
--- a/mastodon/build.gradle
+++ b/mastodon/build.gradle
@@ -33,6 +33,9 @@ android {
initWith release
versionNameSuffix "-beta"
}
+ githubRelease{
+ initWith release
+ }
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
@@ -46,6 +49,9 @@ android {
appcenterPublicBeta{
setRoot "src/appcenter"
}
+ githubRelease{
+ setRoot "src/github"
+ }
}
lintOptions{
checkReleaseBuilds false
diff --git a/mastodon/src/github/AndroidManifest.xml b/mastodon/src/github/AndroidManifest.xml
new file mode 100644
index 000000000..a75f12de6
--- /dev/null
+++ b/mastodon/src/github/AndroidManifest.xml
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/mastodon/src/github/java/org/joinmastodon/android/updater/GithubSelfUpdaterImpl.java b/mastodon/src/github/java/org/joinmastodon/android/updater/GithubSelfUpdaterImpl.java
new file mode 100644
index 000000000..a3caaac6a
--- /dev/null
+++ b/mastodon/src/github/java/org/joinmastodon/android/updater/GithubSelfUpdaterImpl.java
@@ -0,0 +1,336 @@
+package org.joinmastodon.android.updater;
+
+import android.app.Activity;
+import android.app.DownloadManager;
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.content.SharedPreferences;
+import android.content.pm.PackageInstaller;
+import android.database.Cursor;
+import android.net.Uri;
+import android.os.Build;
+import android.util.Log;
+import android.widget.Toast;
+
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+import com.google.gson.JsonParser;
+
+import org.joinmastodon.android.BuildConfig;
+import org.joinmastodon.android.E;
+import org.joinmastodon.android.MastodonApp;
+import org.joinmastodon.android.R;
+import org.joinmastodon.android.api.MastodonAPIController;
+import org.joinmastodon.android.events.SelfUpdateStateChangedEvent;
+
+import java.io.File;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import androidx.annotation.Keep;
+import okhttp3.Call;
+import okhttp3.Request;
+import okhttp3.Response;
+
+@Keep
+public class GithubSelfUpdaterImpl extends GithubSelfUpdater{
+ private static final long CHECK_PERIOD=24*3600*1000L;
+ private static final String TAG="GithubSelfUpdater";
+
+ private UpdateState state=UpdateState.NO_UPDATE;
+ private UpdateInfo info;
+ private long downloadID;
+ private BroadcastReceiver downloadCompletionReceiver=new BroadcastReceiver(){
+ @Override
+ public void onReceive(Context context, Intent intent){
+ if(downloadID!=0 && intent.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, 0)==downloadID){
+ MastodonApp.context.unregisterReceiver(this);
+ setState(UpdateState.DOWNLOADED);
+ }
+ }
+ };
+
+ public GithubSelfUpdaterImpl(){
+ SharedPreferences prefs=getPrefs();
+ int checkedByBuild=prefs.getInt("checkedByBuild", 0);
+ if(prefs.contains("version") && checkedByBuild==BuildConfig.VERSION_CODE){
+ info=new UpdateInfo();
+ info.version=prefs.getString("version", null);
+ info.size=prefs.getLong("apkSize", 0);
+ downloadID=prefs.getLong("downloadID", 0);
+ if(downloadID==0 || !getUpdateApkFile().exists()){
+ state=UpdateState.UPDATE_AVAILABLE;
+ }else{
+ DownloadManager dm=MastodonApp.context.getSystemService(DownloadManager.class);
+ state=dm.getUriForDownloadedFile(downloadID)==null ? UpdateState.DOWNLOADING : UpdateState.DOWNLOADED;
+ if(state==UpdateState.DOWNLOADING){
+ MastodonApp.context.registerReceiver(downloadCompletionReceiver, new IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE));
+ }
+ }
+ }else if(checkedByBuild!=BuildConfig.VERSION_CODE && checkedByBuild>0){
+ // We are in a new version, running for the first time after update. Gotta clean things up.
+ long id=getPrefs().getLong("downloadID", 0);
+ if(id!=0){
+ MastodonApp.context.getSystemService(DownloadManager.class).remove(id);
+ }
+ getUpdateApkFile().delete();
+ getPrefs().edit()
+ .remove("apkSize")
+ .remove("version")
+ .remove("apkURL")
+ .remove("checkedByBuild")
+ .remove("downloadID")
+ .apply();
+ }
+ }
+
+ private SharedPreferences getPrefs(){
+ return MastodonApp.context.getSharedPreferences("githubUpdater", Context.MODE_PRIVATE);
+ }
+
+ @Override
+ public void maybeCheckForUpdates(){
+ if(state!=UpdateState.NO_UPDATE && state!=UpdateState.UPDATE_AVAILABLE)
+ return;
+ long timeSinceLastCheck=System.currentTimeMillis()-getPrefs().getLong("lastCheck", 0);
+ if(timeSinceLastCheck>CHECK_PERIOD){
+ setState(UpdateState.CHECKING);
+ MastodonAPIController.runInBackground(this::actuallyCheckForUpdates);
+ }
+ }
+
+ private void actuallyCheckForUpdates(){
+ Request req=new Request.Builder()
+ .url("https://api.github.com/repos/mastodon/mastodon-android/releases/latest")
+ .build();
+ Call call=MastodonAPIController.getHttpClient().newCall(req);
+ try(Response resp=call.execute()){
+ JsonObject obj=JsonParser.parseReader(resp.body().charStream()).getAsJsonObject();
+ String tag=obj.get("tag_name").getAsString();
+ Matcher matcher=Pattern.compile("v(\\d+)\\.(\\d+)\\.(\\d+)").matcher(tag);
+ if(!matcher.find()){
+ Log.w(TAG, "actuallyCheckForUpdates: release tag has wrong format: "+tag);
+ return;
+ }
+ int newMajor=Integer.parseInt(matcher.group(1)), newMinor=Integer.parseInt(matcher.group(2)), newRevision=Integer.parseInt(matcher.group(3));
+ String[] currentParts=BuildConfig.VERSION_NAME.split("\\.");
+ int curMajor=Integer.parseInt(currentParts[0]), curMinor=Integer.parseInt(currentParts[1]), curRevision=Integer.parseInt(currentParts[2]);
+ long newVersion=((long)newMajor << 32) | ((long)newMinor << 16) | newRevision;
+ long curVersion=((long)curMajor << 32) | ((long)curMinor << 16) | curRevision;
+ if(newVersion>curVersion || BuildConfig.DEBUG){
+ String version=newMajor+"."+newMinor+"."+newRevision;
+ Log.d(TAG, "actuallyCheckForUpdates: new version: "+version);
+ for(JsonElement el:obj.getAsJsonArray("assets")){
+ JsonObject asset=el.getAsJsonObject();
+ if("application/vnd.android.package-archive".equals(asset.get("content_type").getAsString()) && "uploaded".equals(asset.get("state").getAsString())){
+ long size=asset.get("size").getAsLong();
+ String url=asset.get("browser_download_url").getAsString();
+
+ UpdateInfo info=new UpdateInfo();
+ info.size=size;
+ info.version=version;
+ this.info=info;
+
+ getPrefs().edit()
+ .putLong("apkSize", size)
+ .putString("version", version)
+ .putString("apkURL", url)
+ .putInt("checkedByBuild", BuildConfig.VERSION_CODE)
+ .remove("downloadID")
+ .apply();
+
+ break;
+ }
+ }
+ }
+ getPrefs().edit().putLong("lastCheck", System.currentTimeMillis()).apply();
+ }catch(Exception x){
+ Log.w(TAG, "actuallyCheckForUpdates", x);
+ }finally{
+ setState(info==null ? UpdateState.NO_UPDATE : UpdateState.UPDATE_AVAILABLE);
+ }
+ }
+
+ private void setState(UpdateState state){
+ this.state=state;
+ E.post(new SelfUpdateStateChangedEvent(state));
+ }
+
+ @Override
+ public UpdateState getState(){
+ return state;
+ }
+
+ @Override
+ public UpdateInfo getUpdateInfo(){
+ return info;
+ }
+
+ public File getUpdateApkFile(){
+ return new File(MastodonApp.context.getExternalCacheDir(), "update.apk");
+ }
+
+ @Override
+ public void downloadUpdate(){
+ if(state==UpdateState.DOWNLOADING)
+ throw new IllegalStateException();
+ DownloadManager dm=MastodonApp.context.getSystemService(DownloadManager.class);
+ MastodonApp.context.registerReceiver(downloadCompletionReceiver, new IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE));
+ downloadID=dm.enqueue(
+ new DownloadManager.Request(Uri.parse(getPrefs().getString("apkURL", null)))
+ .setDestinationUri(Uri.fromFile(getUpdateApkFile()))
+ );
+ getPrefs().edit().putLong("downloadID", downloadID).apply();
+ setState(UpdateState.DOWNLOADING);
+ }
+
+ @Override
+ public void installUpdate(Activity activity){
+ if(state!=UpdateState.DOWNLOADED)
+ throw new IllegalStateException();
+ Uri uri;
+ Intent intent=new Intent(Intent.ACTION_INSTALL_PACKAGE);
+ if(Build.VERSION.SDK_INT>=Build.VERSION_CODES.N){
+ uri=new Uri.Builder().scheme("content").authority(activity.getPackageName()+".self_update_provider").path("update.apk").build();
+ intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
+ }else{
+ uri=Uri.fromFile(getUpdateApkFile());
+ }
+ intent.setDataAndType(uri, "application/vnd.android.package-archive");
+ activity.startActivity(intent);
+
+ // TODO figure out how to restart the app when updating via this new API
+ /*
+ PackageInstaller installer=activity.getPackageManager().getPackageInstaller();
+ try{
+ final int sid=installer.createSession(new PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL));
+ installer.registerSessionCallback(new PackageInstaller.SessionCallback(){
+ @Override
+ public void onCreated(int i){
+
+ }
+
+ @Override
+ public void onBadgingChanged(int i){
+
+ }
+
+ @Override
+ public void onActiveChanged(int i, boolean b){
+
+ }
+
+ @Override
+ public void onProgressChanged(int id, float progress){
+
+ }
+
+ @Override
+ public void onFinished(int id, boolean success){
+ activity.getPackageManager().setComponentEnabledSetting(new ComponentName(activity, AfterUpdateRestartReceiver.class), PackageManager.COMPONENT_ENABLED_STATE_DISABLED, PackageManager.DONT_KILL_APP);
+ }
+ });
+ activity.getPackageManager().setComponentEnabledSetting(new ComponentName(activity, AfterUpdateRestartReceiver.class), PackageManager.COMPONENT_ENABLED_STATE_ENABLED, PackageManager.DONT_KILL_APP);
+ PackageInstaller.Session session=installer.openSession(sid);
+ try(OutputStream out=session.openWrite("mastodon.apk", 0, info.size); InputStream in=new FileInputStream(getUpdateApkFile())){
+ byte[] buffer=new byte[16384];
+ int read;
+ while((read=in.read(buffer))>0){
+ out.write(buffer, 0, read);
+ }
+ }
+// PendingIntent intent=PendingIntent.getBroadcast(activity, 1, new Intent(activity, InstallerStatusReceiver.class), PendingIntent.FLAG_CANCEL_CURRENT | PendingIntent.FLAG_MUTABLE);
+ PendingIntent intent=PendingIntent.getActivity(activity, 1, new Intent(activity, MainActivity.class), PendingIntent.FLAG_UPDATE_CURRENT);
+ session.commit(intent.getIntentSender());
+ }catch(IOException x){
+ Log.w(TAG, "installUpdate", x);
+ Toast.makeText(activity, x.getMessage(), Toast.LENGTH_SHORT).show();
+ }
+ */
+ }
+
+ @Override
+ public float getDownloadProgress(){
+ if(state!=UpdateState.DOWNLOADING)
+ throw new IllegalStateException();
+ DownloadManager dm=MastodonApp.context.getSystemService(DownloadManager.class);
+ try(Cursor cursor=dm.query(new DownloadManager.Query().setFilterById(downloadID))){
+ if(cursor.moveToFirst()){
+ long loaded=cursor.getLong(cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR));
+ long total=cursor.getLong(cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_TOTAL_SIZE_BYTES));
+// Log.d(TAG, "getDownloadProgress: "+loaded+" of "+total);
+ return total>0 ? (float)loaded/total : 0f;
+ }
+ }
+ return 0;
+ }
+
+ @Override
+ public void cancelDownload(){
+ if(state!=UpdateState.DOWNLOADING)
+ throw new IllegalStateException();
+ DownloadManager dm=MastodonApp.context.getSystemService(DownloadManager.class);
+ dm.remove(downloadID);
+ downloadID=0;
+ getPrefs().edit().remove("downloadID").apply();
+ setState(UpdateState.UPDATE_AVAILABLE);
+ }
+
+ @Override
+ public void handleIntentFromInstaller(Intent intent, Activity activity){
+ int status=intent.getIntExtra(PackageInstaller.EXTRA_STATUS, 0);
+ if(status==PackageInstaller.STATUS_PENDING_USER_ACTION){
+ Intent confirmIntent=intent.getParcelableExtra(Intent.EXTRA_INTENT);
+ activity.startActivity(confirmIntent);
+ }else if(status!=PackageInstaller.STATUS_SUCCESS){
+ String msg=intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE);
+ Toast.makeText(activity, activity.getString(R.string.error)+":\n"+msg, Toast.LENGTH_LONG).show();
+ }
+ }
+
+ /*public static class InstallerStatusReceiver extends BroadcastReceiver{
+
+ @Override
+ public void onReceive(Context context, Intent intent){
+ int status=intent.getIntExtra(PackageInstaller.EXTRA_STATUS, 0);
+ if(status==PackageInstaller.STATUS_PENDING_USER_ACTION){
+ Intent confirmIntent=intent.getParcelableExtra(Intent.EXTRA_INTENT);
+ context.startActivity(confirmIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK));
+ }else if(status!=PackageInstaller.STATUS_SUCCESS){
+ String msg=intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE);
+ Toast.makeText(context, context.getString(R.string.error)+":\n"+msg, Toast.LENGTH_LONG).show();
+ }
+ }
+ }
+
+ public static class AfterUpdateRestartReceiver extends BroadcastReceiver{
+
+ @Override
+ public void onReceive(Context context, Intent intent){
+ if(Intent.ACTION_MY_PACKAGE_REPLACED.equals(intent.getAction())){
+ context.getPackageManager().setComponentEnabledSetting(new ComponentName(context, AfterUpdateRestartReceiver.class), PackageManager.COMPONENT_ENABLED_STATE_DISABLED, PackageManager.DONT_KILL_APP);
+ Toast.makeText(context, R.string.update_installed, Toast.LENGTH_SHORT).show();
+ Intent restartIntent=new Intent(context, MainActivity.class)
+ .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ .setPackage(context.getPackageName());
+ if(Build.VERSION.SDK_INT0)
+ if((holder instanceof HeaderViewHolder || holder instanceof FooterViewHolder) && holder.getAbsoluteAdapterPosition()>1)
outRect.top=V.dp(32);
}
});
@@ -155,6 +171,20 @@ public class SettingsFragment extends MastodonToolbarFragment{
}
}
+ @Override
+ public void onViewCreated(View view, Bundle savedInstanceState){
+ super.onViewCreated(view, savedInstanceState);
+ if(GithubSelfUpdater.needSelfUpdating())
+ E.register(this);
+ }
+
+ @Override
+ public void onDestroyView(){
+ super.onDestroyView();
+ if(GithubSelfUpdater.needSelfUpdating())
+ E.unregister(this);
+ }
+
private void onThemePreferenceClick(GlobalUserPreferences.ThemePreference theme){
GlobalUserPreferences.theme=theme;
GlobalUserPreferences.save();
@@ -294,6 +324,16 @@ public class SettingsFragment extends MastodonToolbarFragment{
});
}
+ @Subscribe
+ public void onSelfUpdateStateChanged(SelfUpdateStateChangedEvent ev){
+ if(items.get(0) instanceof UpdateItem item){
+ RecyclerView.ViewHolder holder=list.findViewHolderForAdapterPosition(0);
+ if(holder instanceof UpdateViewHolder uvh){
+ uvh.bind(item);
+ }
+ }
+ }
+
private static abstract class Item{
public abstract int getViewType();
}
@@ -395,6 +435,14 @@ public class SettingsFragment extends MastodonToolbarFragment{
}
}
+ private class UpdateItem extends Item{
+
+ @Override
+ public int getViewType(){
+ return 7;
+ }
+ }
+
private class SettingsAdapter extends RecyclerView.Adapter>{
@NonNull
@Override
@@ -408,6 +456,7 @@ public class SettingsFragment extends MastodonToolbarFragment{
case 4 -> new TextViewHolder();
case 5 -> new HeaderViewHolder(true);
case 6 -> new FooterViewHolder();
+ case 7 -> new UpdateViewHolder();
default -> throw new IllegalStateException("Unexpected value: "+viewType);
};
}
@@ -609,4 +658,74 @@ public class SettingsFragment extends MastodonToolbarFragment{
text.setText(item.text);
}
}
+
+ private class UpdateViewHolder extends BindableViewHolder{
+
+ private final TextView text;
+ private final Button button;
+ private final ImageButton cancelBtn;
+ private final ProgressBar progress;
+
+ private ObjectAnimator rotationAnimator;
+ private Runnable progressUpdater=this::updateProgress;
+
+ public UpdateViewHolder(){
+ super(getActivity(), R.layout.item_settings_update, list);
+ text=findViewById(R.id.text);
+ button=findViewById(R.id.button);
+ cancelBtn=findViewById(R.id.cancel_btn);
+ progress=findViewById(R.id.progress);
+ button.setOnClickListener(v->{
+ GithubSelfUpdater updater=GithubSelfUpdater.getInstance();
+ switch(updater.getState()){
+ case UPDATE_AVAILABLE -> updater.downloadUpdate();
+ case DOWNLOADED -> updater.installUpdate(getActivity());
+ }
+ });
+ cancelBtn.setOnClickListener(v->GithubSelfUpdater.getInstance().cancelDownload());
+ rotationAnimator=ObjectAnimator.ofFloat(progress, View.ROTATION, 0f, 360f);
+ rotationAnimator.setInterpolator(new LinearInterpolator());
+ rotationAnimator.setDuration(1500);
+ rotationAnimator.setRepeatCount(ObjectAnimator.INFINITE);
+ }
+
+ @Override
+ public void onBind(UpdateItem item){
+ GithubSelfUpdater updater=GithubSelfUpdater.getInstance();
+ GithubSelfUpdater.UpdateInfo info=updater.getUpdateInfo();
+ GithubSelfUpdater.UpdateState state=updater.getState();
+ if(state!=GithubSelfUpdater.UpdateState.DOWNLOADED){
+ text.setText(getString(R.string.update_available, info.version));
+ button.setText(getString(R.string.download_update, UiUtils.formatFileSize(getActivity(), info.size, false)));
+ }else{
+ text.setText(getString(R.string.update_ready, info.version));
+ button.setText(R.string.install_update);
+ }
+ if(state==GithubSelfUpdater.UpdateState.DOWNLOADING){
+ rotationAnimator.start();
+ button.setVisibility(View.INVISIBLE);
+ cancelBtn.setVisibility(View.VISIBLE);
+ progress.setVisibility(View.VISIBLE);
+ updateProgress();
+ }else{
+ rotationAnimator.cancel();
+ button.setVisibility(View.VISIBLE);
+ cancelBtn.setVisibility(View.GONE);
+ progress.setVisibility(View.GONE);
+ progress.removeCallbacks(progressUpdater);
+ }
+ }
+
+ private void updateProgress(){
+ GithubSelfUpdater updater=GithubSelfUpdater.getInstance();
+ if(updater.getState()!=GithubSelfUpdater.UpdateState.DOWNLOADING)
+ return;
+ int value=Math.round(progress.getMax()*updater.getDownloadProgress());
+ if(Build.VERSION.SDK_INT>=24)
+ progress.setProgress(value, true);
+ else
+ progress.setProgress(value);
+ progress.postDelayed(progressUpdater, 1000);
+ }
+ }
}
diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/report/ReportAddPostsChoiceFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/report/ReportAddPostsChoiceFragment.java
index 9a37ce8c1..c7b84c2ec 100644
--- a/mastodon/src/main/java/org/joinmastodon/android/fragments/report/ReportAddPostsChoiceFragment.java
+++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/report/ReportAddPostsChoiceFragment.java
@@ -102,6 +102,7 @@ public class ReportAddPostsChoiceFragment extends StatusListFragment{
else
selectedIDs.add(id);
list.invalidate();
+ btn.setEnabled(!selectedIDs.isEmpty());
}
@Override
diff --git a/mastodon/src/main/java/org/joinmastodon/android/updater/GithubSelfUpdater.java b/mastodon/src/main/java/org/joinmastodon/android/updater/GithubSelfUpdater.java
new file mode 100644
index 000000000..810c516dc
--- /dev/null
+++ b/mastodon/src/main/java/org/joinmastodon/android/updater/GithubSelfUpdater.java
@@ -0,0 +1,54 @@
+package org.joinmastodon.android.updater;
+
+import android.app.Activity;
+import android.content.Intent;
+
+import org.joinmastodon.android.BuildConfig;
+
+public abstract class GithubSelfUpdater{
+ private static GithubSelfUpdater instance;
+
+ public static GithubSelfUpdater getInstance(){
+ if(instance==null){
+ try{
+ Class> c=Class.forName("org.joinmastodon.android.updater.GithubSelfUpdaterImpl");
+ instance=(GithubSelfUpdater) c.newInstance();
+ }catch(IllegalAccessException|InstantiationException|ClassNotFoundException ignored){
+ }
+ }
+ return instance;
+ }
+
+ public static boolean needSelfUpdating(){
+ return BuildConfig.BUILD_TYPE.equals("githubRelease");
+ }
+
+ public abstract void maybeCheckForUpdates();
+
+ public abstract GithubSelfUpdater.UpdateState getState();
+
+ public abstract GithubSelfUpdater.UpdateInfo getUpdateInfo();
+
+ public abstract void downloadUpdate();
+
+ public abstract void installUpdate(Activity activity);
+
+ public abstract float getDownloadProgress();
+
+ public abstract void cancelDownload();
+
+ public abstract void handleIntentFromInstaller(Intent intent, Activity activity);
+
+ public enum UpdateState{
+ NO_UPDATE,
+ CHECKING,
+ UPDATE_AVAILABLE,
+ DOWNLOADING,
+ DOWNLOADED
+ }
+
+ public static class UpdateInfo{
+ public String version;
+ public long size;
+ }
+}
diff --git a/mastodon/src/main/res/drawable/bg_settings_update.xml b/mastodon/src/main/res/drawable/bg_settings_update.xml
new file mode 100644
index 000000000..400025a47
--- /dev/null
+++ b/mastodon/src/main/res/drawable/bg_settings_update.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/mastodon/src/main/res/drawable/bg_update_download_progress.xml b/mastodon/src/main/res/drawable/bg_update_download_progress.xml
new file mode 100644
index 000000000..9b54b9e98
--- /dev/null
+++ b/mastodon/src/main/res/drawable/bg_update_download_progress.xml
@@ -0,0 +1,13 @@
+
+
+ -
+
+
+
+
+ -
+
+
+
+
+
\ No newline at end of file
diff --git a/mastodon/src/main/res/drawable/ic_fluent_dismiss_16_filled.xml b/mastodon/src/main/res/drawable/ic_fluent_dismiss_16_filled.xml
new file mode 100644
index 000000000..de8790b73
--- /dev/null
+++ b/mastodon/src/main/res/drawable/ic_fluent_dismiss_16_filled.xml
@@ -0,0 +1,3 @@
+
+
+
diff --git a/mastodon/src/main/res/drawable/ic_settings_24_badged.xml b/mastodon/src/main/res/drawable/ic_settings_24_badged.xml
new file mode 100644
index 000000000..de24a3c06
--- /dev/null
+++ b/mastodon/src/main/res/drawable/ic_settings_24_badged.xml
@@ -0,0 +1,10 @@
+
+
+
+ -
+
+
+
+
+
+
\ No newline at end of file
diff --git a/mastodon/src/main/res/drawable/update_progress.xml b/mastodon/src/main/res/drawable/update_progress.xml
new file mode 100644
index 000000000..5ee226392
--- /dev/null
+++ b/mastodon/src/main/res/drawable/update_progress.xml
@@ -0,0 +1,12 @@
+
+
+ -
+
+
+
+
+
\ No newline at end of file
diff --git a/mastodon/src/main/res/layout/item_settings_update.xml b/mastodon/src/main/res/layout/item_settings_update.xml
new file mode 100644
index 000000000..f83aaece2
--- /dev/null
+++ b/mastodon/src/main/res/layout/item_settings_update.xml
@@ -0,0 +1,75 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/mastodon/src/main/res/values/strings.xml b/mastodon/src/main/res/values/strings.xml
index c31062254..66e8072f9 100644
--- a/mastodon/src/main/res/values/strings.xml
+++ b/mastodon/src/main/res/values/strings.xml
@@ -377,4 +377,11 @@
%s remaining
Your device lost connection to the internet
Processing…
+
+ Mastodon for Android %s is ready to download.
+
+ Mastodon for Android %s is downloaded and ready to install.
+
+ Download (%s)
+ Install
\ No newline at end of file