Compare commits

...

22 Commits

Author SHA1 Message Date
sk
10a405ef13 change self updater api url 2022-11-01 21:38:30 +01:00
sk
a4cb05080a Merge branch 'upstream' into fork 2022-11-01 21:26:44 +01:00
Gregory K
4f8f698911 Merge pull request #275 from davidmhewitt/fix-duplicate-emoji-crash
Fix crash on duplicate custom emojis
2022-11-01 21:51:24 +03:00
David Hewitt
244f2ed911 Fix crash on duplicate custom emojis
Fixes #274

Add a merge function to `Collectors.toMap` to discard any duplicate custom emojis that may be returned if a user uses the same custom emoji in both their name and profile.
2022-11-01 14:47:25 +00:00
Gregory K
0ee494bcfc Merge pull request #273 from davidmhewitt/load-post-visibility-preference
Load post privacy preference
2022-11-01 00:30:01 +03:00
David Hewitt
eea00b0d53 Load post privacy preference
This queries the user's post visibility preference when opening the composer, and sets it on the composer.

In the case of composing a reply, the user's preference is only respected if it is "more private" than the privacy of the post being replied to, as this appears to be the behaviour in the web interface (and is what I'd expect)
2022-10-31 20:51:28 +00:00
Gregory K
e8fa82d0de Merge pull request #269 from davidmhewitt/fix-image-keyboard
Fix receiving images from keyboards
2022-10-31 09:26:29 +03:00
Grishka
e381de812c Add self-updater for github builds 2022-10-31 09:26:17 +03:00
David Hewitt
8ff3ecb4d4 Fix receiving images from keyboards
The call to `super.onCreateInputConnection` was overwriting the mimes in the `outAttrs`, so we can call that first and then modify the mimes.

This fixes the ability to insert GIFs with the default GBoard GIF menu for me.
2022-10-30 14:56:47 +00:00
Grishka
1fa8a9e858 Always show domain for own account 2022-10-26 18:28:57 +03:00
Grishka
212e8893b9 Fix editing 2022-10-26 03:01:39 +03:00
Grishka
367057421b Declare and request notifications permission (should fix #262) 2022-10-26 02:46:23 +03:00
Grishka
01970ab69b Compose media attachment redesign 2022-10-04 07:35:31 +03:00
Grishka
3aa252f681 Fix editing 2022-10-01 01:12:31 +03:00
Grishka
18633291e6 Add monochrome icon 2022-09-29 01:23:00 +03:00
sk
8aeda56fc8 merge upstream changes 2022-09-08 14:56:43 +02:00
Grishka
f531a90b41 Better editing 2022-09-02 11:21:28 +03:00
Grishka
ff52c37868 Editing 2022-09-02 05:47:20 +03:00
Grishka
8fb2b454dd Post edit history + extended footer redesign 2022-09-02 02:00:25 +03:00
Grishka
265b2ad32c Fix #218 2022-08-29 21:21:08 +03:00
Grishka
ba3219d9fc Fix #209, fix #198 2022-08-29 00:57:09 +03:00
Grishka
b44e3424e3 Fix #249 2022-08-29 00:12:15 +03:00
62 changed files with 2178 additions and 357 deletions

View File

@@ -4,13 +4,13 @@ plugins {
}
android {
compileSdk 31
compileSdk 33
defaultConfig {
applicationId "org.joinmastodon.android.sk"
minSdk 23
targetSdk 31
versionCode 20
versionName '1.1.3+fork.20'
targetSdk 33
versionCode 23
versionName "1.1.3+fork.23"
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,13 @@ android {
appcenterPublicBeta{
setRoot "src/appcenter"
}
githubRelease{
setRoot "src/github"
}
}
lintOptions{
checkReleaseBuilds false
abortOnError false
}
}

View File

@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="org.joinmastodon.android">
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES"/>
<application>
<!-- <receiver android:name=".updater.GithubSelfUpdaterImpl$InstallerStatusReceiver" android:exported="false"/>-->
<!-- <receiver android:name=".updater.GithubSelfUpdaterImpl$AfterUpdateRestartReceiver" android:exported="true" android:enabled="false">-->
<!-- <intent-filter>-->
<!-- <action android:name="android.intent.action.MY_PACKAGE_REPLACED"/>-->
<!-- </intent-filter>-->
<!-- </receiver>-->
<provider
android:authorities="${applicationId}.self_update_provider"
android:name=".updater.SelfUpdateContentProvider"
android:grantUriPermissions="true"
android:exported="false"/>
</application>
</manifest>

View File

@@ -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/sk22/mastodon-android-fork/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<Build.VERSION_CODES.P){
context.startActivity(restartIntent);
}else{
// Bypass activity starting restrictions by starting it from a notification
NotificationManager nm=context.getSystemService(NotificationManager.class);
NotificationChannel chan=new NotificationChannel("selfUpdateRestart", context.getString(R.string.update_installed), NotificationManager.IMPORTANCE_HIGH);
nm.createNotificationChannel(chan);
Notification n=new Notification.Builder(context, "selfUpdateRestart")
.setContentTitle(context.getString(R.string.update_installed))
.setContentIntent(PendingIntent.getActivity(context, 1, restartIntent, PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE))
.setFullScreenIntent(PendingIntent.getActivity(context, 1, restartIntent, PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE), true)
.setSmallIcon(R.drawable.ic_ntf_logo)
.build();
nm.notify(1, n);
}
}
}
}*/
}

View File

@@ -0,0 +1,62 @@
package org.joinmastodon.android.updater;
import android.content.ContentProvider;
import android.content.ContentValues;
import android.database.Cursor;
import android.net.Uri;
import android.os.ParcelFileDescriptor;
import java.io.FileNotFoundException;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
public class SelfUpdateContentProvider extends ContentProvider{
@Override
public boolean onCreate(){
return true;
}
@Nullable
@Override
public Cursor query(@NonNull Uri uri, @Nullable String[] projection, @Nullable String selection, @Nullable String[] selectionArgs, @Nullable String sortOrder){
return null;
}
@Nullable
@Override
public String getType(@NonNull Uri uri){
if(isCorrectUri(uri))
return "application/vnd.android.package-archive";
return null;
}
@Nullable
@Override
public Uri insert(@NonNull Uri uri, @Nullable ContentValues values){
return null;
}
@Override
public int delete(@NonNull Uri uri, @Nullable String selection, @Nullable String[] selectionArgs){
return 0;
}
@Override
public int update(@NonNull Uri uri, @Nullable ContentValues values, @Nullable String selection, @Nullable String[] selectionArgs){
return 0;
}
@Nullable
@Override
public ParcelFileDescriptor openFile(@NonNull Uri uri, @NonNull String mode) throws FileNotFoundException{
if(isCorrectUri(uri)){
return ParcelFileDescriptor.open(((GithubSelfUpdaterImpl)GithubSelfUpdater.getInstance()).getUpdateApkFile(), ParcelFileDescriptor.MODE_READ_ONLY);
}
throw new FileNotFoundException();
}
private boolean isCorrectUri(Uri uri){
return "/update.apk".equals(uri.getPath());
}
}

View File

@@ -7,6 +7,7 @@
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="28"/>
<uses-permission android:name="${applicationId}.permission.C2D_MESSAGE"/>
<uses-permission android:name="com.google.android.c2dm.permission.RECEIVE"/>
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<permission android:name="${applicationId}.permission.C2D_MESSAGE" android:protectionLevel="signature"/>

View File

@@ -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);
}
}
}

View File

@@ -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();
}

View File

@@ -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<String, List<MastodonDetailedErrorResponse.FieldError>> 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;
}
}

View File

@@ -82,7 +82,7 @@ public abstract class MastodonAPIRequest<T> extends APIRequest<T>{
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<T> extends APIRequest<T>{
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){

View File

@@ -7,8 +7,8 @@ import java.util.Map;
public class MastodonDetailedErrorResponse extends MastodonErrorResponse{
public Map<String, List<FieldError>> 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{

View File

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

View File

@@ -27,6 +27,7 @@ public class GetAccountStatuses extends MastodonAPIRequest<List<Status>>{
addQueryParameter("exclude_replies", "true");
addQueryParameter("exclude_reblogs", "true");
}
case OWN_POSTS_AND_REPLIES -> addQueryParameter("exclude_reblogs", "true");
}
}
@@ -35,6 +36,7 @@ public class GetAccountStatuses extends MastodonAPIRequest<List<Status>>{
INCLUDE_REPLIES,
PINNED,
MEDIA,
NO_REBLOGS
NO_REBLOGS,
OWN_POSTS_AND_REPLIES
}
}

View File

@@ -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<Preferences> {
public GetPreferences(){
super(HttpMethod.GET, "/preferences", Preferences.class);
}
}

View File

@@ -0,0 +1,11 @@
package org.joinmastodon.android.api.requests.statuses;
import org.joinmastodon.android.api.MastodonAPIRequest;
import org.joinmastodon.android.model.Status;
public class EditStatus extends MastodonAPIRequest<Status>{
public EditStatus(CreateStatus.Request req, String id){
super(HttpMethod.PUT, "/statuses/"+id, Status.class);
setRequestBody(req);
}
}

View File

@@ -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<Attachment>{
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);
}
}

View File

