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"> - - -