Merge branch 'master' into feature/pin-posts

This commit is contained in:
sk
2022-07-22 11:57:02 +02:00
91 changed files with 1639 additions and 409 deletions

View File

@@ -9,6 +9,7 @@ import android.util.Log;
import org.joinmastodon.android.api.ObjectValidationException;
import org.joinmastodon.android.api.session.AccountSession;
import org.joinmastodon.android.api.session.AccountSessionManager;
import org.joinmastodon.android.fragments.ComposeFragment;
import org.joinmastodon.android.fragments.HomeFragment;
import org.joinmastodon.android.fragments.ProfileFragment;
import org.joinmastodon.android.fragments.SplashFragment;
@@ -56,6 +57,8 @@ public class MainActivity extends FragmentStackActivity{
if(intent.getBooleanExtra("fromNotification", false) && intent.hasExtra("notification")){
Notification notification=Parcels.unwrap(intent.getParcelableExtra("notification"));
showFragmentForNotification(notification, session.getID());
}else if(intent.getBooleanExtra("compose", false)){
showCompose();
}
}
}
@@ -91,6 +94,8 @@ public class MainActivity extends FragmentStackActivity{
fragment.setArguments(args);
showFragmentClearingBackStack(fragment);
}
}else if(intent.getBooleanExtra("compose", false)){
showCompose();
}
}
@@ -115,4 +120,15 @@ public class MainActivity extends FragmentStackActivity{
fragment.setArguments(args);
showFragment(fragment);
}
private void showCompose(){
AccountSession session=AccountSessionManager.getInstance().getLastActiveAccount();
if(session==null || !session.activated)
return;
ComposeFragment compose=new ComposeFragment();
Bundle composeArgs=new Bundle();
composeArgs.putString("account", session.getID());
compose.setArguments(composeArgs);
showFragment(compose);
}
}

View File

@@ -0,0 +1,39 @@
package org.joinmastodon.android.api;
import android.graphics.Rect;
import android.net.Uri;
import java.io.IOException;
public class AvatarResizedImageRequestBody extends ResizedImageRequestBody{
public AvatarResizedImageRequestBody(Uri uri, ProgressListener progressListener) throws IOException{
super(uri, 0, progressListener);
}
@Override
protected int[] getTargetSize(int srcWidth, int srcHeight){
float factor=400f/Math.min(srcWidth, srcHeight);
return new int[]{Math.round(srcWidth*factor), Math.round(srcHeight*factor)};
}
@Override
protected boolean needResize(int srcWidth, int srcHeight){
return srcHeight>400 || srcWidth!=srcHeight;
}
@Override
protected boolean needCrop(int srcWidth, int srcHeight){
return srcWidth!=srcHeight;
}
@Override
protected Rect getCropBounds(int srcWidth, int srcHeight){
Rect rect=new Rect();
if(srcWidth>srcHeight){
rect.set(srcWidth/2-srcHeight/2, 0, srcWidth/2-srcHeight/2+srcHeight, srcHeight);
}else{
rect.set(0, srcHeight/2-srcWidth/2, srcWidth, srcHeight/2-srcWidth/2+srcWidth);
}
return rect;
}
}

View File

@@ -233,6 +233,12 @@ public class CacheController{
});
}
public void deleteStatus(String id){
runOnDbThread((db)->{
db.delete("home_timeline", "`id`=?", new String[]{id});
});
}
public void clearRecentSearches(){
runOnDbThread((db)->db.delete("recent_searches", null, null));
}

View File