@@ -0,0 +1,33 @@
package org.joinmastodon.android.api.requests.statuses;
import com.google.gson.reflect.TypeToken;
import org.joinmastodon.android.api.MastodonAPIRequest;
import org.joinmastodon.android.model.Status;
import org.joinmastodon.android.model.StatusPrivacy;
import java.io.IOException;
import java.util.Collections;
import java.util.List;
import okhttp3.Response;
public class GetStatusEditHistory extends MastodonAPIRequest<List<Status>>{
public GetStatusEditHistory(String id){
super(HttpMethod.GET, "/statuses/"+id+"/history", new TypeToken<>(){});
}
@Override
public void validateAndPostprocessResponse(List<Status> respObj, Response httpResponse) throws IOException{
int i=0;
for(Status s:respObj){
s.uri="";
s.id="fakeID"+i;
s.visibility=StatusPrivacy.PUBLIC;
s.mentions=Collections.emptyList();
s.tags=Collections.emptyList();
i++;
}
super.validateAndPostprocessResponse(respObj, httpResponse);
}
}

View File

@@ -0,0 +1,18 @@
package org.joinmastodon.android.api.requests.statuses;
import org.joinmastodon.android.api.AllFieldsAreRequired;
import org.joinmastodon.android.api.MastodonAPIRequest;
import org.joinmastodon.android.model.BaseModel;
public class GetStatusSourceText extends MastodonAPIRequest<GetStatusSourceText.Response>{
public GetStatusSourceText(String id){
super(HttpMethod.GET, "/statuses/"+id+"/source", Response.class);
}
@AllFieldsAreRequired
public static class Response extends BaseModel{
public String id;
public String text;
public String spoilerText;
}
}

View File

@@ -17,6 +17,7 @@ import java.io.IOException;
import okhttp3.MultipartBody;
import okhttp3.RequestBody;
import okhttp3.Response;
public class UploadAttachment extends MastodonAPIRequest<Attachment>{
private Uri uri;
@@ -40,6 +41,18 @@ public class UploadAttachment extends MastodonAPIRequest<Attachment>{
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()

View File

@@ -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;
}
}

View File

@@ -0,0 +1,11 @@
package org.joinmastodon.android.events;
import org.joinmastodon.android.model.Status;
public class StatusUpdatedEvent{
public Status status;
public StatusUpdatedEvent(Status status){
this.status=status;
}
}

View File

@@ -457,7 +457,7 @@ public abstract class BaseStatusListFragment<T extends DisplayItemsParent> exten
status.spoilerRevealed=true;
TextStatusDisplayItem.Holder text=findHolderOfType(itemID, TextStatusDisplayItem.Holder.class);
if(text!=null)
adapter.notifyItemChanged(text.getAbsoluteAdapterPosition()+getMainAdapterOffset());
adapter.notifyItemChanged(text.getAbsoluteAdapterPosition()-getMainAdapterOffset());
HeaderStatusDisplayItem.Holder header=findHolderOfType(itemID, HeaderStatusDisplayItem.Holder.class);
if(header!=null)
header.rebind();
@@ -579,6 +579,10 @@ public abstract class BaseStatusListFragment<T extends DisplayItemsParent> exten
return true;
}
public ArrayList<StatusDisplayItem> getDisplayItems(){
return displayItems;
}
@Override
public void onApplyWindowInsets(WindowInsets insets){
if(Build.VERSION.SDK_INT>=29 && insets.getTappableElementInsets().bottom==0 && wantsOverlaySystemNavigation()){

View File

@@ -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,19 +57,27 @@ 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;
import org.joinmastodon.android.events.StatusCountersUpdatedEvent;
import org.joinmastodon.android.events.StatusCreatedEvent;
import org.joinmastodon.android.events.StatusUpdatedEvent;
import org.joinmastodon.android.model.Account;
import org.joinmastodon.android.model.Attachment;
import org.joinmastodon.android.model.Emoji;
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;
@@ -76,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;
@@ -84,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;
@@ -105,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);
@@ -152,8 +170,7 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
private ArrayList<DraftPollOption> pollOptions=new ArrayList<>();
private ArrayList<DraftMediaAttachment> queuedAttachments=new ArrayList<>(), failedAttachments=new ArrayList<>(), attachments=new ArrayList<>(), allAttachments=new ArrayList<>();
private DraftMediaAttachment uploadingAttachment;
private ArrayList<DraftMediaAttachment> attachments=new ArrayList<>();
private List<EmojiCategory> customEmojis;
private CustomEmojiPopupKeyboard emojiKeyboard;
@@ -175,6 +192,12 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
private Instance instance;
private boolean attachmentsErrorShowing;
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();
draft.serverAttachment=att;
@@ -194,6 +217,9 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
instanceDomain=session.domain;
customEmojis=AccountSessionManager.getInstance().getCustomEmojis(instanceDomain);
instance=AccountSessionManager.getInstance().getInstanceInfo(instanceDomain);
if(getArguments().containsKey("editStatus")){
editingStatus=Parcels.unwrap(getArguments().getParcelable("editStatus"));
}
if(instance==null){
Nav.finish(this);
return;
@@ -209,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
@@ -239,6 +260,7 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
@Override
public View onCreateContentView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState){
creatingView=true;
emojiKeyboard=new CustomEmojiPopupKeyboard(getActivity(), customEmojis, instanceDomain);
emojiKeyboard.setListener(this::onCustomEmojiClick);
@@ -317,6 +339,16 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
}
updatePollOptionHints();
pollDurationView.setText(getString(R.string.compose_poll_duration, pollDurationStr));
}else if(savedInstanceState==null && editingStatus!=null && editingStatus.poll!=null){
pollBtn.setSelected(true);
mediaBtn.setEnabled(false);
pollWrap.setVisibility(View.VISIBLE);
for(Poll.Option eopt:editingStatus.poll.options){
DraftPollOption opt=createDraftPollOption();
opt.edit.setText(eopt.title);
}
updatePollOptionHints();
pollDurationView.setText(getString(R.string.compose_poll_duration, pollDurationStr));
}else{
pollDurationView.setText(getString(R.string.compose_poll_duration, pollDurationStr=getResources().getQuantityString(R.plurals.x_days, 1, 1)));
}
@@ -329,6 +361,10 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
if((savedInstanceState!=null && savedInstanceState.getBoolean("hasSpoiler", false)) || hasSpoiler){
spoilerEdit.setVisibility(View.VISIBLE);
spoilerBtn.setSelected(true);
}else if(editingStatus!=null && !TextUtils.isEmpty(editingStatus.spoilerText)){
spoilerEdit.setVisibility(View.VISIBLE);
spoilerEdit.setText(getArguments().getString("sourceSpoiler", editingStatus.spoilerText));
spoilerBtn.setSelected(true);
}
ArrayList<Parcelable> serializedAttachments=(savedInstanceState!=null ? savedInstanceState : getArguments())
@@ -340,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));
}
}
@@ -354,6 +390,8 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
autocompleteView.setVisibility(View.GONE);
mainEditTextWrap.addView(autocompleteView, new FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, V.dp(178), Gravity.TOP));
creatingView=false;
return view;
}
@@ -455,9 +493,10 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
@Override
public void afterTextChanged(Editable s){
updateCharCounter(s);
updateCharCounter();
}
});
spoilerEdit.addTextChangedListener(new SimpleTextWatcher(e->updateCharCounter()));
if(replyTo!=null){
replyText.setText(getString(R.string.in_reply_to, replyTo.account.displayName));
ArrayList<String> mentions=new ArrayList<>();
@@ -474,46 +513,65 @@ 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());
// TODO: setting for preserving cw always / only when replying to own posts
// && AccountSessionManager.getInstance().isSelf(accountID, replyTo.account)
ignoreSelectionChanges=false;
if(!TextUtils.isEmpty(replyTo.spoilerText)){
insertSpoiler(replyTo.spoilerText);
hasSpoiler=true;
spoilerEdit.setVisibility(View.VISIBLE);
spoilerEdit.setText(replyTo.spoilerText);
spoilerBtn.setSelected(true);
}
}
}else{
replyText.setVisibility(View.GONE);
}
if(savedInstanceState==null){
String prefilledText=getArguments().getString("prefilledText");
if(!TextUtils.isEmpty(prefilledText)){
mainEditText.setText(prefilledText);
if(editingStatus!=null){
initialText=getArguments().getString("sourceText", "");
mainEditText.setText(initialText);
ignoreSelectionChanges=true;
mainEditText.setSelection(mainEditText.length());
initialText=prefilledText;
}
String spoilerText=getArguments().getString("spoilerText");
if(!TextUtils.isEmpty(spoilerText)) insertSpoiler(spoilerText);
ArrayList<Uri> mediaUris=getArguments().getParcelableArrayList("mediaAttachments");
if(mediaUris!=null && !mediaUris.isEmpty()){
for(Uri uri:mediaUris){
addMediaAttachment(uri, null);
ignoreSelectionChanges=false;
if(!editingStatus.mediaAttachments.isEmpty()){
attachmentsView.setVisibility(View.VISIBLE);
for(Attachment att:editingStatus.mediaAttachments){
DraftMediaAttachment da=new DraftMediaAttachment();
da.serverAttachment=att;
da.description=att.description;
da.uri=Uri.parse(att.previewUrl);
attachmentsView.addView(createMediaAttachmentView(da));
attachments.add(da);
}
pollBtn.setEnabled(false);
}
}else{
String prefilledText=getArguments().getString("prefilledText");
if(!TextUtils.isEmpty(prefilledText)){
mainEditText.setText(prefilledText);
ignoreSelectionChanges=true;
mainEditText.setSelection(mainEditText.length());
ignoreSelectionChanges=false;
initialText=prefilledText;
}
ArrayList<Uri> mediaUris=getArguments().getParcelableArrayList("mediaAttachments");
if(mediaUris!=null && !mediaUris.isEmpty()){
for(Uri uri:mediaUris){
addMediaAttachment(uri, null);
}
}
}
}
}
private void insertSpoiler(String text) {
hasSpoiler=true;
if (text!=null) spoilerEdit.setText(text);
spoilerEdit.setVisibility(View.VISIBLE);
spoilerBtn.setSelected(true);
if(editingStatus!=null){
updateCharCounter();
}
}
@Override
public void onCreateOptionsMenu(Menu menu, MenuInflater inflater){
publishButton=new Button(getActivity());
publishButton.setText(R.string.publish);
publishButton.setText(editingStatus==null ? R.string.publish : R.string.save);
publishButton.setOnClickListener(this::onPublishClick);
LinearLayout wrap=new LinearLayout(getActivity());
wrap.setOrientation(LinearLayout.HORIZONTAL);
@@ -536,7 +594,7 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
wrap.addView(publishButton, new LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT));
wrap.setPadding(V.dp(16), V.dp(4), V.dp(16), V.dp(8));
wrap.setClipToPadding(false);
MenuItem item=menu.add(R.string.publish);
MenuItem item=menu.add(editingStatus==null ? R.string.publish : R.string.save);
item.setActionView(wrap);
item.setShowAsAction(MenuItem.SHOW_AS_ACTION_ALWAYS);
updatePublishButtonState();
@@ -554,7 +612,9 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
}
@SuppressLint("NewApi")
private void updateCharCounter(CharSequence text){
private void updateCharCounter(){
CharSequence text=mainEditText.getText();
String countableText=TwitterTextEmojiRegex.VALID_EMOJI_PATTERN.matcher(
MENTION_PATTERN.matcher(
URL_PATTERN.matcher(text).replaceAll("$2xxxxxxxxxxxxxxxxxxxxxxx")
@@ -566,6 +626,9 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
charCount++;
}
if(hasSpoiler){
charCount+=spoilerEdit.length();
}
charCounter.setText(String.valueOf(charLimit-charCount));
trimmedCharCount=text.toString().trim().length();
updatePublishButtonState();
@@ -578,11 +641,14 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
if(opt.edit.length()>0)
nonEmptyPollOptionsCount++;
}
if(publishButton!=null){
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){
@@ -638,41 +704,59 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
sendProgress.setVisibility(View.VISIBLE);
sendError.setVisibility(View.GONE);
new CreateStatus(req, uuid)
.setCallback(new Callback<>(){
@Override
public void onSuccess(Status result){
wm.removeView(sendingOverlay);
sendingOverlay=null;
E.post(new StatusCreatedEvent(result));
if(replyTo!=null){
replyTo.repliesCount++;
E.post(new StatusCountersUpdatedEvent(replyTo));
}
Nav.finish(ComposeFragment.this);
Callback<Status> resCallback=new Callback<>(){
@Override
public void onSuccess(Status result){
wm.removeView(sendingOverlay);
sendingOverlay=null;
if(editingStatus==null){
E.post(new StatusCreatedEvent(result));
if(replyTo!=null){
replyTo.repliesCount++;
E.post(new StatusCountersUpdatedEvent(replyTo));
}
}else{
E.post(new StatusUpdatedEvent(result));
}
Nav.finish(ComposeFragment.this);
}
@Override
public void onError(ErrorResponse error){
wm.removeView(sendingOverlay);
sendingOverlay=null;
sendProgress.setVisibility(View.GONE);
sendError.setVisibility(View.VISIBLE);
publishButton.setEnabled(true);
error.showToast(getActivity());
}
})
.exec(accountID);
@Override
public void onError(ErrorResponse error){
wm.removeView(sendingOverlay);
sendingOverlay=null;
sendProgress.setVisibility(View.GONE);
sendError.setVisibility(View.VISIBLE);
publishButton.setEnabled(true);
error.showToast(getActivity());
}
};
if(editingStatus!=null){
new EditStatus(req, editingStatus.id)
.setCallback(resCallback)
.exec(accountID);
}else{
new CreateStatus(req, uuid)
.setCallback(resCallback)
.exec(accountID);
}
}
private boolean hasDraft(){
if(getArguments().getBoolean("hasDraft", false)) return true;
if(editingStatus!=null){
if(!mainEditText.getText().toString().equals(initialText))
return true;
List<String> existingMediaIDs=editingStatus.mediaAttachments.stream().map(a->a.id).collect(Collectors.toList());
if(!existingMediaIDs.equals(attachments.stream().map(a->a.serverAttachment.id).collect(Collectors.toList())))
return true;
return pollChanged;
}
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
@@ -716,7 +800,7 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
private void confirmDiscardDraftAndFinish(){
new M3AlertDialogBuilder(getActivity())
.setTitle(R.string.discard_draft)
.setTitle(editingStatus==null ? R.string.discard_draft : R.string.discard_changes)
.setPositiveButton(R.string.discard, (dialog, which)->Nav.finish(this))
.setNegativeButton(R.string.cancel, null)
.show();
@@ -773,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;
}
@@ -782,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)
@@ -812,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);
@@ -838,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;
@@ -855,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);
@@ -923,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)
@@ -996,7 +1187,11 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
pollOptionsView.startDragging(option.view);
return true;
});
option.edit.addTextChangedListener(new SimpleTextWatcher(e->updatePublishButtonState()));
option.edit.addTextChangedListener(new SimpleTextWatcher(e->{
if(!creatingView)
pollChanged=true;
updatePublishButtonState();
}));
option.edit.setFilters(new InputFilter[]{new InputFilter.LengthFilter(instance.configuration!=null && instance.configuration.polls!=null && instance.configuration.polls.maxCharactersPerOption>0 ? instance.configuration.polls.maxCharactersPerOption : 50)});
pollOptionsView.addView(option.view);
@@ -1016,6 +1211,7 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
private void onSwapPollOptions(int oldIndex, int newIndex){
pollOptions.add(newIndex, pollOptions.remove(oldIndex));
updatePollOptionHints();
pollChanged=true;
}
private void showPollDurationMenu(){
@@ -1039,6 +1235,7 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
default -> throw new IllegalStateException("Unexpected value: "+item.getItemId());
};
pollDurationView.setText(getString(R.string.compose_poll_duration, pollDurationStr=item.getTitle().toString()));
pollChanged=true;
return true;
});
menu.show();
@@ -1055,11 +1252,12 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
spoilerEdit.setText("");
spoilerBtn.setSelected(false);
mainEditText.requestFocus();
updateCharCounter();
}
}
private int getMediaAttachmentsCount(){
return allAttachments.size();
return attachments.size();
}
private void onVisibilityClick(View v){
@@ -1095,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;
@@ -1109,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){
@@ -1179,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);
@@ -1199,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{

View File

@@ -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;

View File

@@ -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);
}
}

