diff --git a/mastodon/build.gradle b/mastodon/build.gradle
index d610d8216..0816ea4b1 100644
--- a/mastodon/build.gradle
+++ b/mastodon/build.gradle
@@ -4,13 +4,13 @@ plugins {
}
android {
- compileSdk 31
+ compileSdk 33
defaultConfig {
applicationId "org.joinmastodon.android.sk"
minSdk 23
- targetSdk 31
- versionCode 21
- versionName '1.1.3+fork.21'
+ targetSdk 33
+ versionCode 22
+ versionName "1.1.3+fork.22"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
@@ -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_INT
+
diff --git a/mastodon/src/main/java/org/joinmastodon/android/MainActivity.java b/mastodon/src/main/java/org/joinmastodon/android/MainActivity.java
index c7b9c58a0..81bbf89b6 100644
--- a/mastodon/src/main/java/org/joinmastodon/android/MainActivity.java
+++ b/mastodon/src/main/java/org/joinmastodon/android/MainActivity.java
@@ -1,8 +1,12 @@
package org.joinmastodon.android;
+import android.Manifest;
import android.app.Application;
import android.app.Fragment;
import android.content.Intent;
+import android.content.pm.PackageInstaller;
+import android.content.pm.PackageManager;
+import android.os.Build;
import android.os.Bundle;
import android.util.Log;
@@ -17,6 +21,7 @@ import org.joinmastodon.android.fragments.ThreadFragment;
import org.joinmastodon.android.fragments.onboarding.AccountActivationFragment;
import org.joinmastodon.android.model.Notification;
import org.joinmastodon.android.ui.utils.UiUtils;
+import org.joinmastodon.android.updater.GithubSelfUpdater;
import org.parceler.Parcels;
import java.lang.reflect.InvocationTargetException;
@@ -59,6 +64,8 @@ public class MainActivity extends FragmentStackActivity{
showFragmentForNotification(notification, session.getID());
}else if(intent.getBooleanExtra("compose", false)){
showCompose();
+ }else{
+ maybeRequestNotificationsPermission();
}
}
}
@@ -68,6 +75,8 @@ public class MainActivity extends FragmentStackActivity{
try{
Class.forName("org.joinmastodon.android.AppCenterWrapper").getMethod("init", Application.class).invoke(null, getApplication());
}catch(ClassNotFoundException|NoSuchMethodException|IllegalAccessException|InvocationTargetException ignore){}
+ }else if(GithubSelfUpdater.needSelfUpdating()){
+ GithubSelfUpdater.getInstance().maybeCheckForUpdates();
}
}
@@ -96,7 +105,9 @@ public class MainActivity extends FragmentStackActivity{
}
}else if(intent.getBooleanExtra("compose", false)){
showCompose();
- }
+ }/*else if(intent.hasExtra(PackageInstaller.EXTRA_STATUS) && GithubSelfUpdater.needSelfUpdating()){
+ GithubSelfUpdater.getInstance().handleIntentFromInstaller(intent, this);
+ }*/
}
private void showFragmentForNotification(Notification notification, String accountID){
@@ -131,4 +142,10 @@ public class MainActivity extends FragmentStackActivity{
compose.setArguments(composeArgs);
showFragment(compose);
}
+
+ private void maybeRequestNotificationsPermission(){
+ if(Build.VERSION.SDK_INT>=Build.VERSION_CODES.TIRAMISU && checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS)!=PackageManager.PERMISSION_GRANTED){
+ requestPermissions(new String[]{Manifest.permission.POST_NOTIFICATIONS}, 100);
+ }
+ }
}
diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/CacheController.java b/mastodon/src/main/java/org/joinmastodon/android/api/CacheController.java
index f728649bf..830b5a19a 100644
--- a/mastodon/src/main/java/org/joinmastodon/android/api/CacheController.java
+++ b/mastodon/src/main/java/org/joinmastodon/android/api/CacheController.java
@@ -102,7 +102,7 @@ public class CacheController{
.exec(accountID);
}catch(SQLiteException x){
Log.w(TAG, x);
- uiHandler.post(()->callback.onError(new MastodonErrorResponse(x.getLocalizedMessage(), 500)));
+ uiHandler.post(()->callback.onError(new MastodonErrorResponse(x.getLocalizedMessage(), 500, x)));
}finally{
closeDelayed();
}
@@ -184,7 +184,7 @@ public class CacheController{
.exec(accountID);
}catch(SQLiteException x){
Log.w(TAG, x);
- uiHandler.post(()->callback.onError(new MastodonErrorResponse(x.getLocalizedMessage(), 500)));
+ uiHandler.post(()->callback.onError(new MastodonErrorResponse(x.getLocalizedMessage(), 500, x)));
}finally{
closeDelayed();
}
diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/MastodonAPIController.java b/mastodon/src/main/java/org/joinmastodon/android/api/MastodonAPIController.java
index 1f30e737e..5610155be 100644
--- a/mastodon/src/main/java/org/joinmastodon/android/api/MastodonAPIController.java
+++ b/mastodon/src/main/java/org/joinmastodon/android/api/MastodonAPIController.java
@@ -96,11 +96,11 @@ public class MastodonAPIController{
if(call.isCanceled())
return;
if(BuildConfig.DEBUG)
- Log.w(TAG, "["+(session==null ? "no-auth" : session.getID())+"] "+hreq+" failed: "+e);
+ Log.w(TAG, "["+(session==null ? "no-auth" : session.getID())+"] "+hreq+" failed", e);
synchronized(req){
req.okhttpCall=null;
}
- req.onError(e.getLocalizedMessage(), 0);
+ req.onError(e.getLocalizedMessage(), 0, e);
}
@Override
@@ -133,7 +133,7 @@ public class MastodonAPIController{
}catch(JsonIOException|JsonSyntaxException x){
if(BuildConfig.DEBUG)
Log.w(TAG, "["+(session==null ? "no-auth" : session.getID())+"] "+response+" error parsing or reading body", x);
- req.onError(x.getLocalizedMessage(), response.code());
+ req.onError(x.getLocalizedMessage(), response.code(), x);
return;
}
@@ -142,7 +142,7 @@ public class MastodonAPIController{
}catch(IOException x){
if(BuildConfig.DEBUG)
Log.w(TAG, "["+(session==null ? "no-auth" : session.getID())+"] "+response+" error post-processing or validating response", x);
- req.onError(x.getLocalizedMessage(), response.code());
+ req.onError(x.getLocalizedMessage(), response.code(), x);
return;
}
@@ -155,7 +155,7 @@ public class MastodonAPIController{
JsonObject error=JsonParser.parseReader(reader).getAsJsonObject();
Log.w(TAG, "["+(session==null ? "no-auth" : session.getID())+"] "+response+" received error: "+error);
if(error.has("details")){
- MastodonDetailedErrorResponse err=new MastodonDetailedErrorResponse(error.get("error").getAsString(), response.code());
+ MastodonDetailedErrorResponse err=new MastodonDetailedErrorResponse(error.get("error").getAsString(), response.code(), null);
HashMap> details=new HashMap<>();
JsonObject errorDetails=error.getAsJsonObject("details");
for(String key:errorDetails.keySet()){
@@ -172,12 +172,12 @@ public class MastodonAPIController{
err.detailedErrors=details;
req.onError(err);
}else{
- req.onError(error.get("error").getAsString(), response.code());
+ req.onError(error.get("error").getAsString(), response.code(), null);
}
}catch(JsonIOException|JsonSyntaxException x){
- req.onError(response.code()+" "+response.message(), response.code());
+ req.onError(response.code()+" "+response.message(), response.code(), x);
}catch(Exception x){
- req.onError("Error parsing an API error", response.code());
+ req.onError("Error parsing an API error", response.code(), x);
}
}
}catch(Exception x){
@@ -189,7 +189,7 @@ public class MastodonAPIController{
}catch(Exception x){
if(BuildConfig.DEBUG)
Log.w(TAG, "["+(session==null ? "no-auth" : session.getID())+"] error creating and sending http request", x);
- req.onError(x.getLocalizedMessage(), 0);
+ req.onError(x.getLocalizedMessage(), 0, x);
}
}, 0);
}
@@ -197,4 +197,8 @@ public class MastodonAPIController{
public static void runInBackground(Runnable action){
thread.postRunnable(action, 0);
}
+
+ public static OkHttpClient getHttpClient(){
+ return httpClient;
+ }
}
diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/MastodonAPIRequest.java b/mastodon/src/main/java/org/joinmastodon/android/api/MastodonAPIRequest.java
index 3d8adffd7..0b8228834 100644
--- a/mastodon/src/main/java/org/joinmastodon/android/api/MastodonAPIRequest.java
+++ b/mastodon/src/main/java/org/joinmastodon/android/api/MastodonAPIRequest.java
@@ -82,7 +82,7 @@ public abstract class MastodonAPIRequest extends APIRequest{
account.getApiController().submitRequest(this);
}catch(Exception x){
Log.e(TAG, "exec: this shouldn't happen, but it still did", x);
- invokeErrorCallback(new MastodonErrorResponse(x.getLocalizedMessage(), -1));
+ invokeErrorCallback(new MastodonErrorResponse(x.getLocalizedMessage(), -1, x));
}
return this;
}
@@ -194,8 +194,8 @@ public abstract class MastodonAPIRequest extends APIRequest{
invokeErrorCallback(err);
}
- void onError(String msg, int httpStatus){
- invokeErrorCallback(new MastodonErrorResponse(msg, httpStatus));
+ void onError(String msg, int httpStatus, Throwable exception){
+ invokeErrorCallback(new MastodonErrorResponse(msg, httpStatus, exception));
}
void onSuccess(T resp){
diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/MastodonDetailedErrorResponse.java b/mastodon/src/main/java/org/joinmastodon/android/api/MastodonDetailedErrorResponse.java
index 61ac1cdb5..f0b86a314 100644
--- a/mastodon/src/main/java/org/joinmastodon/android/api/MastodonDetailedErrorResponse.java
+++ b/mastodon/src/main/java/org/joinmastodon/android/api/MastodonDetailedErrorResponse.java
@@ -7,8 +7,8 @@ import java.util.Map;
public class MastodonDetailedErrorResponse extends MastodonErrorResponse{
public Map> detailedErrors;
- public MastodonDetailedErrorResponse(String error, int httpStatus){
- super(error, httpStatus);
+ public MastodonDetailedErrorResponse(String error, int httpStatus, Throwable exception){
+ super(error, httpStatus, exception);
}
public static class FieldError{
diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/MastodonErrorResponse.java b/mastodon/src/main/java/org/joinmastodon/android/api/MastodonErrorResponse.java
index 4e24629e0..9dfbfdc83 100644
--- a/mastodon/src/main/java/org/joinmastodon/android/api/MastodonErrorResponse.java
+++ b/mastodon/src/main/java/org/joinmastodon/android/api/MastodonErrorResponse.java
@@ -12,10 +12,12 @@ import me.grishka.appkit.api.ErrorResponse;
public class MastodonErrorResponse extends ErrorResponse{
public final String error;
public final int httpStatus;
+ public final Throwable underlyingException;
- public MastodonErrorResponse(String error, int httpStatus){
+ public MastodonErrorResponse(String error, int httpStatus, Throwable exception){
this.error=error;
this.httpStatus=httpStatus;
+ this.underlyingException=exception;
}
@Override
diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/accounts/GetPreferences.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/accounts/GetPreferences.java
new file mode 100644
index 000000000..8d5d79e94
--- /dev/null
+++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/accounts/GetPreferences.java
@@ -0,0 +1,10 @@
+package org.joinmastodon.android.api.requests.accounts;
+
+import org.joinmastodon.android.api.MastodonAPIRequest;
+import org.joinmastodon.android.model.Preferences;
+
+public class GetPreferences extends MastodonAPIRequest {
+ public GetPreferences(){
+ super(HttpMethod.GET, "/preferences", Preferences.class);
+ }
+}
diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/statuses/GetAttachmentByID.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/statuses/GetAttachmentByID.java
new file mode 100644
index 000000000..0228ade20
--- /dev/null
+++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/statuses/GetAttachmentByID.java
@@ -0,0 +1,21 @@
+package org.joinmastodon.android.api.requests.statuses;
+
+import org.joinmastodon.android.api.MastodonAPIRequest;
+import org.joinmastodon.android.model.Attachment;
+
+import java.io.IOException;
+
+import okhttp3.Response;
+
+public class GetAttachmentByID extends MastodonAPIRequest{
+ public GetAttachmentByID(String id){
+ super(HttpMethod.GET, "/media/"+id, Attachment.class);
+ }
+
+ @Override
+ public void validateAndPostprocessResponse(Attachment respObj, Response httpResponse) throws IOException{
+ if(httpResponse.code()==206)
+ respObj.url="";
+ super.validateAndPostprocessResponse(respObj, httpResponse);
+ }
+}
diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/statuses/UploadAttachment.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/statuses/UploadAttachment.java
index 9a570d8b8..6cb29da11 100644
--- a/mastodon/src/main/java/org/joinmastodon/android/api/requests/statuses/UploadAttachment.java
+++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/statuses/UploadAttachment.java
@@ -17,6 +17,7 @@ import java.io.IOException;
import okhttp3.MultipartBody;
import okhttp3.RequestBody;
+import okhttp3.Response;
public class UploadAttachment extends MastodonAPIRequest{
private Uri uri;
@@ -40,6 +41,18 @@ public class UploadAttachment extends MastodonAPIRequest{
return this;
}
+ @Override
+ protected String getPathPrefix(){
+ return "/api/v2";
+ }
+
+ @Override
+ public void validateAndPostprocessResponse(Attachment respObj, Response httpResponse) throws IOException{
+ if(respObj.url==null)
+ respObj.url="";
+ super.validateAndPostprocessResponse(respObj, httpResponse);
+ }
+
@Override
public RequestBody getRequestBody() throws IOException{
MultipartBody.Builder builder=new MultipartBody.Builder()
diff --git a/mastodon/src/main/java/org/joinmastodon/android/events/SelfUpdateStateChangedEvent.java b/mastodon/src/main/java/org/joinmastodon/android/events/SelfUpdateStateChangedEvent.java
new file mode 100644
index 000000000..0ba57bdd8
--- /dev/null
+++ b/mastodon/src/main/java/org/joinmastodon/android/events/SelfUpdateStateChangedEvent.java
@@ -0,0 +1,11 @@
+package org.joinmastodon.android.events;
+
+import org.joinmastodon.android.updater.GithubSelfUpdater;
+
+public class SelfUpdateStateChangedEvent{
+ public final GithubSelfUpdater.UpdateState state;
+
+ public SelfUpdateStateChangedEvent(GithubSelfUpdater.UpdateState state){
+ this.state=state;
+ }
+}
diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/ComposeFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/ComposeFragment.java
index bad446c9a..fcb5fa8fb 100644
--- a/mastodon/src/main/java/org/joinmastodon/android/fragments/ComposeFragment.java
+++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/ComposeFragment.java
@@ -4,13 +4,18 @@ import android.animation.ObjectAnimator;
import android.annotation.SuppressLint;
import android.app.Activity;
import android.content.ClipData;
+import android.content.Context;
import android.content.Intent;
import android.content.res.Configuration;
import android.database.Cursor;
+import android.graphics.Bitmap;
import android.graphics.Outline;
import android.graphics.PixelFormat;
+import android.graphics.RenderEffect;
+import android.graphics.Shader;
import android.graphics.drawable.LayerDrawable;
import android.icu.text.BreakIterator;
+import android.media.MediaMetadataRetriever;
import android.net.Uri;
import android.os.Build;
import android.os.Bundle;
@@ -52,9 +57,13 @@ import com.twitter.twittertext.TwitterTextEmojiRegex;
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.api.MastodonErrorResponse;
import org.joinmastodon.android.api.ProgressListener;
+import org.joinmastodon.android.api.requests.accounts.GetPreferences;
import org.joinmastodon.android.api.requests.statuses.CreateStatus;
import org.joinmastodon.android.api.requests.statuses.EditStatus;
+import org.joinmastodon.android.api.requests.statuses.GetAttachmentByID;
import org.joinmastodon.android.api.requests.statuses.UploadAttachment;
import org.joinmastodon.android.api.session.AccountSession;
import org.joinmastodon.android.api.session.AccountSessionManager;
@@ -68,6 +77,7 @@ import org.joinmastodon.android.model.EmojiCategory;
import org.joinmastodon.android.model.Instance;
import org.joinmastodon.android.model.Mention;
import org.joinmastodon.android.model.Poll;
+import org.joinmastodon.android.model.Preferences;
import org.joinmastodon.android.model.Status;
import org.joinmastodon.android.model.StatusPrivacy;
import org.joinmastodon.android.ui.ComposeAutocompleteViewController;
@@ -79,6 +89,7 @@ import org.joinmastodon.android.ui.text.ComposeAutocompleteSpan;
import org.joinmastodon.android.ui.text.ComposeHashtagOrMentionSpan;
import org.joinmastodon.android.ui.text.HtmlParser;
import org.joinmastodon.android.ui.utils.SimpleTextWatcher;
+import org.joinmastodon.android.ui.utils.TransferSpeedTracker;
import org.joinmastodon.android.ui.utils.UiUtils;
import org.joinmastodon.android.ui.views.ComposeEditText;
import org.joinmastodon.android.ui.views.ComposeMediaLayout;
@@ -87,6 +98,9 @@ import org.joinmastodon.android.ui.views.SizeListenerLinearLayout;
import org.parceler.Parcel;
import org.parceler.Parcels;
+import java.io.InterruptedIOException;
+import java.net.SocketException;
+import java.net.UnknownHostException;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
@@ -108,6 +122,7 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
private static final int MEDIA_RESULT=717;
private static final int IMAGE_DESCRIPTION_RESULT=363;
private static final int MAX_ATTACHMENTS=4;
+ private static final String TAG="ComposeFragment";
private static final Pattern MENTION_PATTERN=Pattern.compile("(^|[^\\/\\w])@(([a-z0-9_]+)@[a-z0-9\\.\\-]+[a-z0-9]+)", Pattern.CASE_INSENSITIVE);
@@ -155,8 +170,7 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
private ArrayList pollOptions=new ArrayList<>();
- private ArrayList queuedAttachments=new ArrayList<>(), failedAttachments=new ArrayList<>(), attachments=new ArrayList<>(), allAttachments=new ArrayList<>();
- private DraftMediaAttachment uploadingAttachment;
+ private ArrayList attachments=new ArrayList<>();
private List customEmojis;
private CustomEmojiPopupKeyboard emojiKeyboard;
@@ -181,6 +195,8 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
private Status editingStatus;
private boolean pollChanged;
private boolean creatingView;
+ private boolean ignoreSelectionChanges=false;
+ private Runnable updateUploadEtaRunnable;
public static DraftMediaAttachment redraftAttachment(Attachment att) {
DraftMediaAttachment draft=new DraftMediaAttachment();
@@ -219,25 +235,20 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
else
charLimit=500;
- if(getArguments().containsKey("replyTo")){
- replyTo=Parcels.unwrap(getArguments().getParcelable("replyTo"));
- statusVisibility=replyTo.visibility;
- }
-
- if(getArguments().containsKey("visibility")){
- statusVisibility=(StatusPrivacy) getArguments().getSerializable("visibility");
- }
-
- if(savedInstanceState!=null){
- statusVisibility=(StatusPrivacy) savedInstanceState.getSerializable("visibility");
- }
+ loadDefaultStatusVisibility(savedInstanceState);
}
@Override
public void onDestroy(){
super.onDestroy();
- if(uploadingAttachment!=null && uploadingAttachment.uploadRequest!=null)
- uploadingAttachment.uploadRequest.cancel();
+ for(DraftMediaAttachment att:attachments){
+ if(att.isUploadingOrProcessing())
+ att.cancelUpload();
+ }
+ if(updateUploadEtaRunnable!=null){
+ UiUtils.removeCallbacks(updateUploadEtaRunnable);
+ updateUploadEtaRunnable=null;
+ }
}
@Override
@@ -365,9 +376,9 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
attachments.add(att);
}
attachmentsView.setVisibility(View.VISIBLE);
- }else if(!allAttachments.isEmpty()){
+ }else if(!attachments.isEmpty()){
attachmentsView.setVisibility(View.VISIBLE);
- for(DraftMediaAttachment att:allAttachments){
+ for(DraftMediaAttachment att:attachments){
attachmentsView.addView(createMediaAttachmentView(att));
}
}
@@ -502,7 +513,9 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
initialText=mentions.isEmpty() ? "" : TextUtils.join(" ", mentions)+" ";
if(savedInstanceState==null){
mainEditText.setText(initialText);
+ ignoreSelectionChanges=true;
mainEditText.setSelection(mainEditText.length());
+ ignoreSelectionChanges=false;
if(!TextUtils.isEmpty(replyTo.spoilerText)){
hasSpoiler=true;
spoilerEdit.setVisibility(View.VISIBLE);
@@ -517,7 +530,9 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
if(editingStatus!=null){
initialText=getArguments().getString("sourceText", "");
mainEditText.setText(initialText);
+ ignoreSelectionChanges=true;
mainEditText.setSelection(mainEditText.length());
+ ignoreSelectionChanges=false;
if(!editingStatus.mediaAttachments.isEmpty()){
attachmentsView.setVisibility(View.VISIBLE);
for(Attachment att:editingStatus.mediaAttachments){
@@ -534,7 +549,9 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
String prefilledText=getArguments().getString("prefilledText");
if(!TextUtils.isEmpty(prefilledText)){
mainEditText.setText(prefilledText);
+ ignoreSelectionChanges=true;
mainEditText.setSelection(mainEditText.length());
+ ignoreSelectionChanges=false;
initialText=prefilledText;
}
ArrayList mediaUris=getArguments().getParcelableArrayList("mediaAttachments");
@@ -624,8 +641,14 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
if(opt.edit.length()>0)
nonEmptyPollOptionsCount++;
}
- publishButton.setEnabled((trimmedCharCount>0 || !attachments.isEmpty()) && charCount<=charLimit && uploadingAttachment==null && failedAttachments.isEmpty() && queuedAttachments.isEmpty()
- && (pollOptions.isEmpty() || nonEmptyPollOptionsCount>1));
+ if(publishButton==null)
+ return;
+ int nonDoneAttachmentCount=0;
+ for(DraftMediaAttachment att:attachments){
+ if(att.state!=AttachmentUploadState.DONE)
+ nonDoneAttachmentCount++;
+ }
+ publishButton.setEnabled((trimmedCharCount>0 || !attachments.isEmpty()) && charCount<=charLimit && nonDoneAttachmentCount==0 && (pollOptions.isEmpty() || nonEmptyPollOptionsCount>1));
}
private void onCustomEmojiClick(Emoji emoji){
@@ -721,6 +744,7 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
}
private boolean hasDraft(){
+ if(getArguments().getBoolean("hasDraft", false)) return true;
if(editingStatus!=null){
if(!mainEditText.getText().toString().equals(initialText))
return true;
@@ -732,10 +756,7 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
boolean pollFieldsHaveContent=false;
for(DraftPollOption opt:pollOptions)
pollFieldsHaveContent|=opt.edit.length()>0;
- return getArguments().getBoolean("hasDraft", false)
- || (mainEditText.length()>0 && !mainEditText.getText().toString().equals(initialText))
- || !attachments.isEmpty() || uploadingAttachment!=null || !queuedAttachments.isEmpty()
- || !failedAttachments.isEmpty() || pollFieldsHaveContent;
+ return (mainEditText.length()>0 && !mainEditText.getText().toString().equals(initialText)) || !attachments.isEmpty() || pollFieldsHaveContent;
}
@Override
@@ -836,7 +857,7 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
}
if(size>sizeLimit){
float mb=sizeLimit/(float) (1024*1024);
- String sMb=String.format(Locale.getDefault(), mb%1f==0f ? "%f" : "%.2f", mb);
+ String sMb=String.format(Locale.getDefault(), mb%1f==0f ? "%.0f" : "%.2f", mb);
showMediaAttachmentError(getString(R.string.media_attachment_too_big, UiUtils.getFileName(uri), sMb));
return false;
}
@@ -845,18 +866,16 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
pollBtn.setEnabled(false);
DraftMediaAttachment draft=new DraftMediaAttachment();
draft.uri=uri;
+ draft.mimeType=type;
draft.description=description;
attachmentsView.addView(createMediaAttachmentView(draft));
- allAttachments.add(draft);
+ attachments.add(draft);
attachmentsView.setVisibility(View.VISIBLE);
- draft.overlay.setVisibility(View.VISIBLE);
- draft.infoBar.setVisibility(View.GONE);
+ draft.setOverlayVisible(true, false);
- if(uploadingAttachment==null){
- uploadMediaAttachment(draft);
- }else{
- queuedAttachments.add(draft);
+ if(!areThereAnyUploadingAttachments()){
+ uploadNextQueuedAttachment();
}
updatePublishButtonState();
if(getMediaAttachmentsCount()==MAX_ATTACHMENTS)
@@ -875,25 +894,35 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
private View createMediaAttachmentView(DraftMediaAttachment draft){
View thumb=getActivity().getLayoutInflater().inflate(R.layout.compose_media_thumb, attachmentsView, false);
ImageView img=thumb.findViewById(R.id.thumb);
- ViewImageLoader.load(img, null, new UrlImageLoaderRequest(draft.uri, V.dp(250), V.dp(250)));
+ if(draft.serverAttachment!=null){
+ ViewImageLoader.load(img, draft.serverAttachment.blurhashPlaceholder, new UrlImageLoaderRequest(draft.serverAttachment.previewUrl, V.dp(250), V.dp(250)));
+ }else{
+ if(draft.mimeType.startsWith("image/")){
+ ViewImageLoader.load(img, null, new UrlImageLoaderRequest(draft.uri, V.dp(250), V.dp(250)));
+ }else if(draft.mimeType.startsWith("video/")){
+ loadVideoThumbIntoView(img, draft.uri);
+ }
+ }
TextView fileName=thumb.findViewById(R.id.file_name);
- fileName.setText(UiUtils.getFileName(draft.uri));
+ fileName.setText(UiUtils.getFileName(draft.serverAttachment!=null ? Uri.parse(draft.serverAttachment.url) : draft.uri));
draft.view=thumb;
+ draft.imageView=img;
draft.progressBar=thumb.findViewById(R.id.progress);
draft.infoBar=thumb.findViewById(R.id.info_bar);
draft.overlay=thumb.findViewById(R.id.overlay);
draft.descriptionView=thumb.findViewById(R.id.description);
+ draft.uploadStateTitle=thumb.findViewById(R.id.state_title);
+ draft.uploadStateText=thumb.findViewById(R.id.state_text);
ImageButton btn=thumb.findViewById(R.id.remove_btn);
btn.setTag(draft);
btn.setOnClickListener(this::onRemoveMediaAttachmentClick);
btn=thumb.findViewById(R.id.remove_btn2);
btn.setTag(draft);
btn.setOnClickListener(this::onRemoveMediaAttachmentClick);
- Button retry=thumb.findViewById(R.id.retry_upload);
+ ImageButton retry=thumb.findViewById(R.id.retry_or_cancel_upload);
retry.setTag(draft);
- retry.setOnClickListener(this::onRetryMediaUploadClick);
- retry.setVisibility(View.GONE);
+ retry.setOnClickListener(this::onRetryOrCancelMediaUploadClick);
draft.retryButton=retry;
draft.infoBar.setTag(draft);
draft.infoBar.setOnClickListener(this::onEditMediaDescriptionClick);
@@ -901,12 +930,14 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
if(!TextUtils.isEmpty(draft.description))
draft.descriptionView.setText(draft.description);
- if(uploadingAttachment!=draft && !queuedAttachments.contains(draft)){
- draft.progressBar.setVisibility(View.GONE);
+ if(Build.VERSION.SDK_INT>=Build.VERSION_CODES.S){
+ draft.overlay.setBackgroundColor(0xA6000000);
}
- if(failedAttachments.contains(draft)){
- draft.infoBar.setVisibility(View.GONE);
- draft.overlay.setVisibility(View.VISIBLE);
+
+ if(draft.state==AttachmentUploadState.UPLOADING || draft.state==AttachmentUploadState.PROCESSING || draft.state==AttachmentUploadState.QUEUED){
+ draft.progressBar.setVisibility(View.GONE);
+ }else if(draft.state==AttachmentUploadState.ERROR){
+ draft.setOverlayVisible(true, false);
}
return thumb;
@@ -918,67 +949,92 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
draft.uri=uri;
draft.description=description;
attachmentsView.addView(createMediaAttachmentView(draft));
- allAttachments.add(draft);
+ attachments.add(draft);
attachmentsView.setVisibility(View.VISIBLE);
}
private void uploadMediaAttachment(DraftMediaAttachment attachment){
- if(uploadingAttachment!=null)
- throw new IllegalStateException("there is already an attachment being uploaded");
- uploadingAttachment=attachment;
+ if(areThereAnyUploadingAttachments()){
+ throw new IllegalStateException("there is already an attachment being uploaded");
+ }
+ attachment.state=AttachmentUploadState.UPLOADING;
attachment.progressBar.setVisibility(View.VISIBLE);
ObjectAnimator rotationAnimator=ObjectAnimator.ofFloat(attachment.progressBar, View.ROTATION, 0f, 360f);
rotationAnimator.setInterpolator(new LinearInterpolator());
rotationAnimator.setDuration(1500);
rotationAnimator.setRepeatCount(ObjectAnimator.INFINITE);
rotationAnimator.start();
+ attachment.progressBarAnimator=rotationAnimator;
int maxSize=0;
String contentType=getActivity().getContentResolver().getType(attachment.uri);
if(contentType!=null && contentType.startsWith("image/")){
maxSize=2_073_600; // TODO get this from instance configuration when it gets added there
}
+ attachment.uploadStateTitle.setText("");
+ attachment.uploadStateText.setText("");
+ attachment.progressBar.setProgress(0);
+ attachment.speedTracker.reset();
+ attachment.speedTracker.addSample(0);
attachment.uploadRequest=(UploadAttachment) new UploadAttachment(attachment.uri, maxSize, attachment.description)
.setProgressListener(new ProgressListener(){
@Override
public void onProgress(long transferred, long total){
+ if(updateUploadEtaRunnable==null){
+ UiUtils.runOnUiThread(updateUploadEtaRunnable=ComposeFragment.this::updateUploadETAs, 100);
+ }
int progress=Math.round(transferred/(float)total*attachment.progressBar.getMax());
if(Build.VERSION.SDK_INT>=24)
attachment.progressBar.setProgress(progress, true);
else
attachment.progressBar.setProgress(progress);
+
+ attachment.speedTracker.setTotalBytes(total);
+ attachment.uploadStateTitle.setText(getString(R.string.file_upload_progress, UiUtils.formatFileSize(getActivity(), transferred, true), UiUtils.formatFileSize(getActivity(), total, true)));
+ attachment.speedTracker.addSample(transferred);
}
})
.setCallback(new Callback<>(){
@Override
public void onSuccess(Attachment result){
attachment.serverAttachment=result;
- attachment.uploadRequest=null;
- uploadingAttachment=null;
- attachments.add(attachment);
- attachment.progressBar.setVisibility(View.GONE);
- if(!queuedAttachments.isEmpty())
- uploadMediaAttachment(queuedAttachments.remove(0));
- updatePublishButtonState();
-
- rotationAnimator.cancel();
- V.setVisibilityAnimated(attachment.overlay, View.GONE);
- V.setVisibilityAnimated(attachment.infoBar, View.VISIBLE);
+ if(TextUtils.isEmpty(result.url)){
+ attachment.state=AttachmentUploadState.PROCESSING;
+ attachment.processingPollingRunnable=()->pollForMediaAttachmentProcessing(attachment);
+ if(getActivity()==null)
+ return;
+ attachment.uploadStateTitle.setText(R.string.upload_processing);
+ attachment.uploadStateText.setText("");
+ UiUtils.runOnUiThread(attachment.processingPollingRunnable, 1000);
+ if(!areThereAnyUploadingAttachments())
+ uploadNextQueuedAttachment();
+ }else{
+ finishMediaAttachmentUpload(attachment);
+ }
}
@Override
public void onError(ErrorResponse error){
attachment.uploadRequest=null;
- uploadingAttachment=null;
- failedAttachments.add(attachment);
-// error.showToast(getActivity());
- Toast.makeText(getActivity(), R.string.image_upload_failed, Toast.LENGTH_SHORT).show();
+ attachment.progressBarAnimator=null;
+ attachment.state=AttachmentUploadState.ERROR;
+ attachment.uploadStateTitle.setText(R.string.upload_failed);
+ if(error instanceof MastodonErrorResponse er){
+ if(er.underlyingException instanceof SocketException || er.underlyingException instanceof UnknownHostException || er.underlyingException instanceof InterruptedIOException)
+ attachment.uploadStateText.setText(R.string.upload_error_connection_lost);
+ else
+ attachment.uploadStateText.setText(er.error);
+ }else{
+ attachment.uploadStateText.setText("");
+ }
+ attachment.retryButton.setImageResource(R.drawable.ic_fluent_arrow_clockwise_24_filled);
+ attachment.retryButton.setContentDescription(getString(R.string.retry_upload));
rotationAnimator.cancel();
V.setVisibilityAnimated(attachment.retryButton, View.VISIBLE);
V.setVisibilityAnimated(attachment.progressBar, View.GONE);
- if(!queuedAttachments.isEmpty())
- uploadMediaAttachment(queuedAttachments.remove(0));
+ if(!areThereAnyUploadingAttachments())
+ uploadNextQueuedAttachment();
}
})
.exec(accountID);
@@ -986,37 +1042,109 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
private void onRemoveMediaAttachmentClick(View v){
DraftMediaAttachment att=(DraftMediaAttachment) v.getTag();
- if(att==uploadingAttachment){
- att.uploadRequest.cancel();
- uploadingAttachment=null;
- if(!queuedAttachments.isEmpty())
- uploadMediaAttachment(queuedAttachments.remove(0));
- }else{
- attachments.remove(att);
- queuedAttachments.remove(att);
- failedAttachments.remove(att);
- }
- allAttachments.remove(att);
+ if(att.isUploadingOrProcessing())
+ att.cancelUpload();
+ attachments.remove(att);
+ uploadNextQueuedAttachment();
attachmentsView.removeView(att.view);
if(getMediaAttachmentsCount()==0)
attachmentsView.setVisibility(View.GONE);
updatePublishButtonState();
- pollBtn.setEnabled(attachments.isEmpty() && queuedAttachments.isEmpty() && failedAttachments.isEmpty() && uploadingAttachment==null);
+ pollBtn.setEnabled(attachments.isEmpty());
mediaBtn.setEnabled(true);
}
- private void onRetryMediaUploadClick(View v){
+ private void onRetryOrCancelMediaUploadClick(View v){
DraftMediaAttachment att=(DraftMediaAttachment) v.getTag();
- if(failedAttachments.remove(att)){
- V.setVisibilityAnimated(att.retryButton, View.GONE);
+ if(att.state==AttachmentUploadState.ERROR){
+ att.retryButton.setImageResource(R.drawable.ic_fluent_dismiss_24_filled);
+ att.retryButton.setContentDescription(getString(R.string.cancel));
V.setVisibilityAnimated(att.progressBar, View.VISIBLE);
- if(uploadingAttachment==null)
- uploadMediaAttachment(att);
- else
- queuedAttachments.add(att);
+ att.state=AttachmentUploadState.QUEUED;
+ if(!areThereAnyUploadingAttachments()){
+ uploadNextQueuedAttachment();
+ }
+ }else{
+ onRemoveMediaAttachmentClick(v);
}
}
+ private void pollForMediaAttachmentProcessing(DraftMediaAttachment attachment){
+ attachment.processingPollingRequest=(GetAttachmentByID) new GetAttachmentByID(attachment.serverAttachment.id)
+ .setCallback(new Callback<>(){
+ @Override
+ public void onSuccess(Attachment result){
+ attachment.processingPollingRequest=null;
+ if(!TextUtils.isEmpty(result.url)){
+ attachment.processingPollingRunnable=null;
+ attachment.serverAttachment=result;
+ finishMediaAttachmentUpload(attachment);
+ }else if(getActivity()!=null){
+ UiUtils.runOnUiThread(attachment.processingPollingRunnable, 1000);
+ }
+ }
+
+ @Override
+ public void onError(ErrorResponse error){
+ attachment.processingPollingRequest=null;
+ if(getActivity()!=null)
+ UiUtils.runOnUiThread(attachment.processingPollingRunnable, 1000);
+ }
+ })
+ .exec(accountID);
+ }
+
+ private void finishMediaAttachmentUpload(DraftMediaAttachment attachment){
+ if(attachment.state!=AttachmentUploadState.PROCESSING && attachment.state!=AttachmentUploadState.UPLOADING)
+ throw new IllegalStateException("Unexpected state "+attachment.state);
+ attachment.uploadRequest=null;
+ attachment.state=AttachmentUploadState.DONE;
+ attachment.progressBar.setVisibility(View.GONE);
+ if(!areThereAnyUploadingAttachments())
+ uploadNextQueuedAttachment();
+ updatePublishButtonState();
+
+ if(attachment.progressBarAnimator!=null){
+ attachment.progressBarAnimator.cancel();
+ attachment.progressBarAnimator=null;
+ }
+ attachment.setOverlayVisible(false, true);
+ }
+
+ private void uploadNextQueuedAttachment(){
+ for(DraftMediaAttachment att:attachments){
+ if(att.state==AttachmentUploadState.QUEUED){
+ uploadMediaAttachment(att);
+ return;
+ }
+ }
+ }
+
+ private boolean areThereAnyUploadingAttachments(){
+ for(DraftMediaAttachment att:attachments){
+ if(att.state==AttachmentUploadState.UPLOADING)
+ return true;
+ }
+ return false;
+ }
+
+ private void updateUploadETAs(){
+ if(!areThereAnyUploadingAttachments()){
+ UiUtils.removeCallbacks(updateUploadEtaRunnable);
+ updateUploadEtaRunnable=null;
+ return;
+ }
+ for(DraftMediaAttachment att:attachments){
+ if(att.state==AttachmentUploadState.UPLOADING){
+ long eta=att.speedTracker.updateAndGetETA();
+// Log.i(TAG, "onProgress: transfer speed "+UiUtils.formatFileSize(getActivity(), Math.round(att.speedTracker.getLastSpeed()), false)+" average "+UiUtils.formatFileSize(getActivity(), Math.round(att.speedTracker.getAverageSpeed()), false)+" eta "+eta);
+ String time=String.format("%d:%02d", eta/60, eta%60);
+ att.uploadStateText.setText(getString(R.string.file_upload_time_remaining, time));
+ }
+ }
+ UiUtils.runOnUiThread(updateUploadEtaRunnable, 100);
+ }
+
private void onEditMediaDescriptionClick(View v){
DraftMediaAttachment att=(DraftMediaAttachment) v.getTag();
if(att.serverAttachment==null)
@@ -1129,7 +1257,7 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
}
private int getMediaAttachmentsCount(){
- return allAttachments.size();
+ return attachments.size();
}
private void onVisibilityClick(View v){
@@ -1165,6 +1293,47 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
menu.show();
}
+ private void loadDefaultStatusVisibility(Bundle savedInstanceState) {
+ if(getArguments().containsKey("replyTo")){
+ replyTo=Parcels.unwrap(getArguments().getParcelable("replyTo"));
+ statusVisibility = replyTo.visibility;
+ }
+
+ // A saved privacy setting from a previous compose session wins over the reply visibility
+ if(savedInstanceState !=null){
+ statusVisibility = (StatusPrivacy) savedInstanceState.getSerializable("visibility");
+ }
+
+ new GetPreferences()
+ .setCallback(new Callback<>(){
+ @Override
+ public void onSuccess(Preferences result){
+ // Only override the reply visibility if our preference is more private
+ if (result.postingDefaultVisibility.isLessVisibleThan(statusVisibility)) {
+ statusVisibility = switch (result.postingDefaultVisibility) {
+ case PUBLIC -> StatusPrivacy.PUBLIC;
+ case UNLISTED -> StatusPrivacy.UNLISTED;
+ case PRIVATE -> StatusPrivacy.PRIVATE;
+ case DIRECT -> StatusPrivacy.DIRECT;
+ };
+ }
+
+ // A saved privacy setting from a previous compose session wins over all
+ if(savedInstanceState !=null){
+ statusVisibility = (StatusPrivacy) savedInstanceState.getSerializable("visibility");
+ }
+
+ updateVisibilityIcon ();
+ }
+
+ @Override
+ public void onError(ErrorResponse error){
+ Log.w(TAG, "Unable to get user preferences to set default post privacy");
+ }
+ })
+ .exec(accountID);
+ }
+
private void updateVisibilityIcon(){
if(statusVisibility==null){ // TODO find out why this happens
statusVisibility=StatusPrivacy.PUBLIC;
@@ -1179,6 +1348,8 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
@Override
public void onSelectionChanged(int start, int end){
+ if(ignoreSelectionChanges)
+ return;
if(start==end && mainEditText.length()>0){
ComposeAutocompleteSpan[] spans=mainEditText.getText().getSpans(start, end, ComposeAutocompleteSpan.class);
if(spans.length>0){
@@ -1249,6 +1420,30 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
finishAutocomplete();
}
+ private void loadVideoThumbIntoView(ImageView target, Uri uri){
+ MastodonAPIController.runInBackground(()->{
+ Context context=getActivity();
+ if(context==null)
+ return;
+ try{
+ MediaMetadataRetriever mmr=new MediaMetadataRetriever();
+ mmr.setDataSource(context, uri);
+ Bitmap frame=mmr.getFrameAtTime(3_000_000);
+ mmr.release();
+ int size=Math.max(frame.getWidth(), frame.getHeight());
+ int maxSize=V.dp(250);
+ if(size>maxSize){
+ float factor=maxSize/(float)size;
+ frame=Bitmap.createScaledBitmap(frame, Math.round(frame.getWidth()*factor), Math.round(frame.getHeight()*factor), true);
+ }
+ Bitmap finalFrame=frame;
+ target.post(()->target.setImageBitmap(finalFrame));
+ }catch(Exception x){
+ Log.w(TAG, "loadVideoThumbIntoView: error getting video frame", x);
+ }
+ });
+ }
+
@Override
public CharSequence getTitle(){
return getString(R.string.new_post);
@@ -1269,14 +1464,75 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
public Attachment serverAttachment;
public Uri uri;
public transient UploadAttachment uploadRequest;
+ public transient GetAttachmentByID processingPollingRequest;
public String description;
+ public String mimeType;
+ public AttachmentUploadState state=AttachmentUploadState.QUEUED;
public transient View view;
public transient ProgressBar progressBar;
public transient TextView descriptionView;
public transient View overlay;
public transient View infoBar;
- public transient Button retryButton;
+ public transient ImageButton retryButton;
+ public transient ObjectAnimator progressBarAnimator;
+ public transient Runnable processingPollingRunnable;
+ public transient ImageView imageView;
+ public transient TextView uploadStateTitle, uploadStateText;
+ public transient TransferSpeedTracker speedTracker=new TransferSpeedTracker();
+
+ public void cancelUpload(){
+ switch(state){
+ case UPLOADING -> {
+ if(uploadRequest!=null){
+ uploadRequest.cancel();
+ uploadRequest=null;
+ }
+ }
+ case PROCESSING -> {
+ if(processingPollingRunnable!=null){
+ UiUtils.removeCallbacks(processingPollingRunnable);
+ processingPollingRunnable=null;
+ }
+ if(processingPollingRequest!=null){
+ processingPollingRequest.cancel();
+ processingPollingRequest=null;
+ }
+ }
+ default -> throw new IllegalStateException("Unexpected state "+state);
+ }
+ }
+
+ public boolean isUploadingOrProcessing(){
+ return state==AttachmentUploadState.UPLOADING || state==AttachmentUploadState.PROCESSING;
+ }
+
+ public void setOverlayVisible(boolean visible, boolean animated){
+ if(Build.VERSION.SDK_INT>=Build.VERSION_CODES.S){
+ if(visible){
+ imageView.setRenderEffect(RenderEffect.createBlurEffect(V.dp(16), V.dp(16), Shader.TileMode.REPEAT));
+ }else{
+ imageView.setRenderEffect(null);
+ }
+ }
+ int infoBarVis=visible ? View.GONE : View.VISIBLE;
+ int overlayVis=visible ? View.VISIBLE : View.GONE;
+ if(animated){
+ V.setVisibilityAnimated(infoBar, infoBarVis);
+ V.setVisibilityAnimated(overlay, overlayVis);
+ }else{
+ infoBar.setVisibility(infoBarVis);
+ overlay.setVisibility(overlayVis);
+ }
+ }
+ }
+
+ enum AttachmentUploadState{
+ QUEUED,
+ UPLOADING,
+ PROCESSING,
+ ERROR,
+ DONE
}
private static class DraftPollOption{
diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/HomeFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/HomeFragment.java
index 4483fd5a3..37fdc7f35 100644
--- a/mastodon/src/main/java/org/joinmastodon/android/fragments/HomeFragment.java
+++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/HomeFragment.java
@@ -2,13 +2,9 @@ package org.joinmastodon.android.fragments;
import android.app.Fragment;
import android.app.NotificationManager;
-import android.content.Intent;
-import android.content.res.Configuration;
import android.graphics.Outline;
-import android.graphics.drawable.ColorDrawable;
import android.os.Build;
import android.os.Bundle;
-import android.util.Log;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
@@ -18,20 +14,14 @@ import android.view.WindowInsets;
import android.widget.FrameLayout;
import android.widget.ImageView;
import android.widget.LinearLayout;
-import android.widget.TextView;
-import org.joinmastodon.android.MainActivity;
-import org.joinmastodon.android.MastodonApp;
import org.joinmastodon.android.PushNotificationReceiver;
import org.joinmastodon.android.R;
-import org.joinmastodon.android.api.requests.oauth.RevokeOauthToken;
import org.joinmastodon.android.api.session.AccountSession;
import org.joinmastodon.android.api.session.AccountSessionManager;
import org.joinmastodon.android.fragments.discover.DiscoverFragment;
-import org.joinmastodon.android.fragments.discover.SearchFragment;
import org.joinmastodon.android.model.Account;
import org.joinmastodon.android.ui.AccountSwitcherSheet;
-import org.joinmastodon.android.ui.M3AlertDialogBuilder;
import org.joinmastodon.android.ui.utils.UiUtils;
import org.joinmastodon.android.ui.views.TabBar;
import org.parceler.Parcels;
@@ -41,15 +31,12 @@ import java.util.ArrayList;
import androidx.annotation.IdRes;
import androidx.annotation.Nullable;
import me.grishka.appkit.FragmentStackActivity;
-import me.grishka.appkit.Nav;
-import me.grishka.appkit.api.ErrorResponse;
import me.grishka.appkit.fragments.AppKitFragment;
import me.grishka.appkit.fragments.LoaderFragment;
import me.grishka.appkit.fragments.OnBackPressedListener;
import me.grishka.appkit.imageloader.ViewImageLoader;
import me.grishka.appkit.imageloader.requests.UrlImageLoaderRequest;
import me.grishka.appkit.utils.V;
-import me.grishka.appkit.views.BottomSheet;
import me.grishka.appkit.views.FragmentRootLinearLayout;
public class HomeFragment extends AppKitFragment implements OnBackPressedListener{
@@ -141,7 +128,6 @@ public class HomeFragment extends AppKitFragment implements OnBackPressedListene
}
});
}
- }else{
}
return content;
diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/HomeTimelineFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/HomeTimelineFragment.java
index d93a26022..31c302218 100644
--- a/mastodon/src/main/java/org/joinmastodon/android/fragments/HomeTimelineFragment.java
+++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/HomeTimelineFragment.java
@@ -23,9 +23,11 @@ import android.widget.Toolbar;
import com.squareup.otto.Subscribe;
+import org.joinmastodon.android.E;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.requests.timelines.GetHomeTimeline;
import org.joinmastodon.android.api.session.AccountSessionManager;
+import org.joinmastodon.android.events.SelfUpdateStateChangedEvent;
import org.joinmastodon.android.events.StatusCreatedEvent;
import org.joinmastodon.android.model.CacheablePaginatedResponse;
import org.joinmastodon.android.model.Filter;
@@ -33,6 +35,7 @@ import org.joinmastodon.android.model.Status;
import org.joinmastodon.android.ui.displayitems.GapStatusDisplayItem;
import org.joinmastodon.android.ui.displayitems.StatusDisplayItem;
import org.joinmastodon.android.ui.utils.UiUtils;
+import org.joinmastodon.android.updater.GithubSelfUpdater;
import org.joinmastodon.android.utils.StatusFilterPredicate;
import java.util.Collections;
@@ -101,6 +104,11 @@ public class HomeTimelineFragment extends StatusListFragment{
}
}
});
+
+ if(GithubSelfUpdater.needSelfUpdating()){
+ E.register(this);
+ updateUpdateState(GithubSelfUpdater.getInstance().getState());
+ }
}
@Override
@@ -397,4 +405,22 @@ public class HomeTimelineFragment extends StatusListFragment{
scrollToTop();
}
}
+
+ @Override
+ public void onDestroyView(){
+ super.onDestroyView();
+ if(GithubSelfUpdater.needSelfUpdating()){
+ E.unregister(this);
+ }
+ }
+
+ private void updateUpdateState(GithubSelfUpdater.UpdateState state){
+ if(state!=GithubSelfUpdater.UpdateState.NO_UPDATE && state!=GithubSelfUpdater.UpdateState.CHECKING)
+ getToolbar().getMenu().findItem(R.id.settings).setIcon(R.drawable.ic_settings_24_badged);
+ }
+
+ @Subscribe
+ public void onSelfUpdateStateChanged(SelfUpdateStateChangedEvent ev){
+ updateUpdateState(ev.state);
+ }
}
diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/ProfileFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/ProfileFragment.java
index 9f2df3bf9..31d7e3895 100644
--- a/mastodon/src/main/java/org/joinmastodon/android/fragments/ProfileFragment.java
+++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/ProfileFragment.java
@@ -421,9 +421,16 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList
HtmlParser.parseCustomEmoji(ssb, account.emojis);
name.setText(ssb);
setTitle(ssb);
+
+ boolean isSelf=AccountSessionManager.getInstance().isSelf(accountID, account);
+
if(account.locked){
ssb=new SpannableStringBuilder("@");
ssb.append(account.acct);
+ if(isSelf){
+ ssb.append('@');
+ ssb.append(AccountSessionManager.getInstance().getAccount(accountID).domain);
+ }
ssb.append(" ");
Drawable lock=username.getResources().getDrawable(R.drawable.ic_fluent_lock_closed_20_filled, getActivity().getTheme()).mutate();
lock.setBounds(0, 0, lock.getIntrinsicWidth(), lock.getIntrinsicHeight());
@@ -431,7 +438,8 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList
ssb.append(getString(R.string.manually_approves_followers), new ImageSpan(lock, ImageSpan.ALIGN_BOTTOM), 0);
username.setText(ssb);
}else{
- username.setText('@'+account.acct);
+ // noinspection SetTextI18n
+ username.setText('@'+account.acct+(isSelf ? ('@'+AccountSessionManager.getInstance().getAccount(accountID).domain) : ""));
}
CharSequence parsedBio=HtmlParser.parse(account.note, account.emojis, Collections.emptyList(), Collections.emptyList(), accountID);
if(TextUtils.isEmpty(parsedBio)){
diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/SettingsFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/SettingsFragment.java
index 92535a487..8b1590595 100644
--- a/mastodon/src/main/java/org/joinmastodon/android/fragments/SettingsFragment.java
+++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/SettingsFragment.java
@@ -1,5 +1,6 @@
package org.joinmastodon.android.fragments;
+import android.animation.ObjectAnimator;
import android.annotation.SuppressLint;
import android.app.Activity;
import android.content.Intent;
@@ -14,15 +15,21 @@ import android.view.View;
import android.view.ViewGroup;
import android.view.WindowInsets;
import android.view.WindowManager;
+import android.view.animation.LinearInterpolator;
import android.widget.Button;
+import android.widget.ImageButton;
import android.widget.ImageView;
import android.widget.PopupMenu;
+import android.widget.ProgressBar;
import android.widget.RadioButton;
import android.widget.Switch;
import android.widget.TextView;
import android.widget.Toast;
+import com.squareup.otto.Subscribe;
+
import org.joinmastodon.android.BuildConfig;
+import org.joinmastodon.android.E;
import org.joinmastodon.android.GlobalUserPreferences;
import org.joinmastodon.android.MainActivity;
import org.joinmastodon.android.MastodonApp;
@@ -31,11 +38,13 @@ import org.joinmastodon.android.api.MastodonAPIController;
import org.joinmastodon.android.api.requests.oauth.RevokeOauthToken;
import org.joinmastodon.android.api.session.AccountSession;
import org.joinmastodon.android.api.session.AccountSessionManager;
+import org.joinmastodon.android.events.SelfUpdateStateChangedEvent;
import org.joinmastodon.android.model.PushNotification;
import org.joinmastodon.android.model.PushSubscription;
import org.joinmastodon.android.ui.M3AlertDialogBuilder;
import org.joinmastodon.android.ui.OutlineProviders;
import org.joinmastodon.android.ui.utils.UiUtils;
+import org.joinmastodon.android.updater.GithubSelfUpdater;
import java.util.ArrayList;
import java.util.function.Consumer;
@@ -47,7 +56,6 @@ import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import me.grishka.appkit.api.Callback;
import me.grishka.appkit.api.ErrorResponse;
-import me.grishka.appkit.fragments.ToolbarFragment;
import me.grishka.appkit.imageloader.ImageCache;
import me.grishka.appkit.utils.BindableViewHolder;
import me.grishka.appkit.utils.V;
@@ -73,6 +81,14 @@ public class SettingsFragment extends MastodonToolbarFragment{
accountID=getArguments().getString("account");
AccountSession session=AccountSessionManager.getInstance().getAccount(accountID);
+ if(GithubSelfUpdater.needSelfUpdating()){
+ GithubSelfUpdater updater=GithubSelfUpdater.getInstance();
+ GithubSelfUpdater.UpdateState state=updater.getState();
+ if(state!=GithubSelfUpdater.UpdateState.NO_UPDATE && state!=GithubSelfUpdater.UpdateState.CHECKING){
+ items.add(new UpdateItem());
+ }
+ }
+
items.add(new HeaderItem(R.string.settings_theme));
items.add(themeItem=new ThemeItem());
items.add(new SwitchItem(R.string.theme_true_black, R.drawable.ic_fluent_dark_theme_24_regular, GlobalUserPreferences.trueBlackTheme, this::onTrueBlackThemeChanged));
@@ -131,7 +147,7 @@ public class SettingsFragment extends MastodonToolbarFragment{
public void getItemOffsets(@NonNull Rect outRect, @NonNull View view, @NonNull RecyclerView parent, @NonNull RecyclerView.State state){
// Add 32dp gaps between sections
RecyclerView.ViewHolder holder=parent.getChildViewHolder(view);
- if((holder instanceof HeaderViewHolder || holder instanceof FooterViewHolder) && holder.getAbsoluteAdapterPosition()>0)
+ 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/model/ExpandMedia.java b/mastodon/src/main/java/org/joinmastodon/android/model/ExpandMedia.java
new file mode 100644
index 000000000..215341122
--- /dev/null
+++ b/mastodon/src/main/java/org/joinmastodon/android/model/ExpandMedia.java
@@ -0,0 +1,12 @@
+package org.joinmastodon.android.model;
+
+import com.google.gson.annotations.SerializedName;
+
+public enum ExpandMedia {
+ @SerializedName("default")
+ DEFAULT,
+ @SerializedName("show_all")
+ SHOW_ALL,
+ @SerializedName("hide_all")
+ HIDE_ALL;
+}
diff --git a/mastodon/src/main/java/org/joinmastodon/android/model/Preferences.java b/mastodon/src/main/java/org/joinmastodon/android/model/Preferences.java
new file mode 100644
index 000000000..2c5ee8c9d
--- /dev/null
+++ b/mastodon/src/main/java/org/joinmastodon/android/model/Preferences.java
@@ -0,0 +1,38 @@
+package org.joinmastodon.android.model;
+
+import com.google.gson.annotations.SerializedName;
+
+/**
+ * Preferred common behaviors to be shared across clients.
+ */
+public class Preferences extends BaseModel {
+ /**
+ * Default visibility for new posts
+ */
+ @SerializedName("posting:default:visibility")
+ public StatusPrivacy postingDefaultVisibility;
+
+ /**
+ * Default sensitivity flag for new posts
+ */
+ @SerializedName("posting:default:sensitive")
+ public boolean postingDefaultSensitive;
+
+ /**
+ * Default language for new posts
+ */
+ @SerializedName("posting:default:language")
+ public String postingDefaultLanguage;
+
+ /**
+ * Whether media attachments should be automatically displayed or blurred/hidden.
+ */
+ @SerializedName("reading:expand:media")
+ public ExpandMedia readingExpandMedia;
+
+ /**
+ * Whether CWs should be expanded by default.
+ */
+ @SerializedName("reading:expand:spoilers")
+ public boolean readingExpandSpoilers;
+}
\ No newline at end of file
diff --git a/mastodon/src/main/java/org/joinmastodon/android/model/StatusPrivacy.java b/mastodon/src/main/java/org/joinmastodon/android/model/StatusPrivacy.java
index fd205e960..cb8d6a0e5 100644
--- a/mastodon/src/main/java/org/joinmastodon/android/model/StatusPrivacy.java
+++ b/mastodon/src/main/java/org/joinmastodon/android/model/StatusPrivacy.java
@@ -4,11 +4,25 @@ import com.google.gson.annotations.SerializedName;
public enum StatusPrivacy{
@SerializedName("public")
- PUBLIC,
+ PUBLIC(0),
@SerializedName("unlisted")
- UNLISTED,
+ UNLISTED(1),
@SerializedName("private")
- PRIVATE,
+ PRIVATE(2),
@SerializedName("direct")
- DIRECT;
+ DIRECT(3);
+
+ private int privacy;
+
+ StatusPrivacy(int privacy) {
+ this.privacy = privacy;
+ }
+
+ public boolean isLessVisibleThan(StatusPrivacy other) {
+ return privacy > other.getPrivacy();
+ }
+
+ public int getPrivacy() {
+ return privacy;
+ }
}
diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/text/HtmlParser.java b/mastodon/src/main/java/org/joinmastodon/android/ui/text/HtmlParser.java
index ec3757dd5..a27ced1da 100644
--- a/mastodon/src/main/java/org/joinmastodon/android/ui/text/HtmlParser.java
+++ b/mastodon/src/main/java/org/joinmastodon/android/ui/text/HtmlParser.java
@@ -129,7 +129,16 @@ public class HtmlParser{
}
public static void parseCustomEmoji(SpannableStringBuilder ssb, List emojis){
- Map emojiByCode=emojis.stream().collect(Collectors.toMap(e->e.shortcode, Function.identity()));
+ Map emojiByCode =
+ emojis.stream()
+ .collect(
+ Collectors.toMap(e->e.shortcode, Function.identity(), (emoji1, emoji2) -> {
+ // Ignore duplicate shortcodes and just take the first, it will be
+ // the same emoji anyway
+ return emoji1;
+ })
+ );
+
Matcher matcher=EMOJI_CODE_PATTERN.matcher(ssb);
int spanCount=0;
CustomEmojiSpan lastSpan=null;
diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/utils/TransferSpeedTracker.java b/mastodon/src/main/java/org/joinmastodon/android/ui/utils/TransferSpeedTracker.java
new file mode 100644
index 000000000..a3525aa7e
--- /dev/null
+++ b/mastodon/src/main/java/org/joinmastodon/android/ui/utils/TransferSpeedTracker.java
@@ -0,0 +1,51 @@
+package org.joinmastodon.android.ui.utils;
+
+import android.os.SystemClock;
+
+public class TransferSpeedTracker{
+ private final double SMOOTHING_FACTOR=0.05;
+
+ private long lastKnownPos;
+ private long lastKnownPosTime;
+ private double lastSpeed;
+ private double averageSpeed;
+ private long totalBytes;
+
+ public void addSample(long position){
+ if(lastKnownPosTime==0){
+ lastKnownPosTime=SystemClock.uptimeMillis();
+ lastKnownPos=position;
+ }else{
+ long time=SystemClock.uptimeMillis();
+ lastSpeed=(position-lastKnownPos)/((double)(time-lastKnownPosTime)/1000.0);
+ lastKnownPos=position;
+ lastKnownPosTime=time;
+ }
+ }
+
+ public double getLastSpeed(){
+ return lastSpeed;
+ }
+
+ public double getAverageSpeed(){
+ return averageSpeed;
+ }
+
+ public long updateAndGetETA(){ // must be called at a constant interval
+ if(averageSpeed==0.0)
+ averageSpeed=lastSpeed;
+ else
+ averageSpeed=SMOOTHING_FACTOR*lastSpeed+(1.0-SMOOTHING_FACTOR)*averageSpeed;
+ return Math.round((totalBytes-lastKnownPos)/averageSpeed);
+ }
+
+ public void setTotalBytes(long totalBytes){
+ this.totalBytes=totalBytes;
+ }
+
+ public void reset(){
+ lastKnownPos=lastKnownPosTime=0;
+ lastSpeed=averageSpeed=0.0;
+ totalBytes=0;
+ }
+}
diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/utils/UiUtils.java b/mastodon/src/main/java/org/joinmastodon/android/ui/utils/UiUtils.java
index dd78df513..4e5c4eaa7 100644
--- a/mastodon/src/main/java/org/joinmastodon/android/ui/utils/UiUtils.java
+++ b/mastodon/src/main/java/org/joinmastodon/android/ui/utils/UiUtils.java
@@ -214,6 +214,14 @@ public class UiUtils{
mainHandler.post(runnable);
}
+ public static void runOnUiThread(Runnable runnable, long delay){
+ mainHandler.postDelayed(runnable, delay);
+ }
+
+ public static void removeCallbacks(Runnable runnable){
+ mainHandler.removeCallbacks(runnable);
+ }
+
/** Linear interpolation between {@code startValue} and {@code endValue} by {@code fraction}. */
public static int lerp(int startValue, int endValue, float fraction) {
return startValue + Math.round(fraction * (endValue - startValue));
@@ -231,6 +239,18 @@ public class UiUtils{
return uri.getLastPathSegment();
}
+ public static String formatFileSize(Context context, long size, boolean atLeastKB){
+ if(size<1024 && !atLeastKB){
+ return context.getString(R.string.file_size_bytes, size);
+ }else if(size<1024*1024){
+ return context.getString(R.string.file_size_kb, size/1024.0);
+ }else if(size<1024*1024*1024){
+ return context.getString(R.string.file_size_mb, size/(1024.0*1024.0));
+ }else{
+ return context.getString(R.string.file_size_gb, size/(1024.0*1024.0*1024.0));
+ }
+ }
+
public static MediaType getFileMediaType(File file){
String name=file.getName();
return MediaType.parse(MimeTypeMap.getSingleton().getMimeTypeFromExtension(name.substring(name.lastIndexOf('.')+1)));
diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/views/ComposeEditText.java b/mastodon/src/main/java/org/joinmastodon/android/ui/views/ComposeEditText.java
index f3ff13b04..f15bee3b4 100644
--- a/mastodon/src/main/java/org/joinmastodon/android/ui/views/ComposeEditText.java
+++ b/mastodon/src/main/java/org/joinmastodon/android/ui/views/ComposeEditText.java
@@ -54,12 +54,13 @@ public class ComposeEditText extends EditText{
// Support receiving images from keyboards
@Override
public InputConnection onCreateInputConnection(EditorInfo outAttrs){
+ final var ic = super.onCreateInputConnection(outAttrs);
if(Build.VERSION.SDK_INT>=Build.VERSION_CODES.N_MR1){
outAttrs.contentMimeTypes=selectionListener.onGetAllowedMediaMimeTypes();
- inputConnectionWrapper.setTarget(super.onCreateInputConnection(outAttrs));
+ inputConnectionWrapper.setTarget(ic);
return inputConnectionWrapper;
}
- return super.onCreateInputConnection(outAttrs);
+ return ic;
}
// Support pasting images
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-v24/ic_launcher_monochrome.xml b/mastodon/src/main/res/drawable-v24/ic_launcher_monochrome.xml
new file mode 100644
index 000000000..d79529585
--- /dev/null
+++ b/mastodon/src/main/res/drawable-v24/ic_launcher_monochrome.xml
@@ -0,0 +1,10 @@
+
+
+
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/bg_upload_progress.xml b/mastodon/src/main/res/drawable/bg_upload_progress.xml
new file mode 100644
index 000000000..d1fa18cd1
--- /dev/null
+++ b/mastodon/src/main/res/drawable/bg_upload_progress.xml
@@ -0,0 +1,8 @@
+
+
+ -
+
+
+
+
+
\ No newline at end of file
diff --git a/mastodon/src/main/res/drawable/ic_fluent_arrow_clockwise_24_filled.xml b/mastodon/src/main/res/drawable/ic_fluent_arrow_clockwise_24_filled.xml
new file mode 100644
index 000000000..734179fef
--- /dev/null
+++ b/mastodon/src/main/res/drawable/ic_fluent_arrow_clockwise_24_filled.xml
@@ -0,0 +1,3 @@
+
+
+
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_fluent_dismiss_24_filled.xml b/mastodon/src/main/res/drawable/ic_fluent_dismiss_24_filled.xml
new file mode 100644
index 000000000..0a846da3d
--- /dev/null
+++ b/mastodon/src/main/res/drawable/ic_fluent_dismiss_24_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/drawable/upload_progress.xml b/mastodon/src/main/res/drawable/upload_progress.xml
index 522c06639..2e6a105f4 100644
--- a/mastodon/src/main/res/drawable/upload_progress.xml
+++ b/mastodon/src/main/res/drawable/upload_progress.xml
@@ -6,7 +6,7 @@
android:shape="ring"
android:thickness="4dp"
android:useLevel="true">
-
+
\ No newline at end of file
diff --git a/mastodon/src/main/res/layout/compose_media_thumb.xml b/mastodon/src/main/res/layout/compose_media_thumb.xml
index bbcfea4fe..a3432b1fc 100644
--- a/mastodon/src/main/res/layout/compose_media_thumb.xml
+++ b/mastodon/src/main/res/layout/compose_media_thumb.xml
@@ -65,30 +65,68 @@
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#cc000000"
- android:backgroundTint="?colorWindowBackground"
android:padding="8dp"
android:clipToPadding="false"
tools:visibility="visible"
android:visibility="gone">
-
-
-
+ android:layout_gravity="center_vertical">
+
+
+
+
+
+
+
+
+
+
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/mipmap-anydpi-v26/ic_launcher.xml b/mastodon/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
index 4ae7d1237..31cfbe12f 100644
--- a/mastodon/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
+++ b/mastodon/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
@@ -2,4 +2,9 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/mastodon/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/mastodon/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
index 4ae7d1237..31cfbe12f 100644
--- a/mastodon/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
+++ b/mastodon/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
@@ -2,4 +2,9 @@
+
+
+
+
+
\ 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 592b19fff..a9d179a83 100644
--- a/mastodon/src/main/res/values/strings.xml
+++ b/mastodon/src/main/res/values/strings.xml
@@ -223,8 +223,6 @@
Add image description…
Image description
Retry upload
- Image failed to upload
- Video failed to upload
Edit image
Save
Add alt text
@@ -388,4 +386,20 @@
Post edited
Edit
Discard changes?
+ Upload failed
+ %d bytes
+ %.2f KB
+ %.2f MB
+ %.2f GB
+ %1$s of %2$s
+ %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