@@ -14,6 +14,7 @@ import android.os.Build;
import android.provider.OpenableColumns;
import org.joinmastodon.android.MastodonApp;
import org.joinmastodon.android.ui.utils.UiUtils;
import java.io.File;
import java.io.FileOutputStream;
@@ -30,62 +31,105 @@ public class ResizedImageRequestBody extends CountingRequestBody{
private File tempFile;
private Uri uri;
private String contentType;
private int maxSize;
public ResizedImageRequestBody(Uri uri, int maxSize, ProgressListener progressListener) throws IOException{
super(progressListener);
this.uri=uri;
contentType=MastodonApp.context.getContentResolver().getType(uri);
this.maxSize=maxSize;
BitmapFactory.Options opts=new BitmapFactory.Options();
opts.inJustDecodeBounds=true;
try(InputStream in=MastodonApp.context.getContentResolver().openInputStream(uri)){
BitmapFactory.decodeStream(in, null, opts);
if("file".equals(uri.getScheme())){
BitmapFactory.decodeFile(uri.getPath(), opts);
contentType=UiUtils.getFileMediaType(new File(uri.getPath())).type();
}else{
try(InputStream in=MastodonApp.context.getContentResolver().openInputStream(uri)){
BitmapFactory.decodeStream(in, null, opts);
}
contentType=MastodonApp.context.getContentResolver().getType(uri);
}
if(opts.outWidth*opts.outHeight>maxSize){
if(needResize(opts.outWidth, opts.outHeight) || needCrop(opts.outWidth, opts.outHeight)){
Bitmap bitmap;
if(Build.VERSION.SDK_INT>=29){
bitmap=ImageDecoder.decodeBitmap(ImageDecoder.createSource(MastodonApp.context.getContentResolver(), uri), (decoder, info, source)->{
int targetWidth=Math.round((float)Math.sqrt((float)maxSize*((float)info.getSize().getWidth()/info.getSize().getHeight())));
int targetHeight=Math.round((float)Math.sqrt((float)maxSize*((float)info.getSize().getHeight()/info.getSize().getWidth())));
if(Build.VERSION.SDK_INT>=28){
ImageDecoder.Source source;
if("file".equals(uri.getScheme())){
source=ImageDecoder.createSource(new File(uri.getPath()));
}else{
source=ImageDecoder.createSource(MastodonApp.context.getContentResolver(), uri);
}
bitmap=ImageDecoder.decodeBitmap(source, (decoder, info, _source)->{
int[] size=getTargetSize(info.getSize().getWidth(), info.getSize().getHeight());
decoder.setAllocator(ImageDecoder.ALLOCATOR_SOFTWARE);
decoder.setTargetSize(targetWidth, targetHeight);
decoder.setTargetSize(size[0], size[1]);
// Breaks images in mysterious ways
// if(needCrop(size[0], size[1]))
// decoder.setCrop(getCropBounds(size[0], size[1]));
});
if(needCrop(bitmap.getWidth(), bitmap.getHeight())){
Rect crop=getCropBounds(bitmap.getWidth(), bitmap.getHeight());
bitmap=Bitmap.createBitmap(bitmap, crop.left, crop.top, crop.width(), crop.height());
}
}else{
int targetWidth=Math.round((float)Math.sqrt((float)maxSize*((float)opts.outWidth/opts.outHeight)));
int targetHeight=Math.round((float)Math.sqrt((float)maxSize*((float)opts.outHeight/opts.outWidth)));
int[] size=getTargetSize(opts.outWidth, opts.outHeight);
int targetWidth=size[0];
int targetHeight=size[1];
float factor=opts.outWidth/(float)targetWidth;
opts=new BitmapFactory.Options();
opts.inSampleSize=(int)factor;
try(InputStream in=MastodonApp.context.getContentResolver().openInputStream(uri)){
bitmap=BitmapFactory.decodeStream(in, null, opts);
if("file".equals(uri.getScheme())){
bitmap=BitmapFactory.decodeFile(uri.getPath(), opts);
}else{
try(InputStream in=MastodonApp.context.getContentResolver().openInputStream(uri)){
bitmap=BitmapFactory.decodeStream(in, null, opts);
}
}
if(factor%1f!=0f){
Bitmap scaled=Bitmap.createBitmap(targetWidth, targetHeight, Bitmap.Config.ARGB_8888);
new Canvas(scaled).drawBitmap(bitmap, null, new Rect(0, 0, targetWidth, targetHeight), new Paint(Paint.FILTER_BITMAP_FLAG));
boolean needCrop=needCrop(targetWidth, targetHeight);
if(factor%1f!=0f || needCrop){
Rect srcBounds=null;
Rect dstBounds;
if(needCrop){
Rect crop=getCropBounds(targetWidth, targetHeight);
dstBounds=new Rect(0, 0, crop.width(), crop.height());
srcBounds=new Rect(
Math.round(crop.left/(float)targetWidth*bitmap.getWidth()),
Math.round(crop.top/(float)targetHeight*bitmap.getHeight()),
Math.round(crop.right/(float)targetWidth*bitmap.getWidth()),
Math.round(crop.bottom/(float)targetHeight*bitmap.getHeight())
);
}else{
dstBounds=new Rect(0, 0, targetWidth, targetHeight);
}
Bitmap scaled=Bitmap.createBitmap(dstBounds.width(), dstBounds.height(), Bitmap.Config.ARGB_8888);
new Canvas(scaled).drawBitmap(bitmap, srcBounds, dstBounds, new Paint(Paint.FILTER_BITMAP_FLAG));
bitmap=scaled;
}
if(Build.VERSION.SDK_INT>=Build.VERSION_CODES.N){
int rotation;
int orientation=0;
if("file".equals(uri.getScheme())){
ExifInterface exif=new ExifInterface(uri.getPath());
orientation=exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL);
}else if(Build.VERSION.SDK_INT>=Build.VERSION_CODES.N){
try(InputStream in=MastodonApp.context.getContentResolver().openInputStream(uri)){
ExifInterface exif=new ExifInterface(in);
int orientation=exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL);
rotation=switch(orientation){
case ExifInterface.ORIENTATION_ROTATE_90 -> 90;
case ExifInterface.ORIENTATION_ROTATE_180 -> 180;
case ExifInterface.ORIENTATION_ROTATE_270 -> 270;
default -> 0;
};
}
if(rotation!=0){
Matrix matrix=new Matrix();
matrix.setRotate(rotation);
bitmap=Bitmap.createBitmap(bitmap, 0, 0, bitmap.getWidth(), bitmap.getHeight(), matrix, false);
orientation=exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL);
}
}
int rotation=switch(orientation){
case ExifInterface.ORIENTATION_ROTATE_90 -> 90;
case ExifInterface.ORIENTATION_ROTATE_180 -> 180;
case ExifInterface.ORIENTATION_ROTATE_270 -> 270;
default -> 0;
};
if(rotation!=0){
Matrix matrix=new Matrix();
matrix.setRotate(rotation);
bitmap=Bitmap.createBitmap(bitmap, 0, 0, bitmap.getWidth(), bitmap.getHeight(), matrix, false);
}
}
tempFile=new File(MastodonApp.context.getCacheDir(), "tmp_upload_image");
boolean isPNG="image/png".equals(contentType);
tempFile=File.createTempFile("mastodon_tmp_resized", null);
try(FileOutputStream out=new FileOutputStream(tempFile)){
if("image/png".equals(contentType)){
if(isPNG){
bitmap.compress(Bitmap.CompressFormat.PNG, 0, out);
}else{
bitmap.compress(Bitmap.CompressFormat.JPEG, 97, out);
@@ -94,9 +138,13 @@ public class ResizedImageRequestBody extends CountingRequestBody{
}
length=tempFile.length();
}else{
try(Cursor cursor=MastodonApp.context.getContentResolver().query(uri, new String[]{OpenableColumns.SIZE}, null, null, null)){
cursor.moveToFirst();
length=cursor.getInt(0);
if("file".equals(uri.getScheme())){
length=new File(uri.getPath()).length();
}else{
try(Cursor cursor=MastodonApp.context.getContentResolver().query(uri, new String[]{OpenableColumns.SIZE}, null, null, null)){
cursor.moveToFirst();
length=cursor.getInt(0);
}
}
}
}
@@ -125,4 +173,22 @@ public class ResizedImageRequestBody extends CountingRequestBody{
}
}
}
protected int[] getTargetSize(int srcWidth, int srcHeight){
int targetWidth=Math.round((float)Math.sqrt((float)maxSize*((float)srcWidth/srcHeight)));
int targetHeight=Math.round((float)Math.sqrt((float)maxSize*((float)srcHeight/srcWidth)));
return new int[]{targetWidth, targetHeight};
}
protected boolean needResize(int srcWidth, int srcHeight){
return srcWidth*srcHeight>maxSize;
}
protected boolean needCrop(int srcWidth, int srcHeight){
return false;
}
protected Rect getCropBounds(int srcWidth, int srcHeight){
return null;
}
}

View File

@@ -2,13 +2,16 @@ package org.joinmastodon.android.api.requests.accounts;
import android.net.Uri;
import org.joinmastodon.android.api.AvatarResizedImageRequestBody;
import org.joinmastodon.android.api.ContentUriRequestBody;
import org.joinmastodon.android.api.MastodonAPIRequest;
import org.joinmastodon.android.api.ResizedImageRequestBody;
import org.joinmastodon.android.model.Account;
import org.joinmastodon.android.model.AccountField;
import org.joinmastodon.android.ui.utils.UiUtils;
import java.io.File;
import java.io.IOException;
import java.util.List;
import okhttp3.MultipartBody;
@@ -39,21 +42,21 @@ public class UpdateAccountCredentials extends MastodonAPIRequest<Account>{
}
@Override
public RequestBody getRequestBody(){
public RequestBody getRequestBody() throws IOException{
MultipartBody.Builder bldr=new MultipartBody.Builder()
.setType(MultipartBody.FORM)
.addFormDataPart("display_name", displayName)
.addFormDataPart("note", bio);
if(avatar!=null){
bldr.addFormDataPart("avatar", UiUtils.getFileName(avatar), new ContentUriRequestBody(avatar, null));
bldr.addFormDataPart("avatar", UiUtils.getFileName(avatar), new AvatarResizedImageRequestBody(avatar, null));
}else if(avatarFile!=null){
bldr.addFormDataPart("avatar", avatarFile.getName(), RequestBody.create(UiUtils.getFileMediaType(avatarFile), avatarFile));
bldr.addFormDataPart("avatar", avatarFile.getName(), new AvatarResizedImageRequestBody(Uri.fromFile(avatarFile), null));
}
if(cover!=null){
bldr.addFormDataPart("header", UiUtils.getFileName(cover), new ContentUriRequestBody(cover, null));
bldr.addFormDataPart("header", UiUtils.getFileName(cover), new ResizedImageRequestBody(cover, 1500*500, null));
}else if(coverFile!=null){
bldr.addFormDataPart("header", coverFile.getName(), RequestBody.create(UiUtils.getFileMediaType(coverFile), coverFile));
bldr.addFormDataPart("header", coverFile.getName(), new ResizedImageRequestBody(Uri.fromFile(coverFile), 1500*500, null));
}
if(fields.isEmpty()){
bldr.addFormDataPart("fields_attributes[0][name]", "").addFormDataPart("fields_attributes[0][value]", "");

View File

@@ -2,15 +2,22 @@ package org.joinmastodon.android.api.session;
import android.app.Activity;
import android.app.NotificationManager;
import android.content.ComponentName;
import android.content.Context;
import android.content.Intent;
import android.content.SharedPreferences;
import android.content.pm.ShortcutInfo;
import android.content.pm.ShortcutManager;
import android.graphics.drawable.Icon;
import android.net.Uri;
import android.os.Build;
import android.util.Log;
import com.google.gson.JsonParseException;
import org.joinmastodon.android.BuildConfig;
import org.joinmastodon.android.E;
import org.joinmastodon.android.MainActivity;
import org.joinmastodon.android.MastodonApp;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.MastodonAPIController;
@@ -85,11 +92,12 @@ public class AccountSessionManager{
domains.add(session.domain.toLowerCase());
sessions.put(session.getID(), session);
}
}catch(IOException|JsonParseException x){
}catch(Exception x){
Log.e(TAG, "Error loading accounts", x);
}
lastActiveAccountID=prefs.getString("lastActiveAccount", null);
MastodonAPIController.runInBackground(()->readInstanceInfo(domains));
maybeUpdateShortcuts();
}
public void addAccount(Instance instance, Token token, Account self, Application app, boolean active){
@@ -102,6 +110,7 @@ public class AccountSessionManager{
if(PushSubscriptionManager.arePushNotificationsAvailable()){
session.getPushSubscriptionManager().registerAccountForPush(null);
}
maybeUpdateShortcuts();
}
public synchronized void writeAccountsFile(){
@@ -181,6 +190,7 @@ public class AccountSessionManager{
NotificationManager nm=MastodonApp.context.getSystemService(NotificationManager.class);
nm.deleteNotificationChannelGroup(id);
}
maybeUpdateShortcuts();
}
@NonNull
@@ -358,7 +368,7 @@ public class AccountSessionManager{
customEmojis.put(domain, groupCustomEmojis(emojis));
instances.put(domain, emojis.instance);
instancesLastUpdated.put(domain, emojis.lastUpdated);
}catch(IOException|JsonParseException x){
}catch(Exception x){
Log.w(TAG, "Error reading instance info file for "+domain, x);
}
}
@@ -395,6 +405,29 @@ public class AccountSessionManager{
writeAccountsFile();
}
private void maybeUpdateShortcuts(){
if(Build.VERSION.SDK_INT<26)
return;
ShortcutManager sm=MastodonApp.context.getSystemService(ShortcutManager.class);
if((sm.getDynamicShortcuts().isEmpty() || BuildConfig.DEBUG) && !sessions.isEmpty()){
// There are no shortcuts, but there are accounts. Add a compose shortcut.
ShortcutInfo info=new ShortcutInfo.Builder(MastodonApp.context, "compose")
.setActivity(ComponentName.createRelative(MastodonApp.context, MainActivity.class.getName()))
.setShortLabel(MastodonApp.context.getString(R.string.new_post))
.setIcon(Icon.createWithResource(MastodonApp.context, R.mipmap.ic_shortcut_compose))
.setIntent(new Intent(MastodonApp.context, MainActivity.class)
.setAction(Intent.ACTION_MAIN)
.putExtra("compose", true))
.build();
sm.setDynamicShortcuts(Collections.singletonList(info));
}else if(sessions.isEmpty()){
// There are shortcuts, but no accounts. Disable existing shortcuts.
sm.disableShortcuts(Collections.singletonList("compose"), MastodonApp.context.getString(R.string.err_not_logged_in));
}else{
sm.enableShortcuts(Collections.singletonList("compose"));
}
}
private static class SessionsStorageWrapper{
public List<AccountSession> accounts;
}

View File

@@ -461,9 +461,7 @@ public abstract class BaseStatusListFragment<T extends DisplayItemsParent> exten
HeaderStatusDisplayItem.Holder header=findHolderOfType(itemID, HeaderStatusDisplayItem.Holder.class);
if(header!=null)
header.rebind();
for(ImageStatusDisplayItem.Holder photo:(List<ImageStatusDisplayItem.Holder>)findAllHoldersOfType(itemID, ImageStatusDisplayItem.Holder.class)){
photo.setRevealed(true);
}
updateImagesSpoilerState(status, itemID);
}
public void onVisibilityIconClick(HeaderStatusDisplayItem.Holder holder){
@@ -472,12 +470,25 @@ public abstract class BaseStatusListFragment<T extends DisplayItemsParent> exten
if(!TextUtils.isEmpty(status.spoilerText)){
TextStatusDisplayItem.Holder text=findHolderOfType(holder.getItemID(), TextStatusDisplayItem.Holder.class);
if(text!=null){
adapter.notifyItemChanged(text.getAbsoluteAdapterPosition()+getMainAdapterOffset());
adapter.notifyItemChanged(text.getAbsoluteAdapterPosition());
}
}
holder.rebind();
for(ImageStatusDisplayItem.Holder<?> photo:(List<ImageStatusDisplayItem.Holder>)findAllHoldersOfType(holder.getItemID(), ImageStatusDisplayItem.Holder.class)){
updateImagesSpoilerState(status, holder.getItemID());
}
protected void updateImagesSpoilerState(Status status, String itemID){
ArrayList<Integer> updatedPositions=new ArrayList<>();
for(ImageStatusDisplayItem.Holder photo:(List<ImageStatusDisplayItem.Holder>)findAllHoldersOfType(itemID, ImageStatusDisplayItem.Holder.class)){
photo.setRevealed(status.spoilerRevealed);
updatedPositions.add(photo.getAbsoluteAdapterPosition()-getMainAdapterOffset());
}
int i=0;
for(StatusDisplayItem item:displayItems){
if(itemID.equals(item.parentID) && item instanceof ImageStatusDisplayItem && !updatedPositions.contains(i)){
adapter.notifyItemChanged(i);
}
i++;
}
}

View File

@@ -610,12 +610,12 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
public void onSuccess(Status result){
wm.removeView(sendingOverlay);
sendingOverlay=null;
Nav.finish(ComposeFragment.this);
E.post(new StatusCreatedEvent(result));
if(replyTo!=null){
replyTo.repliesCount++;
E.post(new StatusCountersUpdatedEvent(replyTo));
}
Nav.finish(ComposeFragment.this);
}
@Override

View File

@@ -515,10 +515,17 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList
item.setShowAsAction(MenuItem.SHOW_AS_ACTION_ALWAYS);
return;
}
if(relationship==null)
if(relationship==null && !isOwnProfile)
return;
inflater.inflate(R.menu.profile, menu);
menu.findItem(R.id.share).setTitle(getString(R.string.share_user, account.getDisplayUsername()));
if(isOwnProfile){
for(int i=0;i<menu.size();i++){
MenuItem item=menu.getItem(i);
item.setVisible(item.getItemId()==R.id.share);
}
return;
}
menu.findItem(R.id.mute).setTitle(getString(relationship.muting ? R.string.unmute_user : R.string.mute_user, account.getDisplayUsername()));
menu.findItem(R.id.block).setTitle(getString(relationship.blocking ? R.string.unblock_user : R.string.block_user, account.getDisplayUsername()));
menu.findItem(R.id.report).setTitle(getString(R.string.report_user, account.getDisplayUsername()));

View File

@@ -13,7 +13,6 @@ import org.joinmastodon.android.events.StatusUnpinnedEvent;
import org.joinmastodon.android.model.Status;
import org.joinmastodon.android.ui.displayitems.ExtendedFooterStatusDisplayItem;
import org.joinmastodon.android.ui.displayitems.FooterStatusDisplayItem;
import org.joinmastodon.android.ui.displayitems.HeaderStatusDisplayItem;
import org.joinmastodon.android.ui.displayitems.StatusDisplayItem;
import org.parceler.Parcels;
@@ -116,10 +115,15 @@ public abstract class StatusListFragment extends BaseStatusListFragment<Status>{
return;
data.remove(status);
preloadedData.remove(status);
HeaderStatusDisplayItem item=findItemOfType(ev.id, HeaderStatusDisplayItem.class);
if(item==null)
int index=-1;
for(int i=0;i<displayItems.size();i++){
if(ev.id.equals(displayItems.get(i).parentID)){
index=i;
break;
}
}
if(index==-1)
return;
int index=displayItems.indexOf(item);
int lastIndex;
for(lastIndex=index;lastIndex<displayItems.size();lastIndex++){
if(!displayItems.get(lastIndex).parentID.equals(ev.id))

View File

@@ -150,6 +150,18 @@ public class DiscoverFragment extends AppKitFragment implements ScrollableToTop,
}
});
tabLayoutMediator.attach();
tabLayout.addOnTabSelectedListener(new TabLayout.OnTabSelectedListener(){
@Override
public void onTabSelected(TabLayout.Tab tab){}
@Override
public void onTabUnselected(TabLayout.Tab tab){}
@Override
public void onTabReselected(TabLayout.Tab tab){
scrollToTop();
}
});
searchEdit=view.findViewById(R.id.search_edit);
searchEdit.setOnFocusChangeListener(this::onSearchEditFocusChanged);

View File

@@ -205,7 +205,7 @@ public class SearchFragment extends BaseStatusListFragment<SearchResult>{
@Override
public void onTabReselected(TabLayout.Tab tab){
scrollToTop();
}
});
}

View File

@@ -162,6 +162,7 @@ public class ComposeAutocompleteViewController{
.map(WrappedEmoji::new)
.collect(Collectors.toList());
UiUtils.updateList(oldList, emojis, list, emojisAdapter, (e1, e2)->e1.emoji.shortcode.equals(e2.emoji.shortcode));
imgLoader.updateImages();
}
}
@@ -186,6 +187,7 @@ public class ComposeAutocompleteViewController{
List<WrappedAccount> oldList=users;
users=result.accounts.stream().map(WrappedAccount::new).collect(Collectors.toList());
UiUtils.updateList(oldList, users, list, usersAdapter, (a1, a2)->a1.account.id.equals(a2.account.id));
imgLoader.updateImages();
if(listIsHidden){
listIsHidden=false;
V.setVisibilityAnimated(list, View.VISIBLE);
@@ -210,6 +212,7 @@ public class ComposeAutocompleteViewController{
List<Hashtag> oldList=hashtags;
hashtags=result.hashtags;
UiUtils.updateList(oldList, hashtags, list, hashtagsAdapter, (t1, t2)->t1.name.equals(t2.name));
imgLoader.updateImages();
if(listIsHidden){
listIsHidden=false;
V.setVisibilityAnimated(list, View.VISIBLE);

View File

@@ -92,7 +92,9 @@ public class AudioStatusDisplayItem extends StatusDisplayItem{
public void onBind(AudioStatusDisplayItem item){
int seconds=(int)item.attachment.getDuration();
String duration=formatDuration(seconds);
time.getLayoutParams().width=(int)Math.ceil(time.getPaint().measureText("-"+duration));
// Some fonts (not Roboto) have different-width digits. 0 is supposedly the widest.
time.getLayoutParams().width=(int)Math.ceil(Math.max(time.getPaint().measureText("-"+duration),
time.getPaint().measureText("-"+duration.replaceAll("\\d", "0"))));
time.setText(duration);
AudioPlayerService service=AudioPlayerService.getInstance();
if(service!=null && service.getAttachmentID().equals(item.attachment.id)){

View File

@@ -11,6 +11,7 @@ import android.widget.TextView;
import org.joinmastodon.android.R;
import org.joinmastodon.android.fragments.BaseStatusListFragment;
import org.joinmastodon.android.model.Status;
import org.joinmastodon.android.ui.text.HtmlParser;
import org.joinmastodon.android.ui.utils.CustomEmojiHelper;
import org.joinmastodon.android.ui.views.LinkedTextView;
@@ -20,7 +21,8 @@ import me.grishka.appkit.imageloader.requests.ImageLoaderRequest;
public class TextStatusDisplayItem extends StatusDisplayItem{
private CharSequence text;
private CustomEmojiHelper emojiHelper=new CustomEmojiHelper();
private CustomEmojiHelper emojiHelper=new CustomEmojiHelper(), spoilerEmojiHelper;
private CharSequence parsedSpoilerText;
public boolean textSelectable;
public final Status status;
@@ -29,6 +31,11 @@ public class TextStatusDisplayItem extends StatusDisplayItem{
this.text=text;
this.status=status;
emojiHelper.setText(text);
if(!TextUtils.isEmpty(status.spoilerText)){
parsedSpoilerText=HtmlParser.parseCustomEmoji(status.spoilerText, status.emojis);
spoilerEmojiHelper=new CustomEmojiHelper();
spoilerEmojiHelper.setText(parsedSpoilerText);
}
}
@Override
@@ -38,11 +45,15 @@ public class TextStatusDisplayItem extends StatusDisplayItem{
@Override
public int getImageCount(){
if(spoilerEmojiHelper!=null && !status.spoilerRevealed)
return spoilerEmojiHelper.getImageCount();
return emojiHelper.getImageCount();
}
@Override
public ImageLoaderRequest getImageRequest(int index){
if(spoilerEmojiHelper!=null && !status.spoilerRevealed)
return spoilerEmojiHelper.getImageRequest(index);
return emojiHelper.getImageRequest(index);
}
@@ -65,7 +76,7 @@ public class TextStatusDisplayItem extends StatusDisplayItem{
text.setTextIsSelectable(item.textSelectable);
text.setInvalidateOnEveryFrame(false);
if(!TextUtils.isEmpty(item.status.spoilerText)){
spoilerTitle.setText(item.status.spoilerText);
spoilerTitle.setText(item.parsedSpoilerText);
if(item.status.spoilerRevealed){
spoilerOverlay.setVisibility(View.GONE);
text.setVisibility(View.VISIBLE);
@@ -84,8 +95,9 @@ public class TextStatusDisplayItem extends StatusDisplayItem{
@Override
public void setImage(int index, Drawable image){
item.emojiHelper.setImageDrawable(index, image);
getEmojiHelper().setImageDrawable(index, image);
text.invalidate();
spoilerTitle.invalidate();
if(image instanceof Animatable){
((Animatable) image).start();
if(image instanceof MovieDrawable)
@@ -95,8 +107,12 @@ public class TextStatusDisplayItem extends StatusDisplayItem{
@Override
public void clearImage(int index){
item.emojiHelper.setImageDrawable(index, null);
getEmojiHelper().setImageDrawable(index, null);
text.invalidate();
}
private CustomEmojiHelper getEmojiHelper(){
return item.spoilerEmojiHelper!=null && !item.status.spoilerRevealed ? item.spoilerEmojiHelper : item.emojiHelper;
}
}
}

View File

@@ -0,0 +1,8 @@
package org.joinmastodon.android.ui.text;
/**
* A span to mark character ranges that should be deleted when copied to the clipboard.
* Works with {@link org.joinmastodon.android.ui.views.LinkedTextView}.
*/
public class DeleteWhenCopiedSpan{
}

View File

@@ -67,10 +67,9 @@ public class HtmlParser{
@Override
public void head(@NonNull Node node, int depth){
if(node instanceof TextNode){
ssb.append(((TextNode) node).text());
}else if(node instanceof Element){
Element el=(Element)node;
if(node instanceof TextNode textNode){
ssb.append(textNode.text());
}else if(node instanceof Element el){
switch(el.nodeName()){
case "a" -> {
String href=el.attr("href");
@@ -108,10 +107,9 @@ public class HtmlParser{
@Override
public void tail(@NonNull Node node, int depth){
if(node instanceof Element){
Element el=(Element)node;
if(node instanceof Element el){
if("span".equals(el.nodeName()) && el.hasClass("ellipsis")){
ssb.append('…');
ssb.append("", new DeleteWhenCopiedSpan(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
}else if("p".equals(el.nodeName())){
if(node.nextSibling()!=null)
ssb.append("\n\n");

View File

@@ -109,7 +109,9 @@ public class UiUtils{
long t=instant.toEpochMilli();
long now=System.currentTimeMillis();
long diff=now-t;
if(diff<60_000L){
if(diff<1000L){
return context.getString(R.string.time_now);
}else if(diff<60_000L){
return context.getString(R.string.time_seconds, diff/1000L);
}else if(diff<3600_000L){
return context.getString(R.string.time_minutes, diff/60_000L);
@@ -338,6 +340,7 @@ public class UiUtils{
@Override
public void onSuccess(Status result){
resultCallback.accept(result);
AccountSessionManager.getInstance().getAccount(accountID).getCacheController().deleteStatus(status.id);
E.post(new StatusDeletedEvent(status.id, accountID));
}

View File

@@ -36,7 +36,7 @@ public class ImageAttachmentFrameLayout extends FrameLayout{
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
return;
}
int w=Math.min(((View)getParent()).getMeasuredWidth()-horizontalInset, V.dp(MAX_WIDTH));
int w=Math.min(((View)getParent()).getMeasuredWidth(), V.dp(MAX_WIDTH))-horizontalInset;
int actualHeight=Math.round(tile.height/1000f*w)+V.dp(1)*(tile.rowSpan-1);
int actualWidth=Math.round(tile.width/1000f*w);
if(tile.startCol+tile.colSpan<tileLayout.columnSizes.length)

View File

@@ -1,38 +1,68 @@
package org.joinmastodon.android.ui.views;
import android.content.ClipData;
import android.content.ClipboardManager;
import android.content.Context;
import android.graphics.Canvas;
import android.text.SpannableStringBuilder;
import android.text.Spanned;
import android.util.AttributeSet;
import android.util.Log;
import android.view.ActionMode;
import android.view.Menu;
import android.view.MenuItem;
import android.view.MotionEvent;
import android.widget.TextView;
import org.joinmastodon.android.ui.text.ClickableLinksDelegate;
import org.joinmastodon.android.ui.text.DeleteWhenCopiedSpan;
public class LinkedTextView extends TextView {
public class LinkedTextView extends TextView{
private ClickableLinksDelegate delegate=new ClickableLinksDelegate(this);
private boolean needInvalidate;
public LinkedTextView(Context context) {
super(context);
// TODO Auto-generated constructor stub
private ActionMode currentActionMode;
public LinkedTextView(Context context){
this(context, null);
}
public LinkedTextView(Context context, AttributeSet attrs) {
super(context, attrs);
// TODO Auto-generated constructor stub
public LinkedTextView(Context context, AttributeSet attrs){
this(context, attrs, 0);
}
public LinkedTextView(Context context, AttributeSet attrs, int defStyle) {
public LinkedTextView(Context context, AttributeSet attrs, int defStyle){
super(context, attrs, defStyle);
// TODO Auto-generated constructor stub
setCustomSelectionActionModeCallback(new ActionMode.Callback(){
@Override
public boolean onCreateActionMode(ActionMode mode, Menu menu){
currentActionMode=mode;
return true;
}
@Override
public boolean onPrepareActionMode(ActionMode mode, Menu menu){
return true;
}
@Override
public boolean onActionItemClicked(ActionMode mode, MenuItem item){
onTextContextMenuItem(item.getItemId());
return true;
}
@Override
public void onDestroyActionMode(ActionMode mode){
currentActionMode=null;
}
});
}
public boolean onTouchEvent(MotionEvent ev){
if(delegate.onTouch(ev)) return true;
return super.onTouchEvent(ev);
return super.onTouchEvent(ev);
}
public void onDraw(Canvas c){
super.onDraw(c);
delegate.onDraw(c);
@@ -47,4 +77,43 @@ public class LinkedTextView extends TextView {
invalidate();
}
@Override
public boolean onTextContextMenuItem(int id){
if(id==android.R.id.copy){
final int selStart=getSelectionStart();
final int selEnd=getSelectionEnd();
int min=Math.max(0, Math.min(selStart, selEnd));
int max=Math.max(0, Math.max(selStart, selEnd));
final ClipData copyData=ClipData.newPlainText(null, deleteTextWithinDeleteSpans(getText().subSequence(min, max)));
ClipboardManager clipboard=getContext().getSystemService(ClipboardManager.class);
try {
clipboard.setPrimaryClip(copyData);
} catch (Throwable t) {
Log.w("LinkedTextView", t);
}
if(currentActionMode!=null){
currentActionMode.finish();
}
return true;
}
return super.onTextContextMenuItem(id);
}
private CharSequence deleteTextWithinDeleteSpans(CharSequence text){
if(text instanceof Spanned spanned){
DeleteWhenCopiedSpan[] delSpans=spanned.getSpans(0, text.length(), DeleteWhenCopiedSpan.class);
if(delSpans.length>0){
SpannableStringBuilder ssb=new SpannableStringBuilder(spanned);
for(DeleteWhenCopiedSpan span:delSpans){
int start=ssb.getSpanStart(span);
int end=ssb.getSpanStart(span);
if(start==-1)
continue;
ssb.delete(start, end+1);
}
return ssb;
}
}
return text;
}
}