View File

@@ -1,10 +1,6 @@
package org.joinmastodon.android.fragments;
import android.app.Activity;
import android.graphics.Canvas;
import android.graphics.Paint;
import android.graphics.Rect;
import android.graphics.RectF;
import android.os.Bundle;
import android.view.View;
@@ -16,15 +12,12 @@ import org.joinmastodon.android.api.session.AccountSessionManager;
import org.joinmastodon.android.events.PollUpdatedEvent;
import org.joinmastodon.android.model.Notification;
import org.joinmastodon.android.model.PaginatedResponse;
import org.joinmastodon.android.model.Poll;
import org.joinmastodon.android.model.Status;
import org.joinmastodon.android.ui.PhotoLayoutHelper;
import org.joinmastodon.android.ui.displayitems.AccountCardStatusDisplayItem;
import org.joinmastodon.android.ui.displayitems.HeaderStatusDisplayItem;
import org.joinmastodon.android.ui.displayitems.ImageStatusDisplayItem;
import org.joinmastodon.android.ui.displayitems.LinkCardStatusDisplayItem;
import org.joinmastodon.android.ui.displayitems.StatusDisplayItem;
import org.joinmastodon.android.ui.utils.UiUtils;
import org.joinmastodon.android.ui.utils.InsetStatusItemDecoration;
import org.parceler.Parcels;
import java.util.ArrayList;
@@ -33,7 +26,6 @@ import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.RecyclerView;
import me.grishka.appkit.Nav;
import me.grishka.appkit.api.SimpleCallback;
@@ -160,91 +152,7 @@ public class NotificationsListFragment extends BaseStatusListFragment<Notificati
@Override
public void onViewCreated(View view, Bundle savedInstanceState){
super.onViewCreated(view, savedInstanceState);
list.addItemDecoration(new RecyclerView.ItemDecoration(){
private Paint paint=new Paint(Paint.ANTI_ALIAS_FLAG);
private int bgColor=UiUtils.getThemeColor(getActivity(), android.R.attr.colorBackground);
private int borderColor=UiUtils.getThemeColor(getActivity(), R.attr.colorPollVoted);
private RectF rect=new RectF();
@Override
public void onDraw(@NonNull Canvas c, @NonNull RecyclerView parent, @NonNull RecyclerView.State state){
int pos=0;
for(int i=0;i<parent.getChildCount();i++){
View child=parent.getChildAt(i);
RecyclerView.ViewHolder holder=parent.getChildViewHolder(child);
pos=holder.getAbsoluteAdapterPosition();
boolean inset=(holder instanceof StatusDisplayItem.Holder<?> sdi) && sdi.getItem().inset;
if(inset){
if(rect.isEmpty()){
rect.set(child.getX(), i==0 && pos>0 && displayItems.get(pos-1).inset ? V.dp(-10) : child.getY(), child.getX()+child.getWidth(), child.getY()+child.getHeight());
}else{
rect.bottom=Math.max(rect.bottom, child.getY()+child.getHeight());
}
}else if(!rect.isEmpty()){
drawInsetBackground(c);
rect.setEmpty();
}
}
if(!rect.isEmpty()){
if(pos<displayItems.size()-1 && displayItems.get(pos+1).inset){
rect.bottom=parent.getHeight()+V.dp(10);
}
drawInsetBackground(c);
rect.setEmpty();
}
}
private void drawInsetBackground(Canvas c){
paint.setStyle(Paint.Style.FILL);
paint.setColor(bgColor);
rect.left=V.dp(12);
rect.right=list.getWidth()-V.dp(12);
rect.inset(V.dp(4), V.dp(4));
c.drawRoundRect(rect, V.dp(4), V.dp(4), paint);
paint.setStyle(Paint.Style.STROKE);
paint.setStrokeWidth(V.dp(1));
paint.setColor(borderColor);
rect.inset(paint.getStrokeWidth()/2f, paint.getStrokeWidth()/2f);
c.drawRoundRect(rect, V.dp(4), V.dp(4), paint);
}
@Override
public void getItemOffsets(@NonNull Rect outRect, @NonNull View view, @NonNull RecyclerView parent, @NonNull RecyclerView.State state){
RecyclerView.ViewHolder holder=parent.getChildViewHolder(view);
if(holder instanceof StatusDisplayItem.Holder<?> sdi){
boolean inset=sdi.getItem().inset;
int pos=holder.getAbsoluteAdapterPosition();
if(inset){
boolean topSiblingInset=pos>0 && displayItems.get(pos-1).inset;
boolean bottomSiblingInset=pos<displayItems.size()-1 && displayItems.get(pos+1).inset;
int pad;
if(holder instanceof ImageStatusDisplayItem.Holder || holder instanceof LinkCardStatusDisplayItem.Holder)
pad=V.dp(16);
else
pad=V.dp(12);
boolean insetLeft=true, insetRight=true;
if(holder instanceof ImageStatusDisplayItem.Holder<?> img){
PhotoLayoutHelper.TiledLayoutResult layout=img.getItem().tiledLayout;
PhotoLayoutHelper.TiledLayoutResult.Tile tile=img.getItem().thisTile;
// only inset those items that are on the edges of the layout
insetLeft=tile.startCol==0;
insetRight=tile.startCol+tile.colSpan==layout.columnSizes.length;
// inset all items in the bottom row
if(tile.startRow+tile.rowSpan==layout.rowSizes.length)
bottomSiblingInset=false;
}
if(insetLeft)
outRect.left=pad;
if(insetRight)
outRect.right=pad;
if(!topSiblingInset)
outRect.top=pad;
if(!bottomSiblingInset)
outRect.bottom=pad;
}
}
}
});
list.addItemDecoration(new InsetStatusItemDecoration(this));
}
private Notification getNotificationByID(String id){
@@ -268,4 +176,5 @@ public class NotificationsListFragment extends BaseStatusListFragment<Notificati
}
}
}
}

