Basic status rendering

This commit is contained in:
Grishka 2022-01-17 13:27:34 +03:00
parent 42d5f52ff5
commit dfbc1fd2e2
41 changed files with 1735 additions and 17 deletions

View File

@ -32,7 +32,8 @@ dependencies {
implementation 'me.grishka.litex:recyclerview:1.2.1'
implementation 'me.grishka.litex:swiperefreshlayout:1.1.0'
implementation 'me.grishka.litex:browser:1.4.0'
implementation 'me.grishka.appkit:appkit:1.0'
implementation 'me.grishka.appkit:appkit:1.1'
implementation 'com.google.code.gson:gson:2.8.9'
implementation 'org.jsoup:jsoup:1.14.3'
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:1.1.5'
}

View File

@ -9,7 +9,8 @@
android:allowBackup="true"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/Theme.Mastodon">
android:theme="@style/Theme.Mastodon"
android:largeHeap="true">
<activity android:name=".MainActivity" android:exported="true" android:configChanges="orientation|screenSize">
<intent-filter>

View File

@ -4,6 +4,9 @@ import android.annotation.SuppressLint;
import android.app.Application;
import android.content.Context;
import me.grishka.appkit.imageloader.ImageCache;
import me.grishka.appkit.utils.NetworkUtils;
public class MastodonApp extends Application{
@SuppressLint("StaticFieldLeak") // it's not a leak
@ -12,6 +15,11 @@ public class MastodonApp extends Application{
@Override
public void onCreate(){
super.onCreate();
ImageCache.Parameters params=new ImageCache.Parameters();
params.diskCacheSize=100*1024*1024;
params.maxMemoryCacheSize=Integer.MAX_VALUE;
ImageCache.setParams(params);
NetworkUtils.setUserAgent("MastodonAndroid/"+BuildConfig.VERSION_NAME);
context=getApplicationContext();
}
}

View File

@ -60,6 +60,8 @@ public class MastodonAPIController{
public <T> void submitRequest(final MastodonAPIRequest<T> req){
thread.postRunnable(()->{
try{
if(req.canceled)
return;
Request.Builder builder=new Request.Builder()
.url(req.getURL().toString())
.method(req.getMethod(), req.getRequestBody())

View File

@ -32,6 +32,7 @@ public abstract class MastodonAPIRequest<T> extends APIRequest<T>{
TypeToken<T> respTypeToken;
Call okhttpCall;
Token token;
boolean canceled;
public MastodonAPIRequest(HttpMethod method, String path, Class<T> respClass){
this.path=path;
@ -47,6 +48,7 @@ public abstract class MastodonAPIRequest<T> extends APIRequest<T>{
@Override
public synchronized void cancel(){
canceled=true;
if(okhttpCall!=null){
okhttpCall.cancel();
}

View File

@ -0,0 +1,20 @@
package org.joinmastodon.android.api.requests.timelines;
import com.google.gson.reflect.TypeToken;
import org.joinmastodon.android.api.MastodonAPIRequest;
import org.joinmastodon.android.model.Status;
import java.util.List;
public class GetHomeTimeline extends MastodonAPIRequest<List<Status>>{
public GetHomeTimeline(String maxID, String minID, int limit){
super(HttpMethod.GET, "/timelines/home", new TypeToken<>(){});
if(maxID!=null)
addQueryParameter("max_id", maxID);
if(minID!=null)
addQueryParameter("min_id", minID);
if(limit>0)
addQueryParameter("limit", ""+limit);
}
}

View File

@ -159,6 +159,7 @@ public class AccountSessionManager{
.build();
new CustomTabsIntent.Builder()
.setShareState(CustomTabsIntent.SHARE_STATE_OFF)
.build()
.launchUrl(context, uri);
}

View File

@ -4,15 +4,43 @@ import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.FrameLayout;
import android.widget.LinearLayout;
import org.joinmastodon.android.R;
import androidx.annotation.Nullable;
import me.grishka.appkit.fragments.AppKitFragment;
import me.grishka.appkit.fragments.ToolbarFragment;
import me.grishka.appkit.views.FragmentRootLinearLayout;
public class HomeFragment extends AppKitFragment{
private FragmentRootLinearLayout content;
private HomeTimelineFragment homeTimelineFragment;
private String accountID;
@Override
public void onCreate(Bundle savedInstanceState){
super.onCreate(savedInstanceState);
accountID=getArguments().getString("account");
}
public class HomeFragment extends ToolbarFragment{
@Nullable
@Override
public View onCreateContentView(LayoutInflater inflater, @Nullable ViewGroup container, Bundle savedInstanceState){
return new View(getActivity());
public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container, Bundle savedInstanceState){
content=new FragmentRootLinearLayout(getActivity());
content.setOrientation(LinearLayout.VERTICAL);
FrameLayout fragmentContainer=new FrameLayout(getActivity());
fragmentContainer.setId(R.id.fragment_wrap);
content.addView(fragmentContainer, new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f));
Bundle args=new Bundle();
args.putString("account", accountID);
homeTimelineFragment=new HomeTimelineFragment();
homeTimelineFragment.setArguments(args);
getChildFragmentManager().beginTransaction().add(R.id.fragment_wrap, homeTimelineFragment).commit();
return content;
}
}

View File

@ -0,0 +1,35 @@
package org.joinmastodon.android.fragments;
import android.app.Activity;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.requests.timelines.GetHomeTimeline;
import org.joinmastodon.android.model.Status;
import java.util.List;
import me.grishka.appkit.api.SimpleCallback;
public class HomeTimelineFragment extends StatusListFragment{
private String accountID;
@Override
public void onAttach(Activity activity){
super.onAttach(activity);
setTitle(R.string.app_name);
accountID=getArguments().getString("account");
loadData();
}
@Override
protected void doLoadData(int offset, int count){
new GetHomeTimeline(null, null, count)
.setCallback(new SimpleCallback<>(this){
@Override
public void onSuccess(List<Status> result){
onDataLoaded(result, false);
}
})
.exec(accountID);
}
}

View File

@ -0,0 +1,83 @@
package org.joinmastodon.android.fragments;
import android.view.ViewGroup;
import org.joinmastodon.android.model.Status;
import org.joinmastodon.android.ui.displayitems.StatusDisplayItem;
import java.util.ArrayList;
import java.util.List;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.RecyclerView;
import me.grishka.appkit.fragments.BaseRecyclerFragment;
import me.grishka.appkit.imageloader.ImageLoaderRecyclerAdapter;
import me.grishka.appkit.imageloader.requests.ImageLoaderRequest;
import me.grishka.appkit.utils.BindableViewHolder;
import me.grishka.appkit.views.UsableRecyclerView;
public abstract class StatusListFragment extends BaseRecyclerFragment<Status>{
protected ArrayList<StatusDisplayItem> displayItems=new ArrayList<>();
public StatusListFragment(){
super(20);
}
@Override
protected RecyclerView.Adapter getAdapter(){
return new DisplayItemsAdapter();
}
@Override
public void onAppendItems(List<Status> items){
super.onAppendItems(items);
for(Status s:items){
displayItems.addAll(StatusDisplayItem.buildItems(this, s));
}
}
@Override
public void onClearItems(){
super.onClearItems();
displayItems.clear();
}
protected class DisplayItemsAdapter extends UsableRecyclerView.Adapter<BindableViewHolder<StatusDisplayItem>> implements ImageLoaderRecyclerAdapter{
public DisplayItemsAdapter(){
super(imgLoader);
}
@NonNull
@Override
public BindableViewHolder<StatusDisplayItem> onCreateViewHolder(@NonNull ViewGroup parent, int viewType){
return (BindableViewHolder<StatusDisplayItem>) StatusDisplayItem.createViewHolder(StatusDisplayItem.Type.values()[viewType], getActivity(), parent);
}
@Override
public void onBindViewHolder(BindableViewHolder<StatusDisplayItem> holder, int position){
holder.bind(displayItems.get(position));
super.onBindViewHolder(holder, position);
}
@Override
public int getItemCount(){
return displayItems.size();
}
@Override
public int getItemViewType(int position){
return displayItems.get(position).getType().ordinal();
}
@Override
public int getImageCountForItem(int position){
return displayItems.get(position).getImageCount();
}
@Override
public ImageLoaderRequest getImageRequest(int position, int image){
return displayItems.get(position).getImageRequest(image);
}
}
}

View File

@ -127,7 +127,7 @@ public class InstanceCatalogFragment extends BaseRecyclerFragment<CatalogInstanc
group.activeUsers+=instance.lastWeekUsers;
}
return group;
}).sorted(Comparator.comparingInt(g->g.activeUsers)).forEachOrdered(ig->sortedList.addAll(ig.instances));
}).sorted(Comparator.comparingInt((InstanceGroup g)->g.activeUsers).reversed()).forEachOrdered(ig->sortedList.addAll(ig.instances));
onDataLoaded(sortedList, false);
updateFilteredList();
}
@ -299,18 +299,13 @@ public class InstanceCatalogFragment extends BaseRecyclerFragment<CatalogInstanc
}
Instance cachedInstance=instancesCache.get(domain);
if(cachedInstance!=null){
boolean found=false;
for(CatalogInstance ci:filteredData){
if(ci.domain.equals(currentSearchQuery)){
found=true;
break;
if(ci.domain.equals(currentSearchQuery))
return;
}
}
if(!found){
CatalogInstance ci=cachedInstance.toCatalogInstance();
filteredData.add(0, ci);
adapter.notifyItemInserted(0);
}
return;
}
if(loadingInstanceDomain!=null){

View File

@ -22,4 +22,13 @@ public class AccountField extends BaseModel{
* Timestamp of when the server verified a URL value for a rel="me” link.
*/
public Instant verifiedAt;
@Override
public String toString(){
return "AccountField{"+
"name='"+name+'\''+
", value='"+value+'\''+
", verifiedAt="+verifiedAt+
'}';
}
}

View File

@ -0,0 +1,128 @@
package org.joinmastodon.android.model;
import android.graphics.Bitmap;
import android.graphics.PointF;
import android.graphics.drawable.Drawable;
import com.google.gson.annotations.SerializedName;
import org.joinmastodon.android.api.ObjectValidationException;
import org.joinmastodon.android.api.RequiredField;
import org.joinmastodon.android.ui.utils.BlurHashDecoder;
import org.joinmastodon.android.ui.utils.BlurHashDrawable;
public class Attachment extends BaseModel{
@RequiredField
public String id;
@RequiredField
public Type type;
@RequiredField
public String url;
@RequiredField
public String previewUrl;
public String remoteUrl;
public String description;
public String blurhash;
public Metadata meta;
public transient Drawable blurhashPlaceholder;
public int getWidth(){
if(meta==null)
return 0;
if(meta.width>0)
return meta.width;
if(meta.original!=null && meta.original.width>0)
return meta.original.width;
if(meta.small!=null && meta.small.width>0)
return meta.small.width;
return 0;
}
public int getHeight(){
if(meta==null)
return 0;
if(meta.height>0)
return meta.height;
if(meta.original!=null && meta.original.height>0)
return meta.original.height;
if(meta.small!=null && meta.small.height>0)
return meta.small.height;
return 0;
}
@Override
public void postprocess() throws ObjectValidationException{
super.postprocess();
if(blurhash!=null){
Bitmap placeholder=BlurHashDecoder.decode(blurhash, 16, 16);
if(placeholder!=null)
blurhashPlaceholder=new BlurHashDrawable(placeholder, getWidth(), getHeight());
}
}
@Override
public String toString(){
return "Attachment{"+
"id='"+id+'\''+
", type="+type+
", url='"+url+'\''+
", previewUrl='"+previewUrl+'\''+
", remoteUrl='"+remoteUrl+'\''+
", description='"+description+'\''+
", blurhash='"+blurhash+'\''+
", meta="+meta+
'}';
}
public enum Type{
@SerializedName("image")
IMAGE,
@SerializedName("gifv")
GIFV,
@SerializedName("video")
VIDEO,
@SerializedName("audio")
AUDIO,
@SerializedName("unknown")
UNKNOWN
}
public static class Metadata{
public double duration;
public int width;
public int height;
public double aspect;
public PointF focus;
public SizeMetadata original;
public SizeMetadata small;
@Override
public String toString(){
return "Metadata{"+
"duration="+duration+
", width="+width+
", height="+height+
", aspect="+aspect+
", focus="+focus+
", original="+original+
", small="+small+
'}';
}
}
public static class SizeMetadata{
public int width;
public int height;
public double aspect;
@Override
public String toString(){
return "SizeMetadata{"+
"width="+width+
", height="+height+
", aspect="+aspect+
'}';
}
}
}

View File

@ -0,0 +1,71 @@
package org.joinmastodon.android.model;
import android.graphics.Bitmap;
import android.graphics.drawable.Drawable;
import com.google.gson.annotations.SerializedName;
import org.joinmastodon.android.api.ObjectValidationException;
import org.joinmastodon.android.api.RequiredField;
import org.joinmastodon.android.ui.utils.BlurHashDecoder;
import org.joinmastodon.android.ui.utils.BlurHashDrawable;
public class Card extends BaseModel{
@RequiredField
public String url;
@RequiredField
public String description;
@RequiredField
public Type type;
public String authorName;
public String authorUrl;
public String providerName;
public String providerUrl;
// public String html;
public int width;
public int height;
public String image;
public String embedUrl;
public String blurhash;
public transient Drawable blurhashPlaceholder;
@Override
public void postprocess() throws ObjectValidationException{
super.postprocess();
if(blurhash!=null){
Bitmap placeholder=BlurHashDecoder.decode(blurhash, 16, 16);
if(placeholder!=null)
blurhashPlaceholder=new BlurHashDrawable(placeholder, width, height);
}
}
@Override
public String toString(){
return "Card{"+
"url='"+url+'\''+
", description='"+description+'\''+
", type="+type+
", authorName='"+authorName+'\''+
", authorUrl='"+authorUrl+'\''+
", providerName='"+providerName+'\''+
", providerUrl='"+providerUrl+'\''+
", width="+width+
", height="+height+
", image='"+image+'\''+
", embedUrl='"+embedUrl+'\''+
", blurhash='"+blurhash+'\''+
'}';
}
public enum Type{
@SerializedName("link")
LINK,
@SerializedName("photo")
PHOTO,
@SerializedName("video")
VIDEO,
@SerializedName("rich")
RICH
}
}

View File

@ -30,4 +30,15 @@ public class Emoji extends BaseModel{
* Used for sorting custom emoji in the picker.
*/
public String category;
@Override
public String toString(){
return "Emoji{"+
"shortcode='"+shortcode+'\''+
", url='"+url+'\''+
", staticUrl='"+staticUrl+'\''+
", visibleInPicker="+visibleInPicker+
", category='"+category+'\''+
'}';
}
}

View File

@ -0,0 +1,22 @@
package org.joinmastodon.android.model;
import org.joinmastodon.android.api.RequiredField;
import java.util.List;
public class Hashtag extends BaseModel{
@RequiredField
public String name;
@RequiredField
public String url;
public List<History> history;
@Override
public String toString(){
return "Hashtag{"+
"name='"+name+'\''+
", url='"+url+'\''+
", history="+history+
'}';
}
}

View File

@ -0,0 +1,19 @@
package org.joinmastodon.android.model;
import org.joinmastodon.android.api.AllFieldsAreRequired;
@AllFieldsAreRequired
public class History extends BaseModel{
public long day; // unixtime
public int uses;
public int accounts;
@Override
public String toString(){
return "History{"+
"day="+day+
", uses="+uses+
", accounts="+accounts+
'}';
}
}

View File

@ -0,0 +1,21 @@
package org.joinmastodon.android.model;
import org.joinmastodon.android.api.AllFieldsAreRequired;
@AllFieldsAreRequired
public class Mention extends BaseModel{
public String id;
public String username;
public String acct;
public String url;
@Override
public String toString(){
return "Mention{"+
"id='"+id+'\''+
", username='"+username+'\''+
", acct='"+acct+'\''+
", url='"+url+'\''+
'}';
}
}

View File

@ -0,0 +1,56 @@
package org.joinmastodon.android.model;
import org.joinmastodon.android.api.AllFieldsAreRequired;
import org.joinmastodon.android.api.ObjectValidationException;
import java.time.Instant;
import java.util.Arrays;
import java.util.List;
@AllFieldsAreRequired
public class Poll extends BaseModel{
public String id;
public Instant expiresAt;
public boolean expired;
public boolean multiple;
public int votersCount;
public boolean voted;
public int[] ownVotes;
public List<Option> options;
public List<Emoji> emojis;
@Override
public void postprocess() throws ObjectValidationException{
super.postprocess();
for(Emoji e:emojis)
e.postprocess();
}
@Override
public String toString(){
return "Poll{"+
"id='"+id+'\''+
", expiresAt="+expiresAt+
", expired="+expired+
", multiple="+multiple+
", votersCount="+votersCount+
", voted="+voted+
", ownVotes="+Arrays.toString(ownVotes)+
", options="+options+
", emojis="+emojis+
'}';
}
public static class Option{
public String title;
public Integer votesCount;
@Override
public String toString(){
return "Option{"+
"title='"+title+'\''+
", votesCount="+votesCount+
'}';
}
}
}

View File

@ -42,4 +42,16 @@ public class Source extends BaseModel{
for(AccountField f:fields)
f.postprocess();
}
@Override
public String toString(){
return "Source{"+
"note='"+note+'\''+
", fields="+fields+
", privacy="+privacy+
", sensitive="+sensitive+
", language='"+language+'\''+
", followRequestCount="+followRequestCount+
'}';
}
}

View File

@ -0,0 +1,120 @@
package org.joinmastodon.android.model;
import android.text.Html;
import android.text.TextUtils;
import org.joinmastodon.android.api.ObjectValidationException;
import org.joinmastodon.android.api.RequiredField;
import org.joinmastodon.android.ui.text.HtmlParser;
import java.time.Instant;
import java.util.List;
public class Status extends BaseModel{
@RequiredField
public String id;
@RequiredField
public String uri;
@RequiredField
public Instant createdAt;
@RequiredField
public Account account;
@RequiredField
public String content;
@RequiredField
public StatusPrivacy visibility;
public boolean sensitive;
@RequiredField
public String spoilerText;
@RequiredField
public List<Attachment> mediaAttachments;
public Application application;
@RequiredField
public List<Mention> mentions;
@RequiredField
public List<Hashtag> tags;
@RequiredField
public List<Emoji> emojis;
public int reblogsCount;
public int favouritesCount;
public int repliesCount;
public String url;
public String inReplyToId;
public String inReplyToAccountId;
public Status reblog;
public Poll poll;
public Card card;
public String language;
public String text;
public boolean favourited;
public boolean reblogged;
public boolean muted;
public boolean bookmarked;
public boolean pinned;
public transient CharSequence processedContent;
@Override
public void postprocess() throws ObjectValidationException{
super.postprocess();
if(application!=null)
application.postprocess();
for(Mention m:mentions)
m.postprocess();
for(Hashtag t:tags)
t.postprocess();
for(Emoji e:emojis)
e.postprocess();
for(Attachment a:mediaAttachments)
a.postprocess();
account.postprocess();
if(poll!=null)
poll.postprocess();
if(card!=null)
card.postprocess();
if(reblog!=null)
reblog.postprocess();
if(!TextUtils.isEmpty(content)){
processedContent=HtmlParser.parse(content, emojis);
}
}
@Override
public String toString(){
return "Status{"+
"id='"+id+'\''+
", uri='"+uri+'\''+
", createdAt="+createdAt+
", account="+account+
", content='"+content+'\''+
", visibility="+visibility+
", sensitive="+sensitive+
", spoilerText='"+spoilerText+'\''+
", mediaAttachments="+mediaAttachments+
", application="+application+
", mentions="+mentions+
", tags="+tags+
", emojis="+emojis+
", reblogsCount="+reblogsCount+
", favouritesCount="+favouritesCount+
", repliesCount="+repliesCount+
", url='"+url+'\''+
", inReplyToId='"+inReplyToId+'\''+
", inReplyToAccountId='"+inReplyToAccountId+'\''+
", reblog="+reblog+
", poll="+poll+
", card="+card+
", language='"+language+'\''+
", text='"+text+'\''+
", favourited="+favourited+
", reblogged="+reblogged+
", muted="+muted+
", bookmarked="+bookmarked+
", pinned="+pinned+
", processedContent="+processedContent+
'}';
}
}

View File

@ -0,0 +1,77 @@
package org.joinmastodon.android.ui.displayitems;
import android.app.Activity;
import android.graphics.Bitmap;
import android.graphics.drawable.Animatable;
import android.graphics.drawable.Drawable;
import android.view.ViewGroup;
import android.widget.ImageView;
import android.widget.TextView;
import org.joinmastodon.android.R;
import org.joinmastodon.android.model.Account;
import org.joinmastodon.android.model.Status;
import java.time.Instant;
import me.grishka.appkit.imageloader.ImageLoaderViewHolder;
import me.grishka.appkit.imageloader.requests.ImageLoaderRequest;
import me.grishka.appkit.imageloader.requests.UrlImageLoaderRequest;
import me.grishka.appkit.utils.BindableViewHolder;
public class HeaderStatusDisplayItem extends StatusDisplayItem{
private Account user;
private Instant createdAt;
private ImageLoaderRequest avaRequest;
public HeaderStatusDisplayItem(Status status, Account user, Instant createdAt){
super(status);
this.user=user;
this.createdAt=createdAt;
avaRequest=new UrlImageLoaderRequest(user.avatar);
}
@Override
public Type getType(){
return Type.HEADER;
}
@Override
public int getImageCount(){
return 1;
}
@Override
public ImageLoaderRequest getImageRequest(int index){
return avaRequest;
}
public static class Holder extends BindableViewHolder<HeaderStatusDisplayItem> implements ImageLoaderViewHolder{
private final TextView name, subtitle;
private final ImageView avatar;
public Holder(Activity activity, ViewGroup parent){
super(activity, R.layout.display_item_header, parent);
name=findViewById(R.id.name);
subtitle=findViewById(R.id.subtitle);
avatar=findViewById(R.id.avatar);
}
@Override
public void onBind(HeaderStatusDisplayItem item){
name.setText(item.user.displayName);
subtitle.setText('@'+item.user.acct);
}
@Override
public void setImage(int index, Drawable drawable){
avatar.setImageDrawable(drawable);
if(drawable instanceof Animatable)
((Animatable) drawable).start();
}
@Override
public void clearImage(int index){
avatar.setImageBitmap(null);
}
}
}

View File

@ -0,0 +1,64 @@
package org.joinmastodon.android.ui.displayitems;
import android.app.Activity;
import android.graphics.Bitmap;
import android.graphics.drawable.Drawable;
import android.view.ViewGroup;
import android.widget.ImageView;
import org.joinmastodon.android.R;
import org.joinmastodon.android.model.Attachment;
import org.joinmastodon.android.model.Status;
import me.grishka.appkit.imageloader.ImageLoaderViewHolder;
import me.grishka.appkit.imageloader.requests.ImageLoaderRequest;
import me.grishka.appkit.imageloader.requests.UrlImageLoaderRequest;
import me.grishka.appkit.utils.BindableViewHolder;
public class PhotoStatusDisplayItem extends StatusDisplayItem{
private Attachment attachment;
private ImageLoaderRequest request;
public PhotoStatusDisplayItem(Status status, Attachment photo){
super(status);
this.attachment=photo;
request=new UrlImageLoaderRequest(photo.url, 1000, 1000);
}
@Override
public Type getType(){
return Type.PHOTO;
}
@Override
public int getImageCount(){
return 1;
}
@Override
public ImageLoaderRequest getImageRequest(int index){
return request;
}
public static class Holder extends BindableViewHolder<PhotoStatusDisplayItem> implements ImageLoaderViewHolder{
private final ImageView photo;
public Holder(Activity activity, ViewGroup parent){
super(activity, R.layout.display_item_photo, parent);
photo=findViewById(R.id.photo);
}
@Override
public void onBind(PhotoStatusDisplayItem item){
}
@Override
public void setImage(int index, Drawable drawable){
photo.setImageDrawable(drawable);
}
@Override
public void clearImage(int index){
photo.setImageDrawable(item.attachment.blurhashPlaceholder);
}
}
}

View File

@ -0,0 +1,36 @@
package org.joinmastodon.android.ui.displayitems;
import android.app.Activity;
import android.view.ViewGroup;
import android.widget.TextView;
import org.joinmastodon.android.R;
import org.joinmastodon.android.model.Status;
import me.grishka.appkit.utils.BindableViewHolder;
public class ReblogOrReplyLineStatusDisplayItem extends StatusDisplayItem{
public ReblogOrReplyLineStatusDisplayItem(Status status){
super(status);
}
@Override
public Type getType(){
return Type.REBLOG_OR_REPLY_LINE;
}
public static class Holder extends BindableViewHolder<ReblogOrReplyLineStatusDisplayItem>{
private final TextView text;
public Holder(Activity activity, ViewGroup parent){
super(activity, R.layout.display_item_reblog_or_reply_line, parent);
text=findViewById(R.id.text);
}
@Override
public void onBind(ReblogOrReplyLineStatusDisplayItem item){
if(item.status.reblog!=null){
text.setText(itemView.getContext().getString(R.string.user_boosted, item.status.account.displayName));
}
}
}
}

View File

@ -0,0 +1,75 @@
package org.joinmastodon.android.ui.displayitems;
import android.app.Activity;
import android.app.Fragment;
import android.text.TextUtils;
import android.view.ViewGroup;
import org.joinmastodon.android.model.Attachment;
import org.joinmastodon.android.model.Status;
import java.util.ArrayList;
import java.util.List;
import me.grishka.appkit.imageloader.requests.ImageLoaderRequest;
import me.grishka.appkit.utils.BindableViewHolder;
public abstract class StatusDisplayItem{
public final Status status;
public StatusDisplayItem(Status status){
this.status=status;
}
public abstract Type getType();
public int getImageCount(){
return 0;
}
public ImageLoaderRequest getImageRequest(int index){
return null;
}
public static BindableViewHolder<? extends StatusDisplayItem> createViewHolder(Type type, Activity activity, ViewGroup parent){
return switch(type){
case HEADER -> new HeaderStatusDisplayItem.Holder(activity, parent);
case REBLOG_OR_REPLY_LINE -> new ReblogOrReplyLineStatusDisplayItem.Holder(activity, parent);
case TEXT -> new TextStatusDisplayItem.Holder(activity, parent);
case PHOTO -> new PhotoStatusDisplayItem.Holder(activity, parent);
default -> throw new UnsupportedOperationException();
};
}
public static List<StatusDisplayItem> buildItems(Fragment fragment, Status status){
ArrayList<StatusDisplayItem> items=new ArrayList<>();
Status statusForContent=status.reblog==null ? status : status.reblog;
if(status.reblog!=null){
items.add(new ReblogOrReplyLineStatusDisplayItem(status));
}
items.add(new HeaderStatusDisplayItem(status, statusForContent.account, statusForContent.createdAt));
if(!TextUtils.isEmpty(statusForContent.content))
items.add(new TextStatusDisplayItem(status, statusForContent.processedContent, fragment));
for(Attachment attachment:statusForContent.mediaAttachments){
if(attachment.type==Attachment.Type.IMAGE){
items.add(new PhotoStatusDisplayItem(status, attachment));
}
}
return items;
}
public enum Type{
HEADER,
REBLOG_OR_REPLY_LINE,
TEXT,
PHOTO,
VIDEO,
GIFV,
AUDIO,
POLL_HEADER,
POLL_OPTION,
POLL_FOOTER,
CARD,
FOOTER,
}
}

View File

@ -0,0 +1,113 @@
package org.joinmastodon.android.ui.displayitems;
import android.app.Activity;
import android.app.Fragment;
import android.graphics.drawable.Animatable;
import android.graphics.drawable.Drawable;
import android.text.Spanned;
import android.view.ViewGroup;
import android.widget.Toast;
import org.joinmastodon.android.R;
import org.joinmastodon.android.model.Status;
import org.joinmastodon.android.ui.text.CustomEmojiSpan;
import org.joinmastodon.android.ui.text.LinkSpan;
import org.joinmastodon.android.ui.utils.UiUtils;
import org.joinmastodon.android.ui.views.LinkedTextView;
import me.grishka.appkit.imageloader.ImageLoaderViewHolder;
import me.grishka.appkit.imageloader.MovieDrawable;
import me.grishka.appkit.imageloader.requests.ImageLoaderRequest;
import me.grishka.appkit.imageloader.requests.UrlImageLoaderRequest;
import me.grishka.appkit.utils.BindableViewHolder;
import me.grishka.appkit.utils.V;
public class TextStatusDisplayItem extends StatusDisplayItem implements LinkSpan.OnLinkClickListener{
private CharSequence text;
private ImageLoaderRequest[] emojiRequests;
private Fragment parentFragment;
public TextStatusDisplayItem(Status status, CharSequence text, Fragment parentFragment){
super(status);
this.text=text;
this.parentFragment=parentFragment;
if(text instanceof Spanned){
CustomEmojiSpan[] emojiSpans=((Spanned) text).getSpans(0, text.length(), CustomEmojiSpan.class);
emojiRequests=new ImageLoaderRequest[emojiSpans.length];
int emojiSize=V.dp(20);
for(int i=0; i<emojiSpans.length; i++){
emojiRequests[i]=new UrlImageLoaderRequest(emojiSpans[i].emoji.url, emojiSize, emojiSize);
}
LinkSpan[] linkSpans=((Spanned) text).getSpans(0, text.length(), LinkSpan.class);
for(LinkSpan span:linkSpans){
span.setListener(this);
}
}else{
emojiRequests=new ImageLoaderRequest[0];
}
}
@Override
public Type getType(){
return Type.TEXT;
}
@Override
public int getImageCount(){
return emojiRequests.length;
}
@Override
public ImageLoaderRequest getImageRequest(int index){
return emojiRequests[index];
}
@Override
public void onLinkClick(LinkSpan span){
switch(span.getType()){
case URL -> UiUtils.launchWebBrowser(parentFragment.getActivity(), span.getLink());
case HASHTAG, MENTION -> Toast.makeText(parentFragment.getActivity(), "Not implemented yet", Toast.LENGTH_SHORT).show();
}
}
public static class Holder extends BindableViewHolder<TextStatusDisplayItem> implements ImageLoaderViewHolder{
private final LinkedTextView text;
private CustomEmojiSpan[] emojiSpans;
public Holder(Activity activity, ViewGroup parent){
super(activity, R.layout.display_item_text, parent);
text=findViewById(R.id.text);
}
@Override
public void onBind(TextStatusDisplayItem item){
text.setText(item.text);
if(emojiSpans!=null){
for(CustomEmojiSpan span:emojiSpans){
span.setDrawable(null);
}
}
if(item.text instanceof Spanned)
emojiSpans=((Spanned) item.text).getSpans(0, item.text.length(), CustomEmojiSpan.class);
else
emojiSpans=new CustomEmojiSpan[0];
text.setInvalidateOnEveryFrame(false);
}
@Override
public void setImage(int index, Drawable image){
emojiSpans[index].setDrawable(image);
text.invalidate();
if(image instanceof Animatable){
((Animatable) image).start();
if(image instanceof MovieDrawable)
text.setInvalidateOnEveryFrame(true);
}
}
@Override
public void clearImage(int index){
emojiSpans[index].setDrawable(null);
text.invalidate();
}
}
}

View File

@ -0,0 +1,118 @@
package org.joinmastodon.android.ui.text;
import android.graphics.Canvas;
import android.graphics.CornerPathEffect;
import android.graphics.Paint;
import android.graphics.Path;
import android.graphics.Rect;
import android.graphics.RectF;
import android.text.Layout;
import android.text.Spanned;
import android.view.MotionEvent;
import android.view.SoundEffectConstants;
import android.widget.TextView;
import me.grishka.appkit.utils.V;
public class ClickableLinksDelegate {
private Paint hlPaint;
private Path hlPath;
private LinkSpan selectedSpan;
private TextView view;
public ClickableLinksDelegate(TextView view) {
this.view=view;
hlPaint=new Paint();
hlPaint.setAntiAlias(true);
hlPaint.setPathEffect(new CornerPathEffect(V.dp(3)));
// view.setHighlightColor(view.getResources().getColor(android.R.color.holo_blue_light));
}
public boolean onTouch(MotionEvent event) {
if(event.getAction()==MotionEvent.ACTION_DOWN){
int line=-1;
Rect rect=new Rect();
Layout l=view.getLayout();
for(int i=0;i<l.getLineCount();i++){
view.getLineBounds(i, rect);
if(rect.contains((int)event.getX(), (int)event.getY())){
line=i;
break;
}
}
if(line==-1){
return false;
}
CharSequence text=view.getText();
if(text instanceof Spanned){
Spanned s=(Spanned)text;
LinkSpan[] spans=s.getSpans(0, s.length()-1, LinkSpan.class);
if(spans.length>0){
for(LinkSpan span:spans){
int start=s.getSpanStart(span);
int end=s.getSpanEnd(span);
int lstart=l.getLineForOffset(start);
int lend=l.getLineForOffset(end);
if(line>=lstart && line<=lend){
if(line==lstart && event.getX()-view.getPaddingLeft()<l.getPrimaryHorizontal(start)){
continue;
}
if(line==lend && event.getX()-view.getPaddingLeft()>l.getPrimaryHorizontal(end)){
continue;
}
hlPath=new Path();
selectedSpan=span;
hlPaint.setColor((span.getColor() & 0x00FFFFFF) | 0x33000000);
//l.getSelectionPath(start, end, hlPath);
for(int j=lstart;j<=lend;j++){
Rect bounds=new Rect();
l.getLineBounds(j, bounds);
//bounds.left+=view.getPaddingLeft();
if(j==lstart){
bounds.left=Math.round(l.getPrimaryHorizontal(start));
}
if(j==lend){
bounds.right=Math.round(l.getPrimaryHorizontal(end));
}else{
CharSequence lineChars=view.getText().subSequence(l.getLineStart(j), l.getLineEnd(j));
bounds.right=Math.round(view.getPaint().measureText(lineChars.toString()))/*+view.getPaddingRight()*/;
}
bounds.inset(V.dp(-2), V.dp(-2));
hlPath.addRect(new RectF(bounds), Path.Direction.CW);
}
hlPath.offset(view.getPaddingLeft(), 0);
view.invalidate();
return true;
}
}
}
}
}
if(event.getAction()==MotionEvent.ACTION_UP && selectedSpan!=null){
view.playSoundEffect(SoundEffectConstants.CLICK);
selectedSpan.onClick(view.getContext());
hlPath=null;
selectedSpan=null;
view.invalidate();
return false;
}
if(event.getAction()==MotionEvent.ACTION_CANCEL){
hlPath=null;
selectedSpan=null;
view.invalidate();
return false;
}
return false;
}
public void onDraw(Canvas canvas){
if(hlPath!=null){
canvas.save();
canvas.translate(0, view.getPaddingTop());
canvas.drawPath(hlPath, hlPaint);
canvas.restore();
}
}
}

View File

@ -0,0 +1,51 @@
package org.joinmastodon.android.ui.text;
import android.graphics.Canvas;
import android.graphics.Paint;
import android.graphics.Rect;
import android.graphics.drawable.Drawable;
import android.text.style.ReplacementSpan;
import org.joinmastodon.android.model.Emoji;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
public class CustomEmojiSpan extends ReplacementSpan{
public final Emoji emoji;
private Drawable drawable;
public CustomEmojiSpan(Emoji emoji){
this.emoji=emoji;
}
@Override
public int getSize(@NonNull Paint paint, CharSequence text, int start, int end, @Nullable Paint.FontMetricsInt fm){
return Math.round(paint.descent()-paint.ascent());
}
@Override
public void draw(@NonNull Canvas canvas, CharSequence text, int start, int end, float x, int top, int y, int bottom, @NonNull Paint paint){
int size=Math.round(paint.descent()-paint.ascent());
if(drawable==null){
canvas.drawRect(x, top, x+size, top+size, paint);
}else{
// AnimatedImageDrawable doesn't like when its bounds don't start at (0, 0)
Rect bounds=drawable.getBounds();
int dw=drawable.getIntrinsicWidth();
int dh=drawable.getIntrinsicHeight();
if(bounds.left!=0 || bounds.top!=0 || bounds.right!=dw || bounds.left!=dh){
drawable.setBounds(0, 0, dw, dh);
}
canvas.save();
canvas.translate(x, top);
canvas.scale(size/(float)dw, size/(float)dh, 0f, 0f);
drawable.draw(canvas);
canvas.restore();
}
}
public void setDrawable(Drawable drawable){
this.drawable=drawable;
}
}

View File

@ -0,0 +1,120 @@
package org.joinmastodon.android.ui.text;
import android.text.SpannableStringBuilder;
import android.text.Spanned;
import org.joinmastodon.android.model.Emoji;
import org.jsoup.Jsoup;
import org.jsoup.nodes.Element;
import org.jsoup.nodes.Node;
import org.jsoup.nodes.TextNode;
import org.jsoup.select.NodeVisitor;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import androidx.annotation.NonNull;
public class HtmlParser{
private static final String TAG="HtmlParser";
private static Pattern EMOJI_CODE_PATTERN=Pattern.compile(":([\\w]+):");
private HtmlParser(){}
/**
* Parse HTML and custom emoji into a spanned string for display.
* Supported tags: <ul>
* <li>&lt;a class="hashtag | mention | (none)"></li>
* <li>&lt;span class="invisible | ellipsis"></li>
* <li>&lt;br/></li>
* <li>&lt;p></li>
* </ul>
* @param source Source HTML
* @param emojis Custom emojis that are present in source as <code>:code:</code>
* @return a spanned string
*/
public static SpannableStringBuilder parse(String source, List<Emoji> emojis){
class SpanInfo{
public Object span;
public int start;
public Element element;
public SpanInfo(Object span, int start, Element element){
this.span=span;
this.start=start;
this.element=element;
}
}
final SpannableStringBuilder ssb=new SpannableStringBuilder();
Jsoup.parseBodyFragment(source).body().traverse(new NodeVisitor(){
private final ArrayList<SpanInfo> openSpans=new ArrayList<>();
@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;
switch(el.nodeName()){
case "a" -> {
LinkSpan.Type linkType;
if(el.hasClass("hashtag")){
linkType=LinkSpan.Type.HASHTAG;
}else if(el.hasClass("mention")){
linkType=LinkSpan.Type.MENTION;
}else{
linkType=LinkSpan.Type.URL;
}
openSpans.add(new SpanInfo(new LinkSpan(el.attr("href"), null, linkType), ssb.length(), el));
}
case "br" -> ssb.append('\n');
case "span" -> {
if(el.hasClass("invisible")){
openSpans.add(new SpanInfo(new InvisibleSpan(), ssb.length(), el));
}
}
}
}
}
@Override
public void tail(@NonNull Node node, int depth){
if(node instanceof Element){
Element el=(Element)node;
if("span".equals(el.nodeName()) && el.hasClass("ellipsis")){
ssb.append('…');
}else if("p".equals(el.nodeName())){
if(node.nextSibling()!=null)
ssb.append("\n\n");
}else if(!openSpans.isEmpty()){
SpanInfo si=openSpans.get(openSpans.size()-1);
if(si.element==el){
ssb.setSpan(si.span, si.start, ssb.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
openSpans.remove(openSpans.size()-1);
}
}
}
}
});
if(!emojis.isEmpty())
parseCustomEmoji(ssb, emojis);
return ssb;
}
public static void parseCustomEmoji(SpannableStringBuilder ssb, List<Emoji> emojis){
Map<String, Emoji> emojiByCode=emojis.stream().collect(Collectors.toMap(e->e.shortcode, Function.identity()));
Matcher matcher=EMOJI_CODE_PATTERN.matcher(ssb);
while(matcher.find()){
Emoji emoji=emojiByCode.get(matcher.group(1));
if(emoji==null)
continue;
ssb.setSpan(new CustomEmojiSpan(emoji), matcher.start(), matcher.end(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
}
}
}

View File

@ -0,0 +1,20 @@
package org.joinmastodon.android.ui.text;
import android.graphics.Canvas;
import android.graphics.Paint;
import android.text.style.ReplacementSpan;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
public class InvisibleSpan extends ReplacementSpan{
@Override
public int getSize(@NonNull Paint paint, CharSequence text, int start, int end, @Nullable Paint.FontMetricsInt fm){
return 0;
}
@Override
public void draw(@NonNull Canvas canvas, CharSequence text, int start, int end, float x, int top, int y, int bottom, @NonNull Paint paint){
}
}

View File

@ -0,0 +1,59 @@
package org.joinmastodon.android.ui.text;
import android.content.Context;
import android.text.TextPaint;
import android.text.style.CharacterStyle;
public class LinkSpan extends CharacterStyle {
private int color=0xFF569ace;
private OnLinkClickListener listener;
private String link;
private Type type;
public LinkSpan(String link, OnLinkClickListener listener, Type type) {
this.listener=listener;
this.link=link;
this.type=type;
}
public void setColor(int c){
color=c;
}
public int getColor(){
return color;
}
@Override
public void updateDrawState(TextPaint tp) {
tp.setColor(color);
}
public void onClick(Context context){
if(listener!=null)
listener.onLinkClick(this);
}
public String getLink(){
return link;
}
public Type getType(){
return type;
}
public void setListener(OnLinkClickListener listener){
this.listener=listener;
}
public interface OnLinkClickListener{
void onLinkClick(LinkSpan span);
}
public enum Type{
URL,
MENTION,
HASHTAG
}
}

View File

@ -0,0 +1,145 @@
package org.joinmastodon.android.ui.utils;
import android.graphics.Bitmap;
import android.util.SparseArray;
/**
* https://github.com/woltapp/blurhash/blob/master/Kotlin/lib/src/main/java/com/wolt/blurhashkt/BlurHashDecoder.kt
* but rewritten in a language that doesn't suck
*/
public class BlurHashDecoder{
private BlurHashDecoder(){}
// cache Math.cos() calculations to improve performance.
// The number of calculations can be huge for many bitmaps: width * height * numCompX * numCompY * 2 * nBitmaps
// the cache is enabled by default, it is recommended to disable it only when just a few images are displayed
private static SparseArray<double[]> cacheCosinesX=new SparseArray<>();
private static SparseArray<double[]> cacheCosinesY=new SparseArray<>();
private static final String CHAR_MAP="0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~";
/**
* Clear calculations stored in memory cache.
* The cache is not big, but will increase when many image sizes are used,
* if the app needs memory it is recommended to clear it.
*/
public static void clearCache(){
cacheCosinesX.clear();
cacheCosinesY.clear();
}
public static Bitmap decode(String blurHash, int width, int height){
return decode(blurHash, width, height, 1f, true);
}
/**
* Decode a blur hash into a new bitmap.
*
* @param useCache use in memory cache for the calculated math, reused by images with same size.
* if the cache does not exist yet it will be created and populated with new calculations.
* By default it is true.
*/
public static Bitmap decode(String blurHash, int width, int height, float punch, boolean useCache){
if(blurHash==null || blurHash.length()<6)
return null;
int numCompEnc=decode83(blurHash, 0, 1);
int numCompX=(numCompEnc%9)+1;
int numCompY=(numCompEnc/9)+1;
if(blurHash.length()!=4+2*numCompX*numCompY)
return null;
int maxAcEnc=decode83(blurHash, 1, 2);
float maxAc=(maxAcEnc+1)/166f;
float[][] colors=new float[numCompX*numCompY][];
colors[0]=decodeDc(decode83(blurHash, 2, 6));
for(int i=1;i<colors.length;i++){
int from=4+i*2;
int colorEnc=decode83(blurHash, from, from+2);
colors[i]=decodeAc(colorEnc, maxAc*punch);
}
return composeBitmap(width, height, numCompX, numCompY, colors, useCache);
}
private static int decode83(String str, int from, int to){
int result=0;
for(int i=from;i<to;i++){
int index=CHAR_MAP.indexOf(str.charAt(i));
if(index!=-1)
result=result*83+index;
}
return result;
}
private static float[] decodeDc(int colorEnc){
int r=colorEnc >> 16;
int g=(colorEnc >> 8) & 255;
int b=colorEnc & 255;
return new float[]{srgbToLinear(r), srgbToLinear(g), srgbToLinear(b)};
}
private static float srgbToLinear(int colorEnc){
float v=colorEnc/255f;
return v<=0.4045f ? (v/12.92f) : (float)Math.pow((v + 0.055f) / 1.055f, 2.4f);
}
private static float[] decodeAc(int value, float maxAc){
int r=value/(19*19);
int g=(value/19)%19;
int b=value%19;
return new float[]{signedPow2((r-9)/9f)*maxAc, signedPow2((g-9)/9f)*maxAc, signedPow2((b-9)/9f)*maxAc};
}
private static float signedPow2(float value){
return value*value*Math.signum(value);
}
private static Bitmap composeBitmap(int width, int height, int numCompX, int numCompY, float[][] colors, boolean useCache){
// use an array for better performance when writing pixel colors
int[] imageArray=new int[width*height];
boolean calculateCosX=!useCache || cacheCosinesX.get(width*numCompX)==null;
double[] cosinesX=getArrayForCosinesX(calculateCosX, width, numCompX);
boolean calculateCosY=!useCache || cacheCosinesY.get(height*numCompY)==null;
double[] cosinesY=getArrayForCosinesY(calculateCosY, height, numCompY);
for(int y=0;y<height;y++){
for(int x=0;x<width;x++){
float r=0f, g=0f, b=0f;
for(int j=0;j<numCompY;j++){
for(int i=0;i<numCompX;i++){
double cosX=calculateCosX ? (cosinesX[i+numCompX*x]=Math.cos(Math.PI*x*i/width)) : cosinesX[i+numCompX*x];
double cosY=calculateCosY ? (cosinesY[j+numCompY*y]=Math.cos(Math.PI*y*j/height)) : cosinesY[j+numCompY*y];
float basis=(float)(cosX*cosY);
float[] color=colors[j*numCompX+i];
r+=color[0]*basis;
g+=color[1]*basis;
b+=color[2]*basis;
}
}
imageArray[x+width*y]=0xFF000000 | linearToSrgb(b) | (linearToSrgb(g) << 8) | (linearToSrgb(r) << 16);
}
}
return Bitmap.createBitmap(imageArray, width, height, Bitmap.Config.ARGB_8888);
}
private static double[] getArrayForCosinesY(boolean calculate, int height, int numCompY){
if(calculate){
double[] res=new double[height*numCompY];
cacheCosinesY.put(height*numCompY, res);
return res;
}else{
return cacheCosinesY.get(height*numCompY);
}
}
private static double[] getArrayForCosinesX(boolean calculate, int width, int numCompX){
if(calculate){
double[] res=new double[width*numCompX];
cacheCosinesX.put(width*numCompX, res);
return res;
}else{
return cacheCosinesX.get(width*numCompX);
}
}
private static int linearToSrgb(float value){
float v=Math.max(0f, Math.min(1f, value));
return v<=0.0031308f ? (int)(v * 12.92f * 255f + 0.5f) : (int)((1.055f * (float)Math.pow(v, 1 / 2.4f) - 0.055f) * 255 + 0.5f);
}
}

View File

@ -0,0 +1,53 @@
package org.joinmastodon.android.ui.utils;
import android.graphics.Bitmap;
import android.graphics.Canvas;
import android.graphics.ColorFilter;
import android.graphics.Paint;
import android.graphics.PixelFormat;
import android.graphics.drawable.Drawable;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
public class BlurHashDrawable extends Drawable{
private final Bitmap bitmap;
private final int width, height;
private static final Paint PAINT=new Paint(Paint.FILTER_BITMAP_FLAG);
public BlurHashDrawable(Bitmap bitmap, int width, int height){
this.bitmap=bitmap;
this.width=width>0 ? width : bitmap.getWidth();
this.height=height>0 ? height : bitmap.getHeight();
}
@Override
public void draw(@NonNull Canvas canvas){
canvas.drawBitmap(bitmap, null, getBounds(), PAINT);
}
@Override
public void setAlpha(int alpha){
}
@Override
public void setColorFilter(@Nullable ColorFilter colorFilter){
}
@Override
public int getOpacity(){
return PixelFormat.OPAQUE;
}
@Override
public int getIntrinsicWidth(){
return width;
}
@Override
public int getIntrinsicHeight(){
return height;
}
}

View File

@ -0,0 +1,17 @@
package org.joinmastodon.android.ui.utils;
import android.content.Context;
import android.net.Uri;
import androidx.browser.customtabs.CustomTabsIntent;
public class UiUtils{
private UiUtils(){}
public static void launchWebBrowser(Context context, String url){
// TODO setting for custom tabs
new CustomTabsIntent.Builder()
.build()
.launchUrl(context, Uri.parse(url));
}
}

View File

@ -0,0 +1,50 @@
package org.joinmastodon.android.ui.views;
import android.content.Context;
import android.graphics.Canvas;
import android.util.AttributeSet;
import android.view.MotionEvent;
import android.widget.TextView;
import org.joinmastodon.android.ui.text.ClickableLinksDelegate;
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
}
public LinkedTextView(Context context, AttributeSet attrs) {
super(context, attrs);
// TODO Auto-generated constructor stub
}
public LinkedTextView(Context context, AttributeSet attrs, int defStyle) {
super(context, attrs, defStyle);
// TODO Auto-generated constructor stub
}
public boolean onTouchEvent(MotionEvent ev){
if(delegate.onTouch(ev)) return true;
return super.onTouchEvent(ev);
}
public void onDraw(Canvas c){
super.onDraw(c);
delegate.onDraw(c);
if(needInvalidate)
invalidate();
}
// a hack to support animated emoji on <9.0
public void setInvalidateOnEveryFrame(boolean invalidate){
needInvalidate=invalidate;
if(invalidate)
invalidate();
}
}

View File

@ -0,0 +1,34 @@
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout 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">
<ImageView
android:id="@+id/avatar"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_marginEnd="8dp"
android:layout_alignParentStart="true"
android:layout_alignParentTop="true"/>
<TextView
android:id="@+id/name"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_toEndOf="@id/avatar"
android:textStyle="bold"
android:singleLine="true"
android:ellipsize="end"
tools:text="Eugen"/>
<TextView
android:id="@+id/subtitle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_toEndOf="@id/avatar"
android:layout_alignBottom="@id/avatar"
tools:text="\@Gargron . 1d"/>
</RelativeLayout>

View File

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<ImageView
android:id="@+id/photo"
android:layout_width="match_parent"
android:layout_height="250dp"
android:scaleType="centerCrop"/>
</FrameLayout>

View File

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="8dp">
<TextView
android:id="@+id/text"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:singleLine="true"
android:ellipsize="end"/>
</FrameLayout>

View File

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="8dp">
<org.joinmastodon.android.ui.views.LinkedTextView
android:id="@+id/text"
android:layout_width="match_parent"
android:layout_height="wrap_content"/>
</FrameLayout>

View File

@ -10,4 +10,6 @@
<string name="ok">OK</string>
<string name="preparing_auth">Preparing for authentication…</string>
<string name="finishing_auth">Finishing authentication…</string>
<string name="user_boosted">%s boosted</string>
<string name="replied_to">Replied to %s</string>
</resources>

View File

@ -3,6 +3,7 @@ dependencyResolutionManagement {
repositories {
google()
mavenCentral()
mavenLocal()
}
}
rootProject.name = "Mastodon"