diff --git a/app/build.gradle b/app/build.gradle index 414114b4..f3ab8750 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -76,6 +76,7 @@ dependencies { implementation "com.google.code.gson:gson:2.8.6" implementation 'com.squareup.retrofit2:retrofit:2.9.0' implementation 'com.squareup.retrofit2:converter-gson:2.9.0' + implementation 'com.squareup.retrofit2:converter-simplexml:2.9.0' implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0' implementation 'androidx.preference:preference:1.2.0' implementation "org.conscrypt:conscrypt-android:2.5.2" diff --git a/app/src/main/java/app/fedilab/android/client/endpoints/MastodonTimelinesService.java b/app/src/main/java/app/fedilab/android/client/endpoints/MastodonTimelinesService.java index 5602c02a..de296828 100644 --- a/app/src/main/java/app/fedilab/android/client/endpoints/MastodonTimelinesService.java +++ b/app/src/main/java/app/fedilab/android/client/endpoints/MastodonTimelinesService.java @@ -22,6 +22,8 @@ import app.fedilab.android.client.entities.api.Marker; import app.fedilab.android.client.entities.api.MastodonList; import app.fedilab.android.client.entities.api.Status; import app.fedilab.android.client.entities.misskey.MisskeyNote; +import app.fedilab.android.client.entities.nitter.Nitter; +import app.fedilab.android.client.entities.nitter.NitterAccount; import app.fedilab.android.client.entities.peertube.PeertubeVideo; import retrofit2.Call; import retrofit2.http.Body; @@ -224,6 +226,17 @@ public interface MastodonTimelinesService { @Query("count") int count ); + @GET("{names}/rss") + Call getNitter( + @Path("names") String id, + @Query("max_position") String max_position + ); + + @GET("{account}/rss") + Call getNitterAccount( + @Path("account") String account + ); + @GET("api/v1/videos/{id}") Call getPeertubeVideo( @Path("id") String id diff --git a/app/src/main/java/app/fedilab/android/client/entities/nitter/Nitter.java b/app/src/main/java/app/fedilab/android/client/entities/nitter/Nitter.java new file mode 100644 index 00000000..5dc87f84 --- /dev/null +++ b/app/src/main/java/app/fedilab/android/client/entities/nitter/Nitter.java @@ -0,0 +1,136 @@ +package app.fedilab.android.client.entities.nitter; + + +import android.content.Context; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; + +import org.simpleframework.xml.Element; +import org.simpleframework.xml.ElementList; +import org.simpleframework.xml.Root; + +import java.io.Serializable; +import java.util.ArrayList; +import java.util.Date; +import java.util.HashMap; +import java.util.List; +import java.util.concurrent.TimeUnit; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import app.fedilab.android.client.endpoints.MastodonTimelinesService; +import app.fedilab.android.client.entities.api.Attachment; +import app.fedilab.android.client.entities.api.Status; +import app.fedilab.android.helper.Helper; +import okhttp3.OkHttpClient; +import retrofit2.Call; +import retrofit2.Response; +import retrofit2.Retrofit; +import retrofit2.converter.gson.GsonConverterFactory; +import retrofit2.converter.simplexml.SimpleXmlConverterFactory; + +@Root(name = "rss", strict = false) +public class Nitter implements Serializable { + + + public static HashMap accounts = new HashMap<>(); + @Element(name = "channel") + public Channel channel; + + public static MastodonTimelinesService initInstanceXMLOnly(Context context, String instance) { + Gson gson = new GsonBuilder().setDateFormat("EEE, dd MMM yyyy HH:mm:ss zzz").create(); + OkHttpClient okHttpClient = new OkHttpClient.Builder() + .readTimeout(60, TimeUnit.SECONDS) + .connectTimeout(60, TimeUnit.SECONDS) + .callTimeout(60, TimeUnit.SECONDS) + .proxy(Helper.getProxy(context)) + .build(); + Retrofit retrofit = new Retrofit.Builder() + .baseUrl("https://" + instance) + .addConverterFactory(GsonConverterFactory.create(gson)) + .addConverterFactory(SimpleXmlConverterFactory.create()) + .client(okHttpClient) + .build(); + return retrofit.create(MastodonTimelinesService.class); + } + + public static Status convert(Context context, String instance, FeedItem feedItem) { + Status status = new Status(); + status.id = feedItem.pubDate.toString(); + status.content = feedItem.title; + status.text = feedItem.title; + status.visibility = "public"; + status.created_at = feedItem.pubDate; + status.uri = feedItem.guid; + status.url = feedItem.link; + if (feedItem.creator != null && !accounts.containsValue(feedItem.creator)) { + MastodonTimelinesService mastodonTimelinesService = initInstanceXMLOnly(context, instance); + Call accountCall = mastodonTimelinesService.getNitterAccount(instance); + if (accountCall != null) { + try { + Response publicTlResponse = accountCall.execute(); + if (publicTlResponse.isSuccessful()) { + NitterAccount nitterAccount = publicTlResponse.body(); + accounts.put(feedItem.creator, nitterAccount); + } + } catch (Exception e) { + e.printStackTrace(); + } + } + } + NitterAccount nitterAccount = accounts.get(feedItem.creator); + if (nitterAccount != null) { + app.fedilab.android.client.entities.api.Account account = new app.fedilab.android.client.entities.api.Account(); + String[] names = nitterAccount.channel.image.title.split("/"); + account.id = feedItem.guid; + account.acct = names[1]; + account.username = names[1]; + account.display_name = names[0]; + account.avatar = nitterAccount.channel.image.url; + account.avatar_static = nitterAccount.channel.image.url; + account.url = nitterAccount.channel.image.link; + status.account = account; + } + + Pattern imgPattern = Pattern.compile("]*src=\"([^\"]+)\"[^>]*>"); + Matcher matcher = imgPattern.matcher(feedItem.description); + String description = feedItem.description; + ArrayList attachmentList = new ArrayList<>(); + while (matcher.find()) { + description = description.replaceAll(Pattern.quote(matcher.group()), ""); + Attachment attachment = new Attachment(); + attachment.type = "image"; + attachment.url = matcher.group(1); + attachment.preview_url = matcher.group(1); + attachment.id = matcher.group(1); + attachmentList.add(attachment); + } + status.media_attachments = attachmentList; + + return status; + } + + @Root(name = "channel", strict = false) + public static class Channel implements Serializable { + @ElementList(name = "item") + public List mFeedItems; + } + + @Root(name = "item", strict = false) + public static class FeedItem implements Serializable { + @ElementList(name = "dc:creator", required = false) + public String creator; + @ElementList(name = "title") + public String title; + @ElementList(name = "description", required = false) + public String description; + @ElementList(name = "pubDate") + public Date pubDate; + @ElementList(name = "guid", required = false) + public String guid; + @ElementList(name = "link", required = false) + public String link; + } + +} diff --git a/app/src/main/java/app/fedilab/android/client/entities/nitter/NitterAccount.java b/app/src/main/java/app/fedilab/android/client/entities/nitter/NitterAccount.java new file mode 100644 index 00000000..b17fbf38 --- /dev/null +++ b/app/src/main/java/app/fedilab/android/client/entities/nitter/NitterAccount.java @@ -0,0 +1,36 @@ +package app.fedilab.android.client.entities.nitter; + + +import org.simpleframework.xml.Element; +import org.simpleframework.xml.Root; + +import java.io.Serializable; +import java.util.HashMap; + +@Root(name = "rss", strict = false) +public class NitterAccount implements Serializable { + + + public static HashMap accounts = new HashMap<>(); + @Element(name = "channel") + public Channel channel; + + @Root(name = "channel", strict = false) + public static class Channel implements Serializable { + @Element(name = "image") + public Image image; + + } + + @Root(name = "image", strict = false) + public static class Image implements Serializable { + @Element(name = "title") + public String title; + @Element(name = "url") + public String url; + @Element(name = "link") + public String link; + } + + +} diff --git a/app/src/main/java/app/fedilab/android/ui/drawer/StatusAdapter.java b/app/src/main/java/app/fedilab/android/ui/drawer/StatusAdapter.java index a8e0c650..88f60ab6 100644 --- a/app/src/main/java/app/fedilab/android/ui/drawer/StatusAdapter.java +++ b/app/src/main/java/app/fedilab/android/ui/drawer/StatusAdapter.java @@ -447,7 +447,7 @@ public class StatusAdapter extends RecyclerView.Adapter attachment.peertubeId = matcherLink.group(3); attachmentList.add(attachment); statusToDeal.media_attachments = attachmentList; - adapter.notifyItemChanged(getPositionAsync(notificationList, statusList, statusToDeal)); + //adapter.notifyItemChanged(getPositionAsync(notificationList, statusList, statusToDeal)); } } diff --git a/app/src/main/java/app/fedilab/android/ui/fragment/timeline/FragmentMastodonTimeline.java b/app/src/main/java/app/fedilab/android/ui/fragment/timeline/FragmentMastodonTimeline.java index 3e041c0a..7eb49695 100644 --- a/app/src/main/java/app/fedilab/android/ui/fragment/timeline/FragmentMastodonTimeline.java +++ b/app/src/main/java/app/fedilab/android/ui/fragment/timeline/FragmentMastodonTimeline.java @@ -21,6 +21,7 @@ import android.content.BroadcastReceiver; import android.content.Context; import android.content.Intent; import android.content.IntentFilter; +import android.content.SharedPreferences; import android.os.Bundle; import android.os.Handler; import android.os.Looper; @@ -33,6 +34,7 @@ import androidx.annotation.Nullable; import androidx.fragment.app.Fragment; import androidx.lifecycle.ViewModelProvider; import androidx.localbroadcastmanager.content.LocalBroadcastManager; +import androidx.preference.PreferenceManager; import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.RecyclerView; @@ -204,7 +206,12 @@ public class FragmentMastodonTimeline extends Fragment implements StatusAdapter. searchCache = getArguments().getString(Helper.ARG_SEARCH_KEYWORD_CACHE, null); pinnedTimeline = (PinnedTimeline) getArguments().getSerializable(Helper.ARG_REMOTE_INSTANCE); if (pinnedTimeline != null && pinnedTimeline.remoteInstance != null) { - remoteInstance = pinnedTimeline.remoteInstance.host; + if (pinnedTimeline.remoteInstance.type != RemoteInstance.InstanceType.NITTER) { + remoteInstance = pinnedTimeline.remoteInstance.host; + } else { + SharedPreferences sharedpreferences = PreferenceManager.getDefaultSharedPreferences(requireActivity()); + remoteInstance = sharedpreferences.getString(getString(R.string.SET_NITTER_HOST), getString(R.string.DEFAULT_NITTER_HOST)).toLowerCase(); + } } tagTimeline = (TagTimeline) getArguments().getSerializable(Helper.ARG_TAG_TIMELINE); @@ -642,7 +649,24 @@ public class FragmentMastodonTimeline extends Fragment implements StatusAdapter. //NITTER TIMELINES if (pinnedTimeline != null && pinnedTimeline.remoteInstance.type == RemoteInstance.InstanceType.NITTER) { - + if (direction == null) { + timelinesVM.getNitter(remoteInstance, pinnedTimeline.remoteInstance.host, null) + .observe(getViewLifecycleOwner(), this::initializeStatusesCommonView); + } else if (direction == DIRECTION.BOTTOM) { + timelinesVM.getNitter(remoteInstance, pinnedTimeline.remoteInstance.host, max_id) + .observe(getViewLifecycleOwner(), statusesBottom -> dealWithPagination(statusesBottom, DIRECTION.BOTTOM, false)); + } else if (direction == DIRECTION.TOP) { + flagLoading = false; + } else if (direction == DIRECTION.REFRESH) { + timelinesVM.getNitter(remoteInstance, pinnedTimeline.remoteInstance.host, null) + .observe(getViewLifecycleOwner(), statusesRefresh -> { + if (statusAdapter != null) { + dealWithPagination(statusesRefresh, DIRECTION.REFRESH, true); + } else { + initializeStatusesCommonView(statusesRefresh); + } + }); + } } //GNU TIMELINES else if (pinnedTimeline != null && pinnedTimeline.remoteInstance.type == RemoteInstance.InstanceType.GNU) { diff --git a/app/src/main/java/app/fedilab/android/viewmodel/mastodon/TimelinesVM.java b/app/src/main/java/app/fedilab/android/viewmodel/mastodon/TimelinesVM.java index 55407114..8b35c02a 100644 --- a/app/src/main/java/app/fedilab/android/viewmodel/mastodon/TimelinesVM.java +++ b/app/src/main/java/app/fedilab/android/viewmodel/mastodon/TimelinesVM.java @@ -45,6 +45,7 @@ import app.fedilab.android.client.entities.app.BaseAccount; import app.fedilab.android.client.entities.app.StatusCache; import app.fedilab.android.client.entities.app.StatusDraft; import app.fedilab.android.client.entities.misskey.MisskeyNote; +import app.fedilab.android.client.entities.nitter.Nitter; import app.fedilab.android.client.entities.peertube.PeertubeVideo; import app.fedilab.android.exception.DBException; import app.fedilab.android.helper.Helper; @@ -56,6 +57,7 @@ import retrofit2.Call; import retrofit2.Response; import retrofit2.Retrofit; import retrofit2.converter.gson.GsonConverterFactory; +import retrofit2.converter.simplexml.SimpleXmlConverterFactory; public class TimelinesVM extends AndroidViewModel { @@ -91,6 +93,17 @@ public class TimelinesVM extends AndroidViewModel { return retrofit.create(MastodonTimelinesService.class); } + private MastodonTimelinesService initInstanceXMLOnly(String instance) { + Gson gson = new GsonBuilder().setDateFormat("EEE, dd MMM yyyy HH:mm:ss zzz").create(); + Retrofit retrofit = new Retrofit.Builder() + .baseUrl("https://" + instance) + //.addConverterFactory(GsonConverterFactory.create(gson)) + .addConverterFactory(SimpleXmlConverterFactory.create()) + .client(okHttpClient) + .build(); + return retrofit.create(MastodonTimelinesService.class); + } + private MastodonTimelinesService init(String instance) { Gson gson = new GsonBuilder().setDateFormat("MMM dd, yyyy HH:mm:ss").create(); Retrofit retrofit = new Retrofit.Builder() @@ -147,6 +160,48 @@ public class TimelinesVM extends AndroidViewModel { } + /** + * Public timeline for Nitter + * + * @param max_position Return results older than this id + * @return {@link LiveData} containing a {@link Statuses} + */ + public LiveData getNitter(@NonNull String instance, + String accountsStr, + String max_position) { + MastodonTimelinesService mastodonTimelinesService = initInstanceXMLOnly(instance); + statusesMutableLiveData = new MutableLiveData<>(); + new Thread(() -> { + Call publicTlCall = mastodonTimelinesService.getNitter(accountsStr, max_position); + Statuses statuses = new Statuses(); + if (publicTlCall != null) { + try { + Response publicTlResponse = publicTlCall.execute(); + if (publicTlResponse.isSuccessful()) { + Nitter rssResponse = publicTlResponse.body(); + List statusList = new ArrayList<>(); + if (rssResponse != null && rssResponse.channel != null && rssResponse.channel.mFeedItems != null) { + for (Nitter.FeedItem feedItem : rssResponse.channel.mFeedItems) { + Status status = Nitter.convert(getApplication(), instance, feedItem); + statusList.add(status); + } + } + List filteredStatuses = TimelineHelper.filterStatus(getApplication(), statusList, TimelineHelper.FilterTimeLineType.PUBLIC); + statuses.statuses = SpannableHelper.convertStatus(getApplication().getApplicationContext(), filteredStatuses); + statuses.pagination = MastodonHelper.getPagination(publicTlResponse.headers()); + } + + } catch (Exception e) { + e.printStackTrace(); + } + } + Handler mainHandler = new Handler(Looper.getMainLooper()); + Runnable myRunnable = () -> statusesMutableLiveData.setValue(statuses); + mainHandler.post(myRunnable); + }).start(); + return statusesMutableLiveData; + } + /** * Public timeline for Misskey *