View File

@@ -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)){

View File

@@ -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<BindableViewHolder<Item>>{
@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<UpdateItem>{
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);
}
}
}

View File

@@ -0,0 +1,157 @@
package org.joinmastodon.android.fragments;
import android.app.Activity;
import android.os.Bundle;
import android.view.View;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.requests.statuses.GetStatusEditHistory;
import org.joinmastodon.android.model.Status;
import org.joinmastodon.android.ui.displayitems.ReblogOrReplyLineStatusDisplayItem;
import org.joinmastodon.android.ui.displayitems.StatusDisplayItem;
import org.joinmastodon.android.ui.utils.InsetStatusItemDecoration;
import org.joinmastodon.android.ui.utils.UiUtils;
import java.time.ZoneId;
import java.util.Collections;
import java.util.Comparator;
import java.util.EnumSet;
import java.util.List;
import java.util.Objects;
import java.util.stream.Collectors;
import me.grishka.appkit.api.SimpleCallback;
public class StatusEditHistoryFragment extends StatusListFragment{
private String id;
@Override
public void onCreate(Bundle savedInstanceState){
super.onCreate(savedInstanceState);
id=getArguments().getString("id");
loadData();
}
@Override
public void onAttach(Activity activity){
super.onAttach(activity);
setTitle(R.string.edit_history);
}
@Override
protected void doLoadData(int offset, int count){
new GetStatusEditHistory(id)
.setCallback(new SimpleCallback<>(this){
@Override
public void onSuccess(List<Status> result){
Collections.sort(result, Comparator.comparing((Status s)->s.createdAt).reversed());
onDataLoaded(result, false);
}
})
.exec(accountID);
}
@Override
protected List<StatusDisplayItem> buildDisplayItems(Status s){
List<StatusDisplayItem> items=StatusDisplayItem.buildItems(this, s, accountID, s, knownAccounts, true, false);
int idx=data.indexOf(s);
if(idx>=0){
String date=UiUtils.DATE_TIME_FORMATTER.format(s.createdAt.atZone(ZoneId.systemDefault()));
String action="";
if(idx==data.size()-1){
action=getString(R.string.edit_original_post);
}else{
enum StatusEditChangeType{
TEXT_CHANGED,
SPOILER_ADDED,
SPOILER_REMOVED,
SPOILER_CHANGED,
POLL_ADDED,
POLL_REMOVED,
POLL_CHANGED,
MEDIA_ADDED,
MEDIA_REMOVED,
MEDIA_REORDERED,
MARKED_SENSITIVE,
MARKED_NOT_SENSITIVE
}
EnumSet<StatusEditChangeType> changes=EnumSet.noneOf(StatusEditChangeType.class);
Status prev=data.get(idx+1);
if(!Objects.equals(s.content, prev.content)){
changes.add(StatusEditChangeType.TEXT_CHANGED);
}
if(!Objects.equals(s.spoilerText, prev.spoilerText)){
if(s.spoilerText==null){
changes.add(StatusEditChangeType.SPOILER_REMOVED);
}else if(prev.spoilerText==null){
changes.add(StatusEditChangeType.SPOILER_ADDED);
}else{
changes.add(StatusEditChangeType.SPOILER_CHANGED);
}
}
if(s.poll!=null || prev.poll!=null){
if(s.poll==null){
changes.add(StatusEditChangeType.POLL_REMOVED);
}else if(prev.poll==null){
changes.add(StatusEditChangeType.POLL_ADDED);
}else if(!s.poll.id.equals(prev.poll.id)){
changes.add(StatusEditChangeType.POLL_CHANGED);
}
}
List<String> newAttachmentIDs=s.mediaAttachments.stream().map(att->att.id).collect(Collectors.toList());
List<String> prevAttachmentIDs=s.mediaAttachments.stream().map(att->att.id).collect(Collectors.toList());
boolean addedOrRemoved=false;
if(!newAttachmentIDs.containsAll(prevAttachmentIDs)){
changes.add(StatusEditChangeType.MEDIA_REMOVED);
addedOrRemoved=true;
}
if(!prevAttachmentIDs.containsAll(newAttachmentIDs)){
changes.add(StatusEditChangeType.MEDIA_ADDED);
addedOrRemoved=true;
}
if(!addedOrRemoved && !newAttachmentIDs.equals(prevAttachmentIDs)){
changes.add(StatusEditChangeType.MEDIA_REORDERED);
}
if(s.sensitive && !prev.sensitive){
changes.add(StatusEditChangeType.MARKED_SENSITIVE);
}else if(prev.sensitive && !s.sensitive){
changes.add(StatusEditChangeType.MARKED_NOT_SENSITIVE);
}
if(changes.size()==1){
action=getString(switch(changes.iterator().next()){
case TEXT_CHANGED -> R.string.edit_text_edited;
case SPOILER_ADDED -> R.string.edit_spoiler_added;
case SPOILER_REMOVED -> R.string.edit_spoiler_removed;
case SPOILER_CHANGED -> R.string.edit_spoiler_edited;
case POLL_ADDED -> R.string.edit_poll_added;
case POLL_REMOVED -> R.string.edit_poll_removed;
case POLL_CHANGED -> R.string.edit_poll_edited;
case MEDIA_ADDED -> R.string.edit_media_added;
case MEDIA_REMOVED -> R.string.edit_media_removed;
case MEDIA_REORDERED -> R.string.edit_media_reordered;
case MARKED_SENSITIVE -> R.string.edit_marked_sensitive;
case MARKED_NOT_SENSITIVE -> R.string.edit_marked_not_sensitive;
});
}else{
action=getString(R.string.edit_multiple_changed);
}
}
items.add(0, new ReblogOrReplyLineStatusDisplayItem(s.id, this, action+" · "+date, Collections.emptyList(), 0));
}
return items;
}
@Override
public void onViewCreated(View view, Bundle savedInstanceState){
super.onViewCreated(view, savedInstanceState);
list.addItemDecoration(new InsetStatusItemDecoration(this));
}
@Override
public boolean isItemEnabled(String id){
return false;
}
}

View File

