From 9bb15e943dd178205021a76008806a490638751b Mon Sep 17 00:00:00 2001 From: kyori19 Date: Wed, 19 Dec 2018 16:05:51 +0900 Subject: [PATCH] [streaming] Home timeline streaming --- .../com/keylesspalace/tusky/MainActivity.java | 31 +++++- .../keylesspalace/tusky/appstore/Events.kt | 3 +- .../keylesspalace/tusky/entity/StreamEvent.kt | 20 ++++ .../tusky/fragment/TimelineFragment.java | 104 +++++++++++++++++- .../tusky/repository/TimelineRepository.kt | 12 ++ .../yuito/TimelineStreamingListener.java | 62 +++++++++++ app/src/main/res/values-ja/strings.xml | 1 + app/src/main/res/values/strings.xml | 1 + app/src/main/res/xml/preferences.xml | 5 + 9 files changed, 234 insertions(+), 5 deletions(-) create mode 100644 app/src/main/java/com/keylesspalace/tusky/entity/StreamEvent.kt create mode 100644 app/src/main/java/net/accelf/yuito/TimelineStreamingListener.java diff --git a/app/src/main/java/com/keylesspalace/tusky/MainActivity.java b/app/src/main/java/com/keylesspalace/tusky/MainActivity.java index 1595fda63..ec3e8749a 100644 --- a/app/src/main/java/com/keylesspalace/tusky/MainActivity.java +++ b/app/src/main/java/com/keylesspalace/tusky/MainActivity.java @@ -16,6 +16,7 @@ package com.keylesspalace.tusky; import android.content.Intent; +import android.content.SharedPreferences; import android.graphics.Color; import android.graphics.drawable.Drawable; import android.net.Uri; @@ -25,6 +26,7 @@ import android.preference.PreferenceManager; import android.util.Log; import android.view.KeyEvent; import android.view.View; +import android.view.WindowManager; import android.widget.ImageButton; import android.widget.ImageView; import android.widget.TextView; @@ -118,6 +120,7 @@ public final class MainActivity extends BottomSheetActivity implements ActionBut private Drawer drawer; private TabLayout tabLayout; private ViewPager viewPager; + private SharedPreferences defPrefs; private int notificationTabPosition; private MainPagerAdapter adapter; @@ -204,12 +207,14 @@ public final class MainActivity extends BottomSheetActivity implements ActionBut setupTabs(showNotificationTab); + defPrefs = PreferenceManager.getDefaultSharedPreferences(this); + int pageMargin = getResources().getDimensionPixelSize(R.dimen.tab_page_margin); viewPager.setPageMargin(pageMargin); Drawable pageMarginDrawable = ThemeUtils.getDrawable(this, R.attr.tab_page_margin_drawable, R.drawable.tab_page_margin_dark); viewPager.setPageMarginDrawable(pageMarginDrawable); - if(PreferenceManager.getDefaultSharedPreferences(this).getBoolean("viewPagerOffScreenLimit", false)) { + if (defPrefs.getBoolean("viewPagerOffScreenLimit", false)) { viewPager.setOffscreenPageLimit(9); } @@ -270,6 +275,28 @@ public final class MainActivity extends BottomSheetActivity implements ActionBut } + @Override + protected void onStart() { + super.onStart(); + startStreaming(); + } + + private void startStreaming() { + if (defPrefs.getBoolean("useHTLStream", false)) { + getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); + } + } + + @Override + protected void onStop() { + super.onStop(); + stopStreaming(); + } + + private void stopStreaming() { + getWindow().clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); + } + @Override public void onBackPressed() { if (drawer != null && drawer.isDrawerOpen()) { @@ -538,6 +565,8 @@ public final class MainActivity extends BottomSheetActivity implements ActionBut SFragment.flushFilters(); accountManager.setActiveAccount(newSelectedId); + stopStreaming(); + Intent intent = new Intent(this, MainActivity.class); intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK); if (forward != null) { diff --git a/app/src/main/java/com/keylesspalace/tusky/appstore/Events.kt b/app/src/main/java/com/keylesspalace/tusky/appstore/Events.kt index 10905e287..067323d06 100644 --- a/app/src/main/java/com/keylesspalace/tusky/appstore/Events.kt +++ b/app/src/main/java/com/keylesspalace/tusky/appstore/Events.kt @@ -17,4 +17,5 @@ data class PreferenceChangedEvent(val preferenceKey: String) : Dispatchable data class MainTabsChangedEvent(val newTabs: List) : Dispatchable data class PollVoteEvent(val statusId: String, val poll: Poll) : Dispatchable data class DomainMuteEvent(val instance: String): Dispatchable -data class QuickReplyEvent(val status: Status) : Dispatchable \ No newline at end of file +data class QuickReplyEvent(val status: Status) : Dispatchable +data class StreamUpdateEvent(val status: Status, val first: Boolean) : Dispatchable \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/StreamEvent.kt b/app/src/main/java/com/keylesspalace/tusky/entity/StreamEvent.kt new file mode 100644 index 000000000..a3b68c47a --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/entity/StreamEvent.kt @@ -0,0 +1,20 @@ +package com.keylesspalace.tusky.entity + +import com.google.gson.annotations.SerializedName + +data class StreamEvent( + var event: EventType, + var payload: String +) { + + enum class EventType(val num: Int) { + UNKNOWN(0), + @SerializedName("update") + UPDATE(1), + @SerializedName("notification") + NOTIFICATION(2), + @SerializedName("delete") + DELETE(3); + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/TimelineFragment.java b/app/src/main/java/com/keylesspalace/tusky/fragment/TimelineFragment.java index 43fcb1313..6e3366121 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/TimelineFragment.java +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/TimelineFragment.java @@ -45,7 +45,9 @@ import com.keylesspalace.tusky.appstore.QuickReplyEvent; import com.keylesspalace.tusky.appstore.ReblogEvent; import com.keylesspalace.tusky.appstore.StatusComposedEvent; import com.keylesspalace.tusky.appstore.StatusDeletedEvent; +import com.keylesspalace.tusky.appstore.StreamUpdateEvent; import com.keylesspalace.tusky.appstore.UnfollowEvent; +import com.keylesspalace.tusky.db.AccountEntity; import com.keylesspalace.tusky.db.AccountManager; import com.keylesspalace.tusky.di.Injectable; import com.keylesspalace.tusky.entity.Filter; @@ -71,8 +73,11 @@ import com.keylesspalace.tusky.view.BackgroundMessageView; import com.keylesspalace.tusky.view.EndlessOnScrollListener; import com.keylesspalace.tusky.viewdata.StatusViewData; +import net.accelf.yuito.TimelineStreamingListener; + import java.io.IOException; import java.util.ArrayList; +import java.util.Arrays; import java.util.Collections; import java.util.Iterator; import java.util.List; @@ -104,6 +109,9 @@ import io.reactivex.android.schedulers.AndroidSchedulers; import kotlin.Unit; import kotlin.collections.CollectionsKt; import kotlin.jvm.functions.Function1; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.WebSocket; import retrofit2.Call; import retrofit2.Callback; import retrofit2.Response; @@ -178,6 +186,8 @@ public class TimelineFragment extends SFragment implements private boolean reduceTimelineRoading; private boolean checkMobileNetwork; + private WebSocket webSocket; + private PairedList, StatusViewData> statuses = new PairedList<>(new Function, StatusViewData>() { @Override @@ -263,6 +273,56 @@ public class TimelineFragment extends SFragment implements return rootView; } + @Override + public void onStart() { + super.onStart(); + startStreaming(); + } + + @Override + public void onStop() { + super.onStop(); + stopStreaming(); + } + + private void startStreaming() { + if (preferences.getBoolean("useHTLStream", false) && kind == Kind.HOME) { + connectWebsocket(buildStreamingUrl()); + } + } + + private String buildStreamingUrl() { + AccountEntity activeAccount = accountManager.getActiveAccount(); + if (activeAccount != null) { + return "wss://" + activeAccount.getDomain() + "/api/v1/streaming/?" + "stream=user" + "&" + "access_token" + "=" + activeAccount.getAccessToken(); + } else { + return null; + } + } + + private void connectWebsocket(String endpoint) { + if (webSocket != null) { + stopStreaming(); + } + + Request request = new Request.Builder() + .url(endpoint) + .build(); + + OkHttpClient client = new OkHttpClient.Builder() + .build(); + + webSocket = client.newWebSocket(request, new TimelineStreamingListener(eventHub)); + } + + private void stopStreaming() { + if (webSocket == null) { + return; + } + webSocket.close(1000, null); + webSocket = null; + } + private void sendInitialRequest() { if (this.kind == Kind.HOME) { this.tryCache(); @@ -535,6 +595,10 @@ public class TimelineFragment extends SFragment implements handleStatusComposeEvent(status); } else if (event instanceof PreferenceChangedEvent) { onPreferenceChanged(((PreferenceChangedEvent) event).getPreferenceKey()); + } else if (event instanceof StreamUpdateEvent) { + if (kind == Kind.HOME) { + handleStreamUpdateEvent((StreamUpdateEvent) event); + } } }); eventRegistered = true; @@ -1222,6 +1286,24 @@ public class TimelineFragment extends SFragment implements } } + private void addStatus(Either item) { + if (item.isRight()) { + Status status = item.asRight(); + if (!((status.getInReplyToId() != null && filterRemoveReplies) + || (status.getReblog() != null && filterRemoveReblogs) + || shouldFilterStatus(status))) { + if (findStatusOrReblogPositionById(status.getId()) < 0) { + statuses.add(0, item); + updateAdapter(); + timelineRepo.addSingleStatusToDb(status); + } + } + } else { + statuses.add(0, item); + updateAdapter(); + } + } + /** * For certain requests we don't want to see placeholders, they will be removed some other way */ @@ -1308,6 +1390,9 @@ public class TimelineFragment extends SFragment implements private void handleStatusComposeEvent(@NonNull Status status) { switch (kind) { case HOME: + if (preferences.getBoolean("useHTLStream", false)){ + return; + } case PUBLIC_FEDERATED: case PUBLIC_LOCAL: break; @@ -1343,6 +1428,16 @@ public class TimelineFragment extends SFragment implements } } + private void handleStreamUpdateEvent(StreamUpdateEvent event) { + Status status = event.getStatus(); + if (event.getFirst() && statuses.get(0).isRight()) { + Placeholder placeholder = new Placeholder(statuses.get(0).asRight().getId() + 1); + updateStatuses(Arrays.asList(new Either.Right<>(status), new Either.Left<>(placeholder)), false); + } else { + addStatus(new Either.Right<>(status)); + } + } + private List> liftStatusList(List list) { return CollectionsKt.map(list, statusLifter); } @@ -1357,11 +1452,14 @@ public class TimelineFragment extends SFragment implements if (isAdded()) { adapter.notifyItemRangeInserted(position, count); Context context = getContext(); - if (position == 0 && context != null) { - if (isSwipeToRefreshEnabled) + if (position == 0 && context != null && layoutManager.findFirstVisibleItemPosition() == 0) { + if (count == 1) { + jumpToTop(); + } else if (isSwipeToRefreshEnabled) { recyclerView.scrollBy(0, Utils.dpToPx(context, -30)); - else + } else { recyclerView.scrollToPosition(0); + } } } } diff --git a/app/src/main/java/com/keylesspalace/tusky/repository/TimelineRepository.kt b/app/src/main/java/com/keylesspalace/tusky/repository/TimelineRepository.kt index ad6e55875..4c19a2cbe 100644 --- a/app/src/main/java/com/keylesspalace/tusky/repository/TimelineRepository.kt +++ b/app/src/main/java/com/keylesspalace/tusky/repository/TimelineRepository.kt @@ -30,6 +30,7 @@ enum class TimelineRequestMode { interface TimelineRepository { fun getStatuses(maxId: String?, sinceId: String?, sincedIdMinusOne: String?, limit: Int, requestMode: TimelineRequestMode): Single> + fun addSingleStatusToDb(status: Status) companion object { val CLEANUP_INTERVAL = TimeUnit.DAYS.toMillis(14) @@ -61,6 +62,17 @@ class TimelineRepositoryImpl( } } + override fun addSingleStatusToDb(status: Status) { + val acc = accountManager.activeAccount ?: throw IllegalStateException() + val accountId = acc.id + + timelineDao.insertInTransaction( + status.toEntity(accountId, htmlConverter, gson), + status.account.toEntity(accountId, gson), + status.reblog?.account?.toEntity(accountId, gson) + ) + } + private fun getStatusesFromNetwork(maxId: String?, sinceId: String?, sinceIdMinusOne: String?, limit: Int, accountId: Long, requestMode: TimelineRequestMode diff --git a/app/src/main/java/net/accelf/yuito/TimelineStreamingListener.java b/app/src/main/java/net/accelf/yuito/TimelineStreamingListener.java new file mode 100644 index 000000000..90c3988ba --- /dev/null +++ b/app/src/main/java/net/accelf/yuito/TimelineStreamingListener.java @@ -0,0 +1,62 @@ +package net.accelf.yuito; + +import android.text.Spanned; +import android.util.Log; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.keylesspalace.tusky.appstore.EventHub; +import com.keylesspalace.tusky.appstore.StatusDeletedEvent; +import com.keylesspalace.tusky.appstore.StreamUpdateEvent; +import com.keylesspalace.tusky.entity.Status; +import com.keylesspalace.tusky.entity.StreamEvent; +import com.keylesspalace.tusky.json.SpannedTypeAdapter; + +import okhttp3.Response; +import okhttp3.WebSocket; +import okhttp3.WebSocketListener; + +public class TimelineStreamingListener extends WebSocketListener { + + private Gson gson = buildGson(); + private boolean isFirstStatus = true; + + private EventHub eventHub; + + private static Gson buildGson() { + return new GsonBuilder() + .registerTypeAdapter(Spanned.class, new SpannedTypeAdapter()) + .create(); + } + + public TimelineStreamingListener(EventHub eventHub) { + this.eventHub = eventHub; + } + + @Override + public void onOpen(WebSocket webSocket, Response response) { + Log.d("StreamingListener", "Stream connected."); + } + + @Override + public void onMessage(WebSocket webSocket, String text) { + StreamEvent event = gson.fromJson(text, StreamEvent.class); + + String payload = event.getPayload(); + switch (event.getEvent()) { + case UPDATE: + Status status = gson.fromJson(payload, Status.class); + eventHub.dispatch(new StreamUpdateEvent(status, isFirstStatus)); + isFirstStatus = false; + break; + case DELETE: + eventHub.dispatch(new StatusDeletedEvent(payload)); + break; + } + } + + @Override + public void onClosed(WebSocket webSocket, int code, String reason) { + Log.d("StreamingListener", "Stream closed."); + } +} diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 68df2070d..cbef6f323 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -175,6 +175,7 @@ モバイルネットワークでのみ有効 トゥート後のタイムラインの自動更新をしない ViewPagerのoffscreenPageLimitを増やす + HTLのストリーミングを利用する ダーク ライト ブラック diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 3a3515588..fd4aeab92 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -223,6 +223,7 @@ Reduce timeline auto reload after toot Experimental Settings Increase offscreenPageLimit of ViewPager + Use home timeline streaming Dark Light diff --git a/app/src/main/res/xml/preferences.xml b/app/src/main/res/xml/preferences.xml index 7a1b0a435..69b44ae40 100644 --- a/app/src/main/res/xml/preferences.xml +++ b/app/src/main/res/xml/preferences.xml @@ -119,5 +119,10 @@ android:defaultValue="false" android:key="viewPagerOffScreenLimit" android:title="@string/pref_title_experimental_viewpager_offscreen" /> + +