Basic status rendering
This commit is contained in:
parent
42d5f52ff5
commit
dfbc1fd2e2
|
@ -32,7 +32,8 @@ dependencies {
|
||||||
implementation 'me.grishka.litex:recyclerview:1.2.1'
|
implementation 'me.grishka.litex:recyclerview:1.2.1'
|
||||||
implementation 'me.grishka.litex:swiperefreshlayout:1.1.0'
|
implementation 'me.grishka.litex:swiperefreshlayout:1.1.0'
|
||||||
implementation 'me.grishka.litex:browser:1.4.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 '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'
|
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:1.1.5'
|
||||||
}
|
}
|
|
@ -9,7 +9,8 @@
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
android:supportsRtl="true"
|
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">
|
<activity android:name=".MainActivity" android:exported="true" android:configChanges="orientation|screenSize">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
|
|
|
@ -4,6 +4,9 @@ import android.annotation.SuppressLint;
|
||||||
import android.app.Application;
|
import android.app.Application;
|
||||||
import android.content.Context;
|
import android.content.Context;
|
||||||
|
|
||||||
|
import me.grishka.appkit.imageloader.ImageCache;
|
||||||
|
import me.grishka.appkit.utils.NetworkUtils;
|
||||||
|
|
||||||
public class MastodonApp extends Application{
|
public class MastodonApp extends Application{
|
||||||
|
|
||||||
@SuppressLint("StaticFieldLeak") // it's not a leak
|
@SuppressLint("StaticFieldLeak") // it's not a leak
|
||||||
|
@ -12,6 +15,11 @@ public class MastodonApp extends Application{
|
||||||
@Override
|
@Override
|
||||||
public void onCreate(){
|
public void onCreate(){
|
||||||
super.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();
|
context=getApplicationContext();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -60,6 +60,8 @@ public class MastodonAPIController{
|
||||||
public <T> void submitRequest(final MastodonAPIRequest<T> req){
|
public <T> void submitRequest(final MastodonAPIRequest<T> req){
|
||||||
thread.postRunnable(()->{
|
thread.postRunnable(()->{
|
||||||
try{
|
try{
|
||||||
|
if(req.canceled)
|
||||||
|
return;
|
||||||
Request.Builder builder=new Request.Builder()
|
Request.Builder builder=new Request.Builder()
|
||||||
.url(req.getURL().toString())
|
.url(req.getURL().toString())
|
||||||
.method(req.getMethod(), req.getRequestBody())
|
.method(req.getMethod(), req.getRequestBody())
|
||||||
|
|
|
@ -32,6 +32,7 @@ public abstract class MastodonAPIRequest<T> extends APIRequest<T>{
|
||||||
TypeToken<T> respTypeToken;
|
TypeToken<T> respTypeToken;
|
||||||
Call okhttpCall;
|
Call okhttpCall;
|
||||||
Token token;
|
Token token;
|
||||||
|
boolean canceled;
|
||||||
|
|
||||||
public MastodonAPIRequest(HttpMethod method, String path, Class<T> respClass){
|
public MastodonAPIRequest(HttpMethod method, String path, Class<T> respClass){
|
||||||
this.path=path;
|
this.path=path;
|
||||||
|
@ -47,6 +48,7 @@ public abstract class MastodonAPIRequest<T> extends APIRequest<T>{
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public synchronized void cancel(){
|
public synchronized void cancel(){
|
||||||
|
canceled=true;
|
||||||
if(okhttpCall!=null){
|
if(okhttpCall!=null){
|
||||||
okhttpCall.cancel();
|
okhttpCall.cancel();
|
||||||
}
|
}
|
||||||
|
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
|
@ -159,6 +159,7 @@ public class AccountSessionManager{
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
new CustomTabsIntent.Builder()
|
new CustomTabsIntent.Builder()
|
||||||
|
.setShareState(CustomTabsIntent.SHARE_STATE_OFF)
|
||||||
.build()
|
.build()
|
||||||
.launchUrl(context, uri);
|
.launchUrl(context, uri);
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,15 +4,43 @@ import android.os.Bundle;
|
||||||
import android.view.LayoutInflater;
|
import android.view.LayoutInflater;
|
||||||
import android.view.View;
|
import android.view.View;
|
||||||
import android.view.ViewGroup;
|
import android.view.ViewGroup;
|
||||||
|
import android.widget.FrameLayout;
|
||||||
|
import android.widget.LinearLayout;
|
||||||
|
|
||||||
|
import org.joinmastodon.android.R;
|
||||||
|
|
||||||
import androidx.annotation.Nullable;
|
import androidx.annotation.Nullable;
|
||||||
import me.grishka.appkit.fragments.AppKitFragment;
|
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
|
@Nullable
|
||||||
@Override
|
@Override
|
||||||
public View onCreateContentView(LayoutInflater inflater, @Nullable ViewGroup container, Bundle savedInstanceState){
|
public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container, Bundle savedInstanceState){
|
||||||
return new View(getActivity());
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -127,7 +127,7 @@ public class InstanceCatalogFragment extends BaseRecyclerFragment<CatalogInstanc
|
||||||
group.activeUsers+=instance.lastWeekUsers;
|
group.activeUsers+=instance.lastWeekUsers;
|
||||||
}
|
}
|
||||||
return group;
|
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);
|
onDataLoaded(sortedList, false);
|
||||||
updateFilteredList();
|
updateFilteredList();
|
||||||
}
|
}
|
||||||
|
@ -299,18 +299,13 @@ public class InstanceCatalogFragment extends BaseRecyclerFragment<CatalogInstanc
|
||||||
}
|
}
|
||||||
Instance cachedInstance=instancesCache.get(domain);
|
Instance cachedInstance=instancesCache.get(domain);
|
||||||
if(cachedInstance!=null){
|
if(cachedInstance!=null){
|
||||||
boolean found=false;
|
|
||||||
for(CatalogInstance ci:filteredData){
|
for(CatalogInstance ci:filteredData){
|
||||||
if(ci.domain.equals(currentSearchQuery)){
|
if(ci.domain.equals(currentSearchQuery))
|
||||||
found=true;
|
return;
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if(!found){
|
|
||||||
CatalogInstance ci=cachedInstance.toCatalogInstance();
|
|
||||||
filteredData.add(0, ci);
|
|
||||||
adapter.notifyItemInserted(0);
|
|
||||||
}
|
}
|
||||||
|
CatalogInstance ci=cachedInstance.toCatalogInstance();
|
||||||
|
filteredData.add(0, ci);
|
||||||
|
adapter.notifyItemInserted(0);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if(loadingInstanceDomain!=null){
|
if(loadingInstanceDomain!=null){
|
||||||
|
|
|
@ -22,4 +22,13 @@ public class AccountField extends BaseModel{
|
||||||
* Timestamp of when the server verified a URL value for a rel="me” link.
|
* Timestamp of when the server verified a URL value for a rel="me” link.
|
||||||
*/
|
*/
|
||||||
public Instant verifiedAt;
|
public Instant verifiedAt;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String toString(){
|
||||||
|
return "AccountField{"+
|
||||||
|
"name='"+name+'\''+
|
||||||
|
", value='"+value+'\''+
|
||||||
|
", verifiedAt="+verifiedAt+
|
||||||
|
'}';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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+
|
||||||
|
'}';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
|
@ -30,4 +30,15 @@ public class Emoji extends BaseModel{
|
||||||
* Used for sorting custom emoji in the picker.
|
* Used for sorting custom emoji in the picker.
|
||||||
*/
|
*/
|
||||||
public String category;
|
public String category;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String toString(){
|
||||||
|
return "Emoji{"+
|
||||||
|
"shortcode='"+shortcode+'\''+
|
||||||
|
", url='"+url+'\''+
|
||||||
|
", staticUrl='"+staticUrl+'\''+
|
||||||
|
", visibleInPicker="+visibleInPicker+
|
||||||
|
", category='"+category+'\''+
|
||||||
|
'}';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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+
|
||||||
|
'}';
|
||||||
|
}
|
||||||
|
}
|
|
@ -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+
|
||||||
|
'}';
|
||||||
|
}
|
||||||
|
}
|
|
@ -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+'\''+
|
||||||
|
'}';
|
||||||
|
}
|
||||||
|
}
|
|
@ -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+
|
||||||
|
'}';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -42,4 +42,16 @@ public class Source extends BaseModel{
|
||||||
for(AccountField f:fields)
|
for(AccountField f:fields)
|
||||||
f.postprocess();
|
f.postprocess();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String toString(){
|
||||||
|
return "Source{"+
|
||||||
|
"note='"+note+'\''+
|
||||||
|
", fields="+fields+
|
||||||
|
", privacy="+privacy+
|
||||||
|
", sensitive="+sensitive+
|
||||||
|
", language='"+language+'\''+
|
||||||
|
", followRequestCount="+followRequestCount+
|
||||||
|
'}';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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+
|
||||||
|
'}';
|
||||||
|
}
|
||||||
|
}
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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,
|
||||||
|
}
|
||||||
|
}
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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><a class="hashtag | mention | (none)"></li>
|
||||||
|
* <li><span class="invisible | ellipsis"></li>
|
||||||
|
* <li><br/></li>
|
||||||
|
* <li><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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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){
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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));
|
||||||
|
}
|
||||||
|
}
|
|
@ -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();
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -10,4 +10,6 @@
|
||||||
<string name="ok">OK</string>
|
<string name="ok">OK</string>
|
||||||
<string name="preparing_auth">Preparing for authentication…</string>
|
<string name="preparing_auth">Preparing for authentication…</string>
|
||||||
<string name="finishing_auth">Finishing 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>
|
</resources>
|
|
@ -3,6 +3,7 @@ dependencyResolutionManagement {
|
||||||
repositories {
|
repositories {
|
||||||
google()
|
google()
|
||||||
mavenCentral()
|
mavenCentral()
|
||||||
|
mavenLocal()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
rootProject.name = "Mastodon"
|
rootProject.name = "Mastodon"
|
||||||
|
|
Loading…
Reference in New Issue