@@ -9,6 +9,7 @@ import org.joinmastodon.android.events.PollUpdatedEvent;
import org.joinmastodon.android.events.StatusCountersUpdatedEvent;
import org.joinmastodon.android.events.StatusCreatedEvent;
import org.joinmastodon.android.events.StatusDeletedEvent;
import org.joinmastodon.android.events.StatusUpdatedEvent;
import org.joinmastodon.android.events.StatusUnpinnedEvent;
import org.joinmastodon.android.model.Status;
import org.joinmastodon.android.ui.displayitems.ExtendedFooterStatusDisplayItem;
@@ -16,6 +17,7 @@ import org.joinmastodon.android.ui.displayitems.FooterStatusDisplayItem;
import org.joinmastodon.android.ui.displayitems.StatusDisplayItem;
import org.parceler.Parcels;
import java.util.ArrayList;
import java.util.List;
import androidx.recyclerview.widget.RecyclerView;
@@ -61,8 +63,6 @@ public abstract class StatusListFragment extends BaseStatusListFragment<Status>{
protected void onStatusCreated(StatusCreatedEvent ev){}
protected void onStatusUnpinned(StatusUnpinnedEvent ev){}
protected Status getContentStatusByID(String id){
Status s=getStatusByID(id);
return s==null ? null : s.getContentStatus();
@@ -138,11 +138,6 @@ public abstract class StatusListFragment extends BaseStatusListFragment<Status>{
StatusListFragment.this.onStatusCreated(ev);
}
@Subscribe
public void onStatusUnpinned(StatusUnpinnedEvent ev){
StatusListFragment.this.onStatusUnpinned(ev);
}
@Subscribe
public void onPollUpdated(PollUpdatedEvent ev){
if(!ev.accountID.equals(accountID))

View File

@@ -43,7 +43,7 @@ public class ThreadFragment extends StatusListFragment{
@Override
protected List<StatusDisplayItem> buildDisplayItems(Status s){
List<StatusDisplayItem> items=super.buildDisplayItems(s);
if(s==mainStatus){
if(s.id.equals(mainStatus.id)){
for(StatusDisplayItem item:items){
if(item instanceof TextStatusDisplayItem text)
text.textSelectable=true;

View File

@@ -85,7 +85,7 @@ public class ReportAddPostsChoiceFragment extends StatusListFragment{
@Override
protected void doLoadData(int offset, int count){
currentRequest=new GetAccountStatuses(reportAccount.id, offset>0 ? getMaxID() : null, null, count, GetAccountStatuses.Filter.NO_REBLOGS)
currentRequest=new GetAccountStatuses(reportAccount.id, offset>0 ? getMaxID() : null, null, count, GetAccountStatuses.Filter.OWN_POSTS_AND_REPLIES)
.setCallback(new SimpleCallback<>(this){
@Override
public void onSuccess(List<Status> result){
@@ -102,6 +102,7 @@ public class ReportAddPostsChoiceFragment extends StatusListFragment{
else
selectedIDs.add(id);
list.invalidate();
btn.setEnabled(!selectedIDs.isEmpty());
}
@Override

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -37,6 +37,7 @@ public class Status extends BaseModel implements DisplayItemsParent{
public int reblogsCount;
public int favouritesCount;
public int repliesCount;
public Instant editedAt;
public String url;
public String inReplyToId;

View File

@@ -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;
}
}

View File

@@ -1,5 +1,6 @@
package org.joinmastodon.android.ui.displayitems;
import android.annotation.SuppressLint;
import android.content.Context;
import android.os.Bundle;
import android.text.SpannableStringBuilder;
@@ -12,6 +13,7 @@ import android.widget.TextView;
import org.joinmastodon.android.R;
import org.joinmastodon.android.fragments.BaseStatusListFragment;
import org.joinmastodon.android.fragments.StatusEditHistoryFragment;
import org.joinmastodon.android.fragments.account_list.StatusFavoritesListFragment;
import org.joinmastodon.android.fragments.account_list.StatusReblogsListFragment;
import org.joinmastodon.android.fragments.account_list.StatusRelatedAccountListFragment;
@@ -43,39 +45,35 @@ public class ExtendedFooterStatusDisplayItem extends StatusDisplayItem{
}
public static class Holder extends StatusDisplayItem.Holder<ExtendedFooterStatusDisplayItem>{
private final TextView reblogs, favorites, time;
private final View buttonsView;
private final TextView time, favoritesCount, reblogsCount, lastEditTime;
private final View favorites, reblogs, editHistory;
public Holder(Context context, ViewGroup parent){
super(context, R.layout.display_item_extended_footer, parent);
reblogs=findViewById(R.id.reblogs);
favorites=findViewById(R.id.favorites);
editHistory=findViewById(R.id.edit_history);
time=findViewById(R.id.timestamp);
buttonsView=findViewById(R.id.button_bar);
favoritesCount=findViewById(R.id.favorites_count);
reblogsCount=findViewById(R.id.reblogs_count);
lastEditTime=findViewById(R.id.last_edited);
reblogs.setOnClickListener(v->startAccountListFragment(StatusReblogsListFragment.class));
favorites.setOnClickListener(v->startAccountListFragment(StatusFavoritesListFragment.class));
editHistory.setOnClickListener(v->startEditHistoryFragment());
}
@SuppressLint("DefaultLocale")
@Override
public void onBind(ExtendedFooterStatusDisplayItem item){
Status s=item.status;
if(s.favouritesCount>0){
favorites.setVisibility(View.VISIBLE);
favorites.setText(getFormattedPlural(R.plurals.x_favorites, s.favouritesCount));
favoritesCount.setText(String.format("%,d", s.favouritesCount));
reblogsCount.setText(String.format("%,d", s.reblogsCount));
if(s.editedAt!=null){
editHistory.setVisibility(View.VISIBLE);
lastEditTime.setText(item.parentFragment.getString(R.string.last_edit_at_x, UiUtils.formatRelativeTimestampAsMinutesAgo(itemView.getContext(), s.editedAt)));
}else{
favorites.setVisibility(View.GONE);
}
if(s.reblogsCount>0){
reblogs.setVisibility(View.VISIBLE);
reblogs.setText(getFormattedPlural(R.plurals.x_reblogs, s.reblogsCount));
}else{
reblogs.setVisibility(View.GONE);
}
if(s.favouritesCount==0 && s.reblogsCount==0){
buttonsView.setVisibility(View.GONE);
}else{
buttonsView.setVisibility(View.VISIBLE);
editHistory.setVisibility(View.GONE);
}
String timeStr=TIME_FORMATTER.format(item.status.createdAt.atZone(ZoneId.systemDefault()));
if(item.status.application!=null && !TextUtils.isEmpty(item.status.application.name)){
@@ -108,5 +106,12 @@ public class ExtendedFooterStatusDisplayItem extends StatusDisplayItem{
args.putParcelable("status", Parcels.wrap(item.status));
Nav.go(item.parentFragment.getActivity(), cls, args);
}
private void startEditHistoryFragment(){
Bundle args=new Bundle();
args.putString("account", item.parentFragment.getAccountID());
args.putString("id", item.status.id);
Nav.go(item.parentFragment.getActivity(), StatusEditHistoryFragment.class, args);
}
}
}

View File

@@ -21,8 +21,10 @@ import android.widget.Toast;
import org.joinmastodon.android.GlobalUserPreferences;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.requests.accounts.GetAccountRelationships;
import org.joinmastodon.android.api.requests.statuses.GetStatusSourceText;
import org.joinmastodon.android.api.session.AccountSessionManager;
import org.joinmastodon.android.fragments.BaseStatusListFragment;
import org.joinmastodon.android.fragments.ComposeFragment;
import org.joinmastodon.android.fragments.ProfileFragment;
import org.joinmastodon.android.fragments.report.ReportReasonChoiceFragment;
import org.joinmastodon.android.model.Account;
@@ -135,7 +137,31 @@ public class HeaderStatusDisplayItem extends StatusDisplayItem{
optionsMenu.setOnMenuItemClickListener(menuItem->{
Account account=item.user;
int id=menuItem.getItemId();
if(id==R.id.delete){
if(id==R.id.edit){
final Bundle args=new Bundle();
args.putString("account", item.parentFragment.getAccountID());
args.putParcelable("editStatus", Parcels.wrap(item.status));
if(TextUtils.isEmpty(item.status.content) && TextUtils.isEmpty(item.status.spoilerText)){
Nav.go(item.parentFragment.getActivity(), ComposeFragment.class, args);
}else{
new GetStatusSourceText(item.status.id)
.setCallback(new Callback<>(){
@Override
public void onSuccess(GetStatusSourceText.Response result){
args.putString("sourceText", result.text);
args.putString("sourceSpoiler", result.spoilerText);
Nav.go(item.parentFragment.getActivity(), ComposeFragment.class, args);
}
@Override
public void onError(ErrorResponse error){
error.showToast(item.parentFragment.getActivity());
}
})
.wrapProgress(item.parentFragment.getActivity(), R.string.loading, true)
.exec(item.parentFragment.getAccountID());
}
}else if(id==R.id.delete){
UiUtils.confirmDeletePost(item.parentFragment.getActivity(), item.parentFragment.getAccountID(), item.status, s->{});
}else if(id==R.id.delete_and_redraft) {
UiUtils.confirmDeleteAndRedraftPost(item.parentFragment.getActivity(), item.parentFragment.getAccountID(), item.status, s->{});
@@ -179,7 +205,10 @@ public class HeaderStatusDisplayItem extends StatusDisplayItem{
public void onBind(HeaderStatusDisplayItem item){
name.setText(item.parsedName);
username.setText('@'+item.user.acct);
timestamp.setText(UiUtils.formatRelativeTimestamp(itemView.getContext(), item.createdAt));
if(item.status==null || item.status.editedAt==null)
timestamp.setText(UiUtils.formatRelativeTimestamp(itemView.getContext(), item.createdAt));
else
timestamp.setText(item.parentFragment.getString(R.string.edited_timestamp, UiUtils.formatRelativeTimestamp(itemView.getContext(), item.status.editedAt)));
visibility.setVisibility(item.hasVisibilityToggle && !item.inset ? View.VISIBLE : View.GONE);
if(item.hasVisibilityToggle){
visibility.setImageResource(item.status.spoilerRevealed ? R.drawable.ic_visibility_off : R.drawable.ic_visibility);
@@ -253,6 +282,7 @@ public class HeaderStatusDisplayItem extends StatusDisplayItem{
Account account=item.user;
Menu menu=optionsMenu.getMenu();
boolean isOwnPost=AccountSessionManager.getInstance().isSelf(item.parentFragment.getAccountID(), account);
menu.findItem(R.id.edit).setVisible(item.status!=null && isOwnPost);
menu.findItem(R.id.delete).setVisible(item.status!=null && isOwnPost);
menu.findItem(R.id.delete_and_redraft).setVisible(item.status!=null && isOwnPost);
menu.findItem(R.id.pin).setVisible(item.status!=null && isOwnPost && !item.status.pinned);

View File

@@ -8,6 +8,7 @@ import android.view.ViewGroup;
import org.joinmastodon.android.R;
import org.joinmastodon.android.fragments.BaseStatusListFragment;
import org.joinmastodon.android.fragments.ThreadFragment;
import org.joinmastodon.android.model.Account;
import org.joinmastodon.android.model.Attachment;
import org.joinmastodon.android.model.DisplayItemsParent;
@@ -114,7 +115,7 @@ public abstract class StatusDisplayItem{
}
if(addFooter){
items.add(new FooterStatusDisplayItem(parentID, fragment, statusForContent, accountID));
if(status.hasGapAfter)
if(status.hasGapAfter && !(fragment instanceof ThreadFragment))
items.add(new GapStatusDisplayItem(parentID, fragment));
}
int i=1;

View File

@@ -129,7 +129,16 @@ public class HtmlParser{
}
public static void parseCustomEmoji(SpannableStringBuilder ssb, List<Emoji> emojis){
Map<String, Emoji> emojiByCode=emojis.stream().collect(Collectors.toMap(e->e.shortcode, Function.identity()));
Map<String, Emoji> 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;

View File

@@ -0,0 +1,116 @@
package org.joinmastodon.android.ui.utils;
import android.graphics.Canvas;
import android.graphics.Paint;
import android.graphics.Rect;
import android.graphics.RectF;
import android.view.View;
import org.joinmastodon.android.R;
import org.joinmastodon.android.fragments.BaseStatusListFragment;
import org.joinmastodon.android.fragments.NotificationsListFragment;
import org.joinmastodon.android.ui.PhotoLayoutHelper;
import org.joinmastodon.android.ui.displayitems.ImageStatusDisplayItem;
import org.joinmastodon.android.ui.displayitems.LinkCardStatusDisplayItem;
import org.joinmastodon.android.ui.displayitems.StatusDisplayItem;
import java.util.List;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.RecyclerView;
import me.grishka.appkit.utils.V;
public class InsetStatusItemDecoration extends RecyclerView.ItemDecoration{
private final BaseStatusListFragment<?> listFragment;
private Paint paint=new Paint(Paint.ANTI_ALIAS_FLAG);
private int bgColor;
private int borderColor;
private RectF rect=new RectF();
public InsetStatusItemDecoration(BaseStatusListFragment<?> listFragment){
this.listFragment=listFragment;
bgColor=UiUtils.getThemeColor(listFragment.getActivity(), android.R.attr.colorBackground);
borderColor=UiUtils.getThemeColor(listFragment.getActivity(), R.attr.colorPollVoted);
}
@Override
public void onDraw(@NonNull Canvas c, @NonNull RecyclerView parent, @NonNull RecyclerView.State state){
List<StatusDisplayItem> displayItems=listFragment.getDisplayItems();
int pos=0;
for(int i=0; i<parent.getChildCount(); i++){
View child=parent.getChildAt(i);
RecyclerView.ViewHolder holder=parent.getChildViewHolder(child);
pos=holder.getAbsoluteAdapterPosition();
boolean inset=(holder instanceof StatusDisplayItem.Holder<?> sdi) && sdi.getItem().inset;
if(inset){
if(rect.isEmpty()){
rect.set(child.getX(), i==0 && pos>0 && displayItems.get(pos-1).inset ? V.dp(-10) : child.getY(), child.getX()+child.getWidth(), child.getY()+child.getHeight());
}else{
rect.bottom=Math.max(rect.bottom, child.getY()+child.getHeight());
}
}else if(!rect.isEmpty()){
drawInsetBackground(parent, c);
rect.setEmpty();
}
}
if(!rect.isEmpty()){
if(pos<displayItems.size()-1 && displayItems.get(pos+1).inset){
rect.bottom=parent.getHeight()+V.dp(10);
}
drawInsetBackground(parent, c);
rect.setEmpty();
}
}
private void drawInsetBackground(RecyclerView list, Canvas c){
paint.setStyle(Paint.Style.FILL);
paint.setColor(bgColor);
rect.left=V.dp(12);
rect.right=list.getWidth()-V.dp(12);
rect.inset(V.dp(4), V.dp(4));
c.drawRoundRect(rect, V.dp(4), V.dp(4), paint);
paint.setStyle(Paint.Style.STROKE);
paint.setStrokeWidth(V.dp(1));
paint.setColor(borderColor);
rect.inset(paint.getStrokeWidth()/2f, paint.getStrokeWidth()/2f);
c.drawRoundRect(rect, V.dp(4), V.dp(4), paint);
}
@Override
public void getItemOffsets(@NonNull Rect outRect, @NonNull View view, @NonNull RecyclerView parent, @NonNull RecyclerView.State state){
List<StatusDisplayItem> displayItems=listFragment.getDisplayItems();
RecyclerView.ViewHolder holder=parent.getChildViewHolder(view);
if(holder instanceof StatusDisplayItem.Holder<?> sdi){
boolean inset=sdi.getItem().inset;
int pos=holder.getAbsoluteAdapterPosition();
if(inset){
boolean topSiblingInset=pos>0 && displayItems.get(pos-1).inset;
boolean bottomSiblingInset=pos<displayItems.size()-1 && displayItems.get(pos+1).inset;
int pad;
if(holder instanceof ImageStatusDisplayItem.Holder || holder instanceof LinkCardStatusDisplayItem.Holder)
pad=V.dp(16);
else
pad=V.dp(12);
boolean insetLeft=true, insetRight=true;
if(holder instanceof ImageStatusDisplayItem.Holder<?> img){
PhotoLayoutHelper.TiledLayoutResult layout=img.getItem().tiledLayout;
PhotoLayoutHelper.TiledLayoutResult.Tile tile=img.getItem().thisTile;
// only inset those items that are on the edges of the layout
insetLeft=tile.startCol==0;
insetRight=tile.startCol+tile.colSpan==layout.columnSizes.length;
// inset all items in the bottom row
if(tile.startRow+tile.rowSpan==layout.rowSizes.length)
bottomSiblingInset=false;
}
if(insetLeft)
outRect.left=pad;
if(insetRight)
outRect.right=pad;
if(!topSiblingInset)
outRect.top=pad;
if(!bottomSiblingInset)
outRect.bottom=pad;
}
}
}
}

View File

@@ -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;
}
}

View File

@@ -69,6 +69,7 @@ import java.time.Instant;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.time.format.FormatStyle;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.Arrays;
@@ -98,6 +99,7 @@ import okhttp3.MediaType;
public class UiUtils{
private static Handler mainHandler=new Handler(Looper.getMainLooper());
private static final DateTimeFormatter DATE_FORMATTER_SHORT_WITH_YEAR=DateTimeFormatter.ofPattern("d MMM uuuu"), DATE_FORMATTER_SHORT=DateTimeFormatter.ofPattern("d MMM");
public static final DateTimeFormatter DATE_TIME_FORMATTER=DateTimeFormatter.ofLocalizedDateTime(FormatStyle.LONG, FormatStyle.SHORT);
private UiUtils(){}
@@ -142,6 +144,23 @@ public class UiUtils{
}
}
public static String formatRelativeTimestampAsMinutesAgo(Context context, Instant instant){
long t=instant.toEpochMilli();
long now=System.currentTimeMillis();
long diff=now-t;
if(diff<1000L){
return context.getString(R.string.time_just_now);
}else if(diff<60_000L){
int secs=(int)(diff/1000L);
return context.getResources().getQuantityString(R.plurals.x_seconds_ago, secs, secs);
}else if(diff<3600_000L){
int mins=(int)(diff/60_000L);
return context.getResources().getQuantityString(R.plurals.x_minutes_ago, mins, mins);
}else{
return DATE_TIME_FORMATTER.format(instant.atZone(ZoneId.systemDefault()));
}
}
public static String formatTimeLeft(Context context, Instant instant){
long t=instant.toEpochMilli();
long now=System.currentTimeMillis();
@@ -195,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));
@@ -212,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)));

View File

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

View File

@@ -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;
}
}

View File

@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="32dp"
android:height="32dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M16.7096,17.7682C19.4819,17.4391 21.8955,15.7408 22.199,14.1888C22.6769,11.7442 22.6376,8.2231 22.6376,8.2231C22.6376,3.4504 19.4929,2.0516 19.4929,2.0516C17.9073,1.3274 15.1846,1.023 12.356,1H12.2865C9.4579,1.023 6.7369,1.3274 5.1513,2.0516C5.1513,2.0516 2.0066,3.4504 2.0066,8.2231C2.0066,8.5125 2.0051,8.8169 2.0035,9.1339C1.9991,10.0135 1.9943,10.9896 2.0199,12.0083C2.1341,16.6755 2.8805,21.2752 7.2202,22.4175C9.2213,22.944 10.9392,23.0542 12.323,22.9785C14.832,22.8403 16.2406,22.0883 16.2406,22.0883L16.1577,20.2779C16.1577,20.2779 14.3648,20.8402 12.3511,20.7717C10.356,20.7037 8.2496,20.5577 7.9269,18.1221C7.8972,17.9082 7.8823,17.6794 7.8823,17.4391C7.8823,17.4391 9.8408,17.9152 12.323,18.0283C13.8407,18.0974 15.2639,17.9399 16.7096,17.7682ZM18.8747,14.3719V8.5932C18.8747,7.4121 18.5723,6.4736 17.9648,5.7792C17.3382,5.0849 16.518,4.729 15.4997,4.729C14.3212,4.729 13.4291,5.1792 12.8392,6.0799L12.2657,7.0359L11.692,6.0799C11.1023,5.1792 10.21,4.729 9.0316,4.729C8.0134,4.729 7.193,5.0849 6.5664,5.7792C5.9589,6.4736 5.6565,7.4121 5.6565,8.5932V14.3719H7.959V8.763C7.959,7.5805 8.4594,6.9806 9.4602,6.9806C10.5665,6.9806 11.1211,7.6925 11.1211,9.1001V12.1701H13.4101V9.1001C13.4101,7.6925 13.9647,6.9806 15.071,6.9806C16.0718,6.9806 16.5722,7.5805 16.5722,8.763V14.3719H18.8747Z"
android:fillColor="#fff"
android:fillType="evenOdd"/>
</vector>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="?colorPollVoted"/>
<corners android:radius="4dp"/>
</shape>

View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<ripple xmlns:android="http://schemas.android.com/apk/res/android" android:color="?android:colorForeground">
<item android:id="@android:id/mask">
<shape android:shape="oval">
<solid android:color="#18000000"/>
</shape>
</item>
<item>
<shape android:shape="oval">
<solid android:color="?android:colorBackground"/>
</shape>
</item>
</ripple>

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<ripple xmlns:android="http://schemas.android.com/apk/res/android" android:color="@color/highlight_over_dark">
<item>
<shape android:shape="oval">
<solid android:color="@color/gray_600"/>
</shape>
</item>
</ripple>

View File

@@ -0,0 +1,3 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:pathData="M12 4.75c-4.004 0-7.25 3.246-7.25 7.25s3.246 7.25 7.25 7.25 7.25-3.246 7.25-7.25c0-0.286-0.017-0.567-0.049-0.844C19.133 10.568 19.56 10 20.151 10c0.515 0 0.968 0.358 1.03 0.87 0.046 0.37 0.069 0.747 0.069 1.13 0 5.109-4.141 9.25-9.25 9.25S2.75 17.109 2.75 12 6.891 2.75 12 2.75c2.173 0 4.171 0.75 5.75 2.004V4.25c0-0.552 0.448-1 1-1s1 0.448 1 1v2.698L19.784 7H19.75v0.25c0 0.552-0.448 1-1 1h-3c-0.552 0-1-0.448-1-1s0.448-1 1-1h0.666c-1.222-0.94-2.754-1.5-4.416-1.5z" android:fillColor="@color/fluent_default_icon_tint"/>
</vector>

View File

@@ -0,0 +1,3 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="16dp" android:height="16dp" android:viewportWidth="16" android:viewportHeight="16">
<path android:pathData="M2.397 2.554L2.47 2.47c0.266-0.267 0.683-0.29 0.976-0.073L3.53 2.47 8 6.939l4.47-4.47c0.293-0.292 0.767-0.292 1.06 0 0.293 0.294 0.293 0.768 0 1.061L9.061 8l4.47 4.47c0.266 0.266 0.29 0.683 0.072 0.976L13.53 13.53c-0.266 0.267-0.683 0.29-0.976 0.073L12.47 13.53 8 9.061l-4.47 4.47c-0.293 0.292-0.767 0.292-1.06 0-0.293-0.294-0.293-0.768 0-1.061L6.939 8l-4.47-4.47C2.204 3.264 2.18 2.847 2.398 2.554L2.47 2.47 2.397 2.554z" android:fillColor="@color/fluent_default_icon_tint"/>
</vector>

View File

@@ -0,0 +1,3 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:pathData="M4.21 4.387l0.083-0.094c0.36-0.36 0.928-0.388 1.32-0.083l0.094 0.083L12 10.585l6.293-6.292c0.39-0.39 1.024-0.39 1.414 0 0.39 0.39 0.39 1.024 0 1.414L13.415 12l6.292 6.293c0.36 0.36 0.388 0.928 0.083 1.32l-0.083 0.094c-0.36 0.36-0.928 0.388-1.32 0.083l-0.094-0.083L12 13.415l-6.293 6.292c-0.39 0.39-1.024 0.39-1.414 0-0.39-0.39-0.39-1.024 0-1.414L10.585 12 4.293 5.707c-0.36-0.36-0.388-0.928-0.083-1.32l0.083-0.094L4.21 4.387z" android:fillColor="@color/fluent_default_icon_tint"/>
</vector>

View File

@@ -0,0 +1,3 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:pathData="M21.03 2.97c1.398 1.397 1.398 3.663 0 5.06L9.062 20c-0.277 0.277-0.621 0.477-0.999 0.58l-5.116 1.395c-0.56 0.153-1.073-0.361-0.92-0.921l1.395-5.116c0.103-0.377 0.302-0.722 0.58-0.999L15.97 2.97c1.397-1.398 3.663-1.398 5.06 0zM15 6.06L5.062 16c-0.092 0.092-0.159 0.207-0.193 0.333l-1.05 3.85 3.85-1.05C7.793 19.096 7.908 19.03 8 18.938L17.94 9 15 6.06zm2.03-2.03L16.06 5 19 7.94l0.97-0.97c0.811-0.812 0.811-2.128 0-2.94-0.812-0.811-2.128-0.811-2.94 0z" android:fillColor="@color/fluent_default_icon_tint"/>
</vector>

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@drawable/ic_fluent_settings_24_regular" android:left="2dp" android:right="2dp" android:top="2dp" android:bottom="2dp"/>
<item android:width="14dp" android:height="14dp" android:gravity="top|right">
<shape android:shape="oval">
<stroke android:color="?android:colorPrimary" android:width="2dp"/>
<solid android:color="@color/primary_600"/>
</shape>
</item>
</layer-list>

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:id="@android:id/progress">
<shape
android:innerRadius="16dp"
android:shape="ring"
android:thickness="4dp"
android:useLevel="true">
<solid android:color="?colorSearchHint"/>
</shape>
</item>
</layer-list>

View File

@@ -6,7 +6,7 @@
android:shape="ring"
android:thickness="4dp"
android:useLevel="true">
<solid android:color="?android:colorAccent"/>
<solid android:color="@color/gray_100"/>
</shape>
</item>
</layer-list>

View File

@@ -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">
<ProgressBar
android:id="@+id/progress"
android:layout_width="44dp"
android:layout_height="44dp"
android:layout_gravity="center"
android:progressDrawable="@drawable/upload_progress"
android:max="1000"
android:padding="0dp"
android:indeterminateOnly="false"
android:indeterminate="false"/>
<Button
android:id="@+id/retry_upload"
android:layout_width="wrap_content"
<RelativeLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="start|bottom"
style="?secondaryButtonStyle"
android:text="@string/retry_upload"/>
android:layout_gravity="center_vertical">
<ImageButton
android:id="@+id/retry_or_cancel_upload"
android:layout_width="44dp"
android:layout_height="44dp"
android:layout_centerHorizontal="true"
android:layout_alignParentTop="true"
android:src="@drawable/ic_fluent_dismiss_24_filled"
android:contentDescription="@string/cancel"
android:tint="@color/gray_100"
android:background="@drawable/bg_upload_progress"/>
<ProgressBar
android:id="@+id/progress"
android:layout_width="44dp"
android:layout_height="44dp"
android:layout_centerHorizontal="true"
android:layout_alignParentTop="true"
android:progressDrawable="@drawable/upload_progress"
android:max="1000"
android:padding="0dp"
android:indeterminateOnly="false"
android:indeterminate="false"/>
<TextView
android:id="@+id/state_title"
android:layout_width="match_parent"
android:layout_height="16dp"
android:layout_below="@id/retry_or_cancel_upload"
android:layout_marginTop="16dp"
android:textColor="@color/gray_200"
android:textSize="14dp"
android:gravity="center_horizontal"
android:singleLine="true"
android:ellipsize="end"
android:fontFamily="sans-serif-medium"
android:includeFontPadding="false"
tools:text="Upload failed"/>
<TextView
android:id="@+id/state_text"
android:layout_width="match_parent"
android:layout_height="32dp"
android:layout_below="@id/state_title"
android:includeFontPadding="false"
android:textColor="@color/gray_200"
android:gravity="center_horizontal|top"
android:lines="2"
android:maxLines="2"
android:ellipsize="end"
tools:text="Your device lost connection to the internet"/>
</RelativeLayout>
<ImageButton
android:id="@+id/remove_btn2"
@@ -98,6 +136,7 @@
android:layout_gravity="end|bottom"
android:background="?android:selectableItemBackgroundBorderless"
android:tint="#D92C2C"
android:contentDescription="@string/delete"
android:src="@drawable/ic_fluent_delete_20_regular"/>
</FrameLayout>

View File

@@ -2,49 +2,146 @@
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:orientation="vertical"
android:padding="8dp"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?colorBackgroundLightest">
android:layout_height="wrap_content">
<org.joinmastodon.android.ui.views.AutoOrientationLinearLayout
android:id="@+id/button_bar"
<RelativeLayout
android:id="@+id/reblogs"
android:layout_width="match_parent"
android:layout_height="wrap_content">
android:layout_height="64dp"
android:paddingLeft="16dp"
android:paddingRight="16dp"
android:paddingTop="12dp"
android:paddingBottom="12dp"
android:background="?android:selectableItemBackground">
<Button
android:id="@+id/reblogs"
android:layout_width="wrap_content"
<ImageView
android:id="@+id/icon"
android:layout_width="24dp"
android:layout_height="24dp"
android:layout_alignParentStart="true"
android:layout_centerVertical="true"
android:src="@drawable/ic_fluent_arrow_repeat_all_24_regular"
android:tint="?android:textColorSecondary"
android:importantForAccessibility="no"/>
<TextView
android:id="@+id/title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center"
android:padding="8dp"
android:textSize="14sp"
android:minHeight="36dp"
android:textColor="?android:textColorSecondary"
android:background="@drawable/bg_text_button"
android:fontFamily="sans-serif"
tools:text="4 reblogs"/>
android:layout_marginStart="32dp"
android:layout_toEndOf="@id/icon"
android:minHeight="22dp"
android:singleLine="true"
android:text="@string/post_info_reblogs"
android:textAppearance="@style/m3_body_large" />
<Button
android:id="@+id/favorites"
android:layout_width="wrap_content"
<TextView
android:id="@+id/reblogs_count"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center"
android:padding="8dp"
android:textSize="14sp"
android:minHeight="36dp"
android:layout_below="@id/title"
android:layout_alignStart="@id/title"
android:singleLine="true"
android:textAppearance="@style/m3_body_medium"
android:textColor="?android:textColorSecondary"
android:background="@drawable/bg_text_button"
android:fontFamily="sans-serif"
tools:text="12 favorites"/>
tools:text="123 456"/>
</org.joinmastodon.android.ui.views.AutoOrientationLinearLayout>
</RelativeLayout>
<RelativeLayout
android:id="@+id/favorites"
android:layout_width="match_parent"
android:layout_height="64dp"
android:paddingLeft="16dp"
android:paddingRight="16dp"
android:paddingTop="12dp"
android:paddingBottom="12dp"
android:background="?android:selectableItemBackground">
<ImageView
android:id="@+id/icon"
android:layout_width="24dp"
android:layout_height="24dp"
android:layout_alignParentStart="true"
android:layout_centerVertical="true"
android:src="@drawable/ic_fluent_star_24_regular"
android:tint="?android:textColorSecondary"
android:importantForAccessibility="no"/>
<TextView
android:id="@+id/title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="32dp"
android:layout_toEndOf="@id/icon"
android:minHeight="22dp"
android:singleLine="true"
android:text="@string/post_info_favorites"
android:textAppearance="@style/m3_body_large" />
<TextView
android:id="@+id/favorites_count"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_below="@id/title"
android:layout_alignStart="@id/title"
android:singleLine="true"
android:textAppearance="@style/m3_body_medium"
android:textColor="?android:textColorSecondary"
tools:text="123 456"/>
</RelativeLayout>
<RelativeLayout
android:id="@+id/edit_history"
android:layout_width="match_parent"
android:layout_height="64dp"
android:paddingLeft="16dp"
android:paddingRight="16dp"
android:paddingTop="12dp"
android:paddingBottom="12dp"
android:background="?android:selectableItemBackground">
<ImageView
android:id="@+id/icon"
android:layout_width="24dp"
android:layout_height="24dp"
android:layout_alignParentStart="true"
android:layout_centerVertical="true"
android:src="@drawable/ic_fluent_edit_24_regular"
android:tint="?android:textColorSecondary"
android:importantForAccessibility="no"/>
<TextView
android:id="@+id/title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="32dp"
android:layout_toEndOf="@id/icon"
android:minHeight="22dp"
android:singleLine="true"
android:text="@string/edit_history"
android:textAppearance="@style/m3_body_large" />
<TextView
android:id="@+id/last_edited"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_below="@id/title"
android:layout_alignStart="@id/title"
android:singleLine="true"
android:textAppearance="@style/m3_body_medium"
android:textColor="?android:textColorSecondary"
tools:text="123 456"/>
</RelativeLayout>
<TextView
android:id="@+id/timestamp"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="8dp"
android:layout_margin="16dp"
android:minHeight="20dp"
android:gravity="center_vertical"
android:textSize="14sp"

View File

@@ -0,0 +1,75 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="8dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:minHeight="64dp"
android:gravity="center_vertical"
android:paddingStart="16dp"
android:background="@drawable/bg_settings_update"
android:orientation="horizontal">
<TextView
android:id="@+id/text"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginEnd="4dp"
android:layout_marginTop="16dp"
android:layout_marginBottom="16dp"
android:textAppearance="@style/m3_body_medium"
tools:text="@string/update_available"/>
<FrameLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content">
<Button
android:id="@+id/button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_vertical|end"
android:background="?android:selectableItemBackground"
android:textColor="?colorAccentLight"
android:textAllCaps="true"
android:textSize="14dp"
android:paddingLeft="16dp"
android:paddingRight="16dp"
android:stateListAnimator="@null"
tools:text="@string/install_update"/>
<ImageButton
android:id="@+id/cancel_btn"
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_gravity="end|center_vertical"
android:layout_marginEnd="16dp"
android:background="@drawable/bg_update_download_progress"
android:tint="?colorSearchHint"
android:contentDescription="@string/cancel"
android:visibility="gone"
android:src="@drawable/ic_fluent_dismiss_16_filled"/>
<ProgressBar
android:id="@+id/progress"
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_gravity="end|center_vertical"
android:layout_marginEnd="16dp"
android:progressDrawable="@drawable/update_progress"
android:max="1000"
android:padding="0dp"
android:visibility="gone"
android:indeterminateOnly="false"
android:indeterminate="false"/>
</FrameLayout>
</LinearLayout>
</FrameLayout>

View File

@@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android">
<item android:id="@+id/edit" android:title="@string/edit"/>
<item android:id="@+id/delete" android:title="@string/delete"/>
<item android:id="@+id/delete_and_redraft" android:title="@string/delete_and_redraft"/>
<item android:id="@+id/pin" android:title="@string/pin_post"/>

View File

@@ -2,4 +2,9 @@
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@mipmap/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
<monochrome>
<layer-list>
<item android:drawable="@drawable/ic_launcher_monochrome" android:gravity="center"/>
</layer-list>
</monochrome>
</adaptive-icon>

View File

@@ -2,4 +2,9 @@
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@mipmap/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
<monochrome>
<layer-list>
<item android:drawable="@drawable/ic_launcher_monochrome" android:gravity="center"/>
</layer-list>
</monochrome>
</adaptive-icon>

View File

@@ -223,8 +223,6 @@
<string name="add_image_description">Add image description…</string>
<string name="image_description">Image description</string>
<string name="retry_upload">Retry upload</string>
<string name="image_upload_failed">Image failed to upload</string>
<string name="video_upload_failed">Video failed to upload</string>
<string name="edit_image">Edit image</string>
<string name="save">Save</string>
<string name="add_alt_text">Add alt text</string>
@@ -358,4 +356,50 @@
</plurals>
<string name="timestamp_via_app">%1$s via %2$s</string>
<string name="time_now">now</string>
<string name="post_info_reblogs">Reblogs</string>
<string name="post_info_favorites">Favorites</string>
<string name="edit_history">Edit history</string>
<string name="last_edit_at_x">Last edit %s</string>
<string name="time_just_now">just now</string>
<plurals name="x_seconds_ago">
<item quantity="one">%d second ago</item>
<item quantity="other">%d seconds ago</item>
</plurals>
<plurals name="x_minutes_ago">
<item quantity="one">%d minute ago</item>
<item quantity="other">%d minutes ago</item>
</plurals>
<string name="edited_timestamp">edited %s</string>
<string name="edit_original_post">Original post</string>
<string name="edit_text_edited">Text edited</string>
<string name="edit_spoiler_added">Content warning added</string>
<string name="edit_spoiler_edited">Content warning edited</string>
<string name="edit_spoiler_removed">Content warning removed</string>
<string name="edit_poll_added">Poll added</string>
<string name="edit_poll_edited">Poll edited</string>
<string name="edit_poll_removed">Poll removed</string>
<string name="edit_media_added">Media added</string>
<string name="edit_media_removed">Media removed</string>
<string name="edit_media_reordered">Media reordered</string>
<string name="edit_marked_sensitive">Marked sensitive</string>
<string name="edit_marked_not_sensitive">Marked not sensitive</string>
<string name="edit_multiple_changed">Post edited</string>
<string name="edit">Edit</string>
<string name="discard_changes">Discard changes?</string>
<string name="upload_failed">Upload failed</string>
<string name="file_size_bytes">%d bytes</string>
<string name="file_size_kb">%.2f KB</string>
<string name="file_size_mb">%.2f MB</string>
<string name="file_size_gb">%.2f GB</string>
<string name="file_upload_progress">%1$s of %2$s</string>
<string name="file_upload_time_remaining">%s remaining</string>
<string name="upload_error_connection_lost">Your device lost connection to the internet</string>
<string name="upload_processing">Processing…</string>
<!-- %s is version like 1.2.3 -->
<string name="update_available">Mastodon for Android %s is ready to download.</string>
<!-- %s is version like 1.2.3 -->
<string name="update_ready">Mastodon for Android %s is downloaded and ready to install.</string>
<!-- %s is file size -->
<string name="download_update">Download (%s)</string>
<string name="install_update">Install</string>
</resources>