From e381de812cfa1eb4036630296fc821bae1a9d2df Mon Sep 17 00:00:00 2001 From: Grishka Date: Mon, 31 Oct 2022 09:26:17 +0300 Subject: [PATCH] Add self-updater for github builds --- mastodon/build.gradle | 6 + mastodon/src/github/AndroidManifest.xml | 21 ++ .../updater/GithubSelfUpdaterImpl.java | 336 ++++++++++++++++++ .../updater/SelfUpdateContentProvider.java | 62 ++++ .../joinmastodon/android/MainActivity.java | 8 +- .../android/api/MastodonAPIController.java | 4 + .../events/SelfUpdateStateChangedEvent.java | 11 + .../android/fragments/HomeFragment.java | 14 - .../fragments/HomeTimelineFragment.java | 26 ++ .../android/fragments/SettingsFragment.java | 123 ++++++- .../report/ReportAddPostsChoiceFragment.java | 1 + .../android/updater/GithubSelfUpdater.java | 54 +++ .../main/res/drawable/bg_settings_update.xml | 5 + .../drawable/bg_update_download_progress.xml | 13 + .../drawable/ic_fluent_dismiss_16_filled.xml | 3 + .../res/drawable/ic_settings_24_badged.xml | 10 + .../src/main/res/drawable/update_progress.xml | 12 + .../main/res/layout/item_settings_update.xml | 75 ++++ mastodon/src/main/res/values/strings.xml | 7 + 19 files changed, 774 insertions(+), 17 deletions(-) create mode 100644 mastodon/src/github/AndroidManifest.xml create mode 100644 mastodon/src/github/java/org/joinmastodon/android/updater/GithubSelfUpdaterImpl.java create mode 100644 mastodon/src/github/java/org/joinmastodon/android/updater/SelfUpdateContentProvider.java create mode 100644 mastodon/src/main/java/org/joinmastodon/android/events/SelfUpdateStateChangedEvent.java create mode 100644 mastodon/src/main/java/org/joinmastodon/android/updater/GithubSelfUpdater.java create mode 100644 mastodon/src/main/res/drawable/bg_settings_update.xml create mode 100644 mastodon/src/main/res/drawable/bg_update_download_progress.xml create mode 100644 mastodon/src/main/res/drawable/ic_fluent_dismiss_16_filled.xml create mode 100644 mastodon/src/main/res/drawable/ic_settings_24_badged.xml create mode 100644 mastodon/src/main/res/drawable/update_progress.xml create mode 100644 mastodon/src/main/res/layout/item_settings_update.xml 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 @@ + + + + + + + + + +