diff options
22 files changed, 510 insertions, 790 deletions
diff --git a/app/build.gradle b/app/build.gradle index 2e924296..db779888 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -135,7 +135,6 @@ android { } def compilerArgs = [ - "-Xuse-experimental=kotlin.Experimental", // "-XXLanguage:+InlineClasses", "-Xopt-in=kotlin.RequiresOptIn", ] @@ -270,6 +269,8 @@ dependencies { implementation kau.Dependencies.kau('searchview', KAU) implementation kau.Dependencies.coreKtx + implementation "androidx.lifecycle:lifecycle-runtime-ktx:2.4.0" + implementation kau.Dependencies.swipeRefreshLayout implementation "androidx.biometric:biometric:${Versions.andxBiometric}" diff --git a/app/src/main/kotlin/com/pitchedapps/frost/activities/BaseMainActivity.kt b/app/src/main/kotlin/com/pitchedapps/frost/activities/BaseMainActivity.kt index 84352cb4..9d16c63a 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/activities/BaseMainActivity.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/activities/BaseMainActivity.kt @@ -130,7 +130,6 @@ import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.components.ActivityComponent import dagger.hilt.android.qualifiers.ActivityContext import dagger.hilt.android.scopes.ActivityScoped -import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.launch import javax.inject.Inject import kotlin.math.abs @@ -140,7 +139,6 @@ import kotlin.math.abs * * Most of the logic that is unrelated to handling fragments */ -@UseExperimental(ExperimentalCoroutinesApi::class) @AndroidEntryPoint abstract class BaseMainActivity : BaseActivity(), @@ -498,7 +496,10 @@ abstract class BaseMainActivity : ) positiveButton(R.string.kau_yes) { this@BaseMainActivity.launch { - fbCookie.logout(this@BaseMainActivity, deleteCookie = true) + fbCookie.logout( + this@BaseMainActivity, + deleteCookie = true + ) } } negativeButton(R.string.kau_no) @@ -637,7 +638,7 @@ abstract class BaseMainActivity : private fun refreshAll() { L.d { "Refresh all" } - fragmentChannel.offer(REQUEST_REFRESH) + fragmentEmit(REQUEST_REFRESH) } override fun onCreateOptionsMenu(menu: Menu): Boolean { @@ -737,19 +738,19 @@ abstract class BaseMainActivity : * These results can be stacked */ if (hasRequest(REQUEST_REFRESH)) { - fragmentChannel.offer(REQUEST_REFRESH) + fragmentEmit(REQUEST_REFRESH) } if (hasRequest(REQUEST_NAV)) { frostNavigationBar(prefs, themeProvider) } if (hasRequest(REQUEST_TEXT_ZOOM)) { - fragmentChannel.offer(REQUEST_TEXT_ZOOM) + fragmentEmit(REQUEST_TEXT_ZOOM) } if (hasRequest(REQUEST_SEARCH)) { invalidateOptionsMenu() } if (hasRequest(REQUEST_FAB)) { - fragmentChannel.offer(lastPosition) + fragmentEmit(lastPosition) } if (hasRequest(REQUEST_NOTIFICATION)) { scheduleNotificationsFromPrefs(prefs) @@ -792,7 +793,6 @@ abstract class BaseMainActivity : override fun onDestroy() { controlWebview?.destroy() super.onDestroy() - fragmentChannel.close() } override fun collapseAppBar() { @@ -864,10 +864,9 @@ abstract class BaseMainActivity : lastPosition = 0 viewpager.setCurrentItem(0, false) viewpager.offscreenPageLimit = pages.size + // todo check if post is necessary viewpager.post { - if (!fragmentChannel.isClosedForSend) { - fragmentChannel.offer(0) - } + fragmentEmit(0) } // trigger hook so title is set } } diff --git a/app/src/main/kotlin/com/pitchedapps/frost/activities/LoginActivity.kt b/app/src/main/kotlin/com/pitchedapps/frost/activities/LoginActivity.kt index 949f1ddd..a95e931b 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/activities/LoginActivity.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/activities/LoginActivity.kt @@ -45,13 +45,20 @@ import com.pitchedapps.frost.utils.frostEvent import com.pitchedapps.frost.utils.frostJsoup import com.pitchedapps.frost.utils.launchNewTask import com.pitchedapps.frost.utils.logFrostEvent -import com.pitchedapps.frost.utils.uniqueOnly +import com.pitchedapps.frost.web.FrostEmitter import com.pitchedapps.frost.web.LoginWebView +import com.pitchedapps.frost.web.asFrostEmitter import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async -import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withContext @@ -76,7 +83,15 @@ class LoginActivity : BaseActivity() { private val profile: ImageView by bindView(R.id.profile) private lateinit var profileLoader: RequestManager - private val refreshChannel = Channel<Boolean>(10) + + private val refreshMutableFlow = MutableSharedFlow<Boolean>( + extraBufferCapacity = 10, + onBufferOverflow = BufferOverflow.DROP_OLDEST + ) + + private val refreshFlow: SharedFlow<Boolean> = refreshMutableFlow.asSharedFlow() + + private val refreshEmit: FrostEmitter<Boolean> = refreshMutableFlow.asFrostEmitter() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -87,11 +102,12 @@ class LoginActivity : BaseActivity() { toolbar(toolbar) } profileLoader = GlideApp.with(profile) - launch { - for (refreshing in refreshChannel.uniqueOnly(this)) { - swipeRefresh.isRefreshing = refreshing - } - } + + refreshFlow + .distinctUntilChanged() + .onEach { swipeRefresh.isRefreshing = it } + .launchIn(this) + launch { val cookie = web.loadLogin { refresh(it != 100) }.await() L.d { "Login found" } @@ -107,7 +123,7 @@ class LoginActivity : BaseActivity() { } private fun refresh(refreshing: Boolean) { - refreshChannel.offer(refreshing) + refreshEmit(refreshing) } private suspend fun loadInfo(cookie: CookieEntity): Unit = withMainContext { diff --git a/app/src/main/kotlin/com/pitchedapps/frost/activities/MainActivity.kt b/app/src/main/kotlin/com/pitchedapps/frost/activities/MainActivity.kt index 755064cd..16606691 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/activities/MainActivity.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/activities/MainActivity.kt @@ -18,23 +18,38 @@ package com.pitchedapps.frost.activities import android.os.Bundle import androidx.viewpager.widget.ViewPager -import ca.allanwang.kau.utils.withMainContext import com.google.android.material.tabs.TabLayout import com.pitchedapps.frost.facebook.FbItem import com.pitchedapps.frost.facebook.parsers.BadgeParser -import com.pitchedapps.frost.kotlin.subscribeDuringJob import com.pitchedapps.frost.utils.L import com.pitchedapps.frost.views.BadgedIcon +import com.pitchedapps.frost.web.FrostEmitter +import com.pitchedapps.frost.web.asFrostEmitter import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.channels.BroadcastChannel -import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.flow.onEach -@UseExperimental(ExperimentalCoroutinesApi::class) class MainActivity : BaseMainActivity() { - override val fragmentChannel = BroadcastChannel<Int>(10) - override val headerBadgeChannel = BroadcastChannel<String>(Channel.CONFLATED) + private val fragmentMutableFlow = MutableSharedFlow<Int>( + extraBufferCapacity = 10, + onBufferOverflow = BufferOverflow.DROP_OLDEST + ) + override val fragmentFlow: SharedFlow<Int> = fragmentMutableFlow.asSharedFlow() + override val fragmentEmit: FrostEmitter<Int> = fragmentMutableFlow.asFrostEmitter() + + private val headerMutableFlow = MutableStateFlow("") + override val headerFlow: SharedFlow<String> = headerMutableFlow.asSharedFlow() + override val headerEmit: FrostEmitter<String> = headerMutableFlow.asFrostEmitter() override fun onNestedCreate(savedInstanceState: Bundle?) { with(contentBinding) { @@ -51,9 +66,9 @@ class MainActivity : BaseMainActivity() { return } if (lastPosition != -1) { - fragmentChannel.offer(-(lastPosition + 1)) + fragmentEmit(-(lastPosition + 1)) } - fragmentChannel.offer(position) + fragmentEmit(position) lastPosition = position } @@ -90,12 +105,18 @@ class MainActivity : BaseMainActivity() { (tab.customView as BadgedIcon).badgeText = null } }) - headerBadgeChannel.subscribeDuringJob(this@MainActivity, Dispatchers.IO) { html -> - val data = - BadgeParser.parseFromData(cookie = fbCookie.webCookie, text = html)?.data - ?: return@subscribeDuringJob - L.v { "Badges $data" } - withMainContext { + headerFlow + .filter { it.isNotBlank() } + .mapNotNull { html -> + BadgeParser.parseFromData( + cookie = fbCookie.webCookie, + text = html + )?.data + } + .distinctUntilChanged() + .flowOn(Dispatchers.IO) + .onEach { data -> + L.v { "Badges $data" } tabsForEachView { _, view -> when (view.iicon) { FbItem.FEED.icon -> view.badgeText = data.feed @@ -105,6 +126,7 @@ class MainActivity : BaseMainActivity() { } } } - } + .flowOn(Dispatchers.Main) + .launchIn(this@MainActivity) } } diff --git a/app/src/main/kotlin/com/pitchedapps/frost/activities/WebOverlayActivity.kt b/app/src/main/kotlin/com/pitchedapps/frost/activities/WebOverlayActivity.kt index ef7579a8..8dbf9d5c 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/activities/WebOverlayActivity.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/activities/WebOverlayActivity.kt @@ -27,7 +27,6 @@ import androidx.coordinatorlayout.widget.CoordinatorLayout import ca.allanwang.kau.swipe.SwipeBackContract import ca.allanwang.kau.swipe.kauSwipeOnCreate import ca.allanwang.kau.swipe.kauSwipeOnDestroy -import ca.allanwang.kau.utils.ContextHelper import ca.allanwang.kau.utils.bindView import ca.allanwang.kau.utils.copyToClipboard import ca.allanwang.kau.utils.darken @@ -56,7 +55,6 @@ import com.pitchedapps.frost.facebook.USER_AGENT import com.pitchedapps.frost.facebook.USER_AGENT_DESKTOP_CONST import com.pitchedapps.frost.facebook.USER_AGENT_MOBILE_CONST import com.pitchedapps.frost.facebook.formattedFbUrl -import com.pitchedapps.frost.kotlin.subscribeDuringJob import com.pitchedapps.frost.utils.ARG_URL import com.pitchedapps.frost.utils.ARG_USER_ID import com.pitchedapps.frost.utils.BiometricUtils @@ -67,7 +65,10 @@ import com.pitchedapps.frost.views.FrostVideoViewer import com.pitchedapps.frost.views.FrostWebView import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.take import kotlinx.coroutines.launch import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import javax.inject.Inject @@ -85,7 +86,6 @@ import javax.inject.Inject * Used by notifications. Unlike the other overlays, this runs as a singleInstance * Going back will bring you back to the previous app */ -@UseExperimental(ExperimentalCoroutinesApi::class) class FrostWebActivity : WebOverlayActivityBase() { override fun onCreate(savedInstanceState: Bundle?) { @@ -97,10 +97,8 @@ class FrostWebActivity : WebOverlayActivityBase() { * We will subscribe to the load cycle once, * and pop a dialog giving the user the option to copy the shared text */ - val refreshReceiver = content.refreshChannel.openSubscription() content.scope.launch(Dispatchers.IO) { - refreshReceiver.receive() - refreshReceiver.cancel() + content.refreshFlow.take(1).collect() withMainContext { materialDialog { title(R.string.invalid_share_url) @@ -151,7 +149,6 @@ class WebOverlayDesktopActivity : WebOverlayActivityBase(USER_AGENT_DESKTOP_CONS */ class WebOverlayActivity : WebOverlayActivityBase() -@UseExperimental(ExperimentalCoroutinesApi::class) @AndroidEntryPoint abstract class WebOverlayActivityBase(private val userAgent: String = USER_AGENT) : BaseActivity(), @@ -215,9 +212,7 @@ abstract class WebOverlayActivityBase(private val userAgent: String = USER_AGENT content.bind(this) - content.titleChannel.subscribeDuringJob(this, ContextHelper.coroutineContext) { - toolbar.title = it - } + content.titleFlow.onEach { toolbar.title = it }.launchIn(this) with(web) { userAgentString = userAgent diff --git a/app/src/main/kotlin/com/pitchedapps/frost/contracts/ActivityContract.kt b/app/src/main/kotlin/com/pitchedapps/frost/contracts/ActivityContract.kt index 756b1f3d..2b7f7b2c 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/contracts/ActivityContract.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/contracts/ActivityContract.kt @@ -18,13 +18,16 @@ package com.pitchedapps.frost.contracts import com.mikepenz.iconics.typeface.IIcon import com.pitchedapps.frost.fragments.BaseFragment -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.channels.BroadcastChannel +import com.pitchedapps.frost.web.FrostEmitter +import kotlinx.coroutines.flow.SharedFlow -@UseExperimental(ExperimentalCoroutinesApi::class) interface MainActivityContract : MainFabContract { - val fragmentChannel: BroadcastChannel<Int> - val headerBadgeChannel: BroadcastChannel<String> + val fragmentFlow: SharedFlow<Int> + val fragmentEmit: FrostEmitter<Int> + + val headerFlow: SharedFlow<String> + val headerEmit: FrostEmitter<String> + fun setTitle(res: Int) fun setTitle(text: CharSequence) diff --git a/app/src/main/kotlin/com/pitchedapps/frost/contracts/FrostContentContract.kt b/app/src/main/kotlin/com/pitchedapps/frost/contracts/FrostContentContract.kt index b8d0d86f..7f91f901 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/contracts/FrostContentContract.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/contracts/FrostContentContract.kt @@ -18,9 +18,9 @@ package com.pitchedapps.frost.contracts import android.view.View import com.pitchedapps.frost.facebook.FbItem +import com.pitchedapps.frost.web.FrostEmitter import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.channels.BroadcastChannel +import kotlinx.coroutines.flow.SharedFlow /** * Created by Allan Wang on 20/12/17. @@ -46,7 +46,6 @@ interface FrostContentContainer : CoroutineScope { * Contract for components shared among * all content providers */ -@UseExperimental(ExperimentalCoroutinesApi::class) interface FrostContentParent : DynamicUiContract { val scope: CoroutineScope @@ -56,18 +55,23 @@ interface FrostContentParent : DynamicUiContract { /** * Observable to get data on whether view is refreshing or not */ - val refreshChannel: BroadcastChannel<Boolean> + val refreshFlow: SharedFlow<Boolean> + + val refreshEmit: FrostEmitter<Boolean> /** * Observable to get data on refresh progress, with range [0, 100] */ - val progressChannel: BroadcastChannel<Int> + val progressFlow: SharedFlow<Int> + + val progressEmit: FrostEmitter<Int> /** * Observable to get new title data (unique values only) */ - // todo note that this should be like a behavior subject vs publish subject - val titleChannel: BroadcastChannel<String> + val titleFlow: SharedFlow<String> + + val titleEmit: FrostEmitter<String> var baseUrl: String @@ -124,17 +128,15 @@ interface FrostContentCore : DynamicUiContract { * Reference to parent * Bound through calling [FrostContentParent.bind] */ - var parent: FrostContentParent + val parent: FrostContentParent /** * Initializes view through given [container] * * The content may be free to extract other data from * the container if necessary - * - * [parent] must be bounded before calling this! */ - fun bind(container: FrostContentContainer): View + fun bind(parent: FrostContentParent, container: FrostContentContainer): View /** * Call to reload wrapped data diff --git a/app/src/main/kotlin/com/pitchedapps/frost/fragments/BaseFragment.kt b/app/src/main/kotlin/com/pitchedapps/frost/fragments/BaseFragment.kt index 79495b2a..a3303638 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/fragments/BaseFragment.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/fragments/BaseFragment.kt @@ -21,6 +21,7 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.fragment.app.Fragment +import androidx.lifecycle.flowWithLifecycle import ca.allanwang.kau.utils.ContextHelper import ca.allanwang.kau.utils.fadeScaleTransition import ca.allanwang.kau.utils.setIcon @@ -43,12 +44,11 @@ import com.pitchedapps.frost.utils.REQUEST_TEXT_ZOOM import com.pitchedapps.frost.utils.frostEvent import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.channels.ReceiveChannel +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.isActive -import kotlinx.coroutines.launch import javax.inject.Inject import kotlin.coroutines.CoroutineContext @@ -58,7 +58,6 @@ import kotlin.coroutines.CoroutineContext * All fragments pertaining to the main view * Must be attached to activities implementing [MainActivityContract] */ -@UseExperimental(ExperimentalCoroutinesApi::class) @AndroidEntryPoint abstract class BaseFragment : Fragment(), @@ -121,7 +120,6 @@ abstract class BaseFragment : } override var firstLoad: Boolean = true - private var activityReceiver: ReceiveChannel<Int>? = null private var onCreateRunnable: ((FragmentContract) -> Unit)? = null override var content: FrostContentParent? = null @@ -152,8 +150,7 @@ abstract class BaseFragment : onCreateRunnable?.invoke(this) onCreateRunnable = null firstLoadRequest() - detachMainObservable() - activityReceiver = attachMainObservable(mainContract) + attach(mainContract) } override fun setUserVisibleHint(isVisibleToUser: Boolean) { @@ -177,10 +174,10 @@ abstract class BaseFragment : mainContract.setTitle(title) } - override fun attachMainObservable(contract: MainActivityContract): ReceiveChannel<Int> { - val receiver = contract.fragmentChannel.openSubscription() - launch { - for (flag in receiver) { + override fun attach(contract: MainActivityContract) { + contract.fragmentFlow + .flowWithLifecycle(viewLifecycleOwner.lifecycle) + .onEach { flag -> when (flag) { REQUEST_REFRESH -> { core?.apply { @@ -201,9 +198,7 @@ abstract class BaseFragment : reloadTextSize() } } - } - } - return receiver + }.launchIn(this) } override fun updateFab(contract: MainFabContract) { @@ -222,16 +217,11 @@ abstract class BaseFragment : setOnClickListener { click() } } - override fun detachMainObservable() { - activityReceiver?.cancel() - } - override fun onDestroyView() { super.onDestroyView() L.i { "Fragment on destroy $position ${hashCode()}" } content?.destroy() content = null - detachMainObservable() } override fun onDestroy() { diff --git a/app/src/main/kotlin/com/pitchedapps/frost/fragments/FragmentContract.kt b/app/src/main/kotlin/com/pitchedapps/frost/fragments/FragmentContract.kt index 10c612c5..beac7494 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/fragments/FragmentContract.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/fragments/FragmentContract.kt @@ -22,7 +22,6 @@ import com.pitchedapps.frost.contracts.FrostContentParent import com.pitchedapps.frost.contracts.MainActivityContract import com.pitchedapps.frost.contracts.MainFabContract import com.pitchedapps.frost.views.FrostRecyclerView -import kotlinx.coroutines.channels.ReceiveChannel /** * Created by Allan Wang on 2017-11-07. @@ -77,15 +76,8 @@ interface FragmentContract : FrostContentContainer { /** * Call whenever a fragment is attached so that it may listen * to activity emissions. - * Returns a means of closing the listener, which can be called from [detachMainObservable] */ - fun attachMainObservable(contract: MainActivityContract): ReceiveChannel<Int> - - /** - * Call when fragment is detached so that any existing - * observable is disposed - */ - fun detachMainObservable() + fun attach(contract: MainActivityContract) /* * ----------------------------------------- diff --git a/app/src/main/kotlin/com/pitchedapps/frost/kotlin/CoroutineUtils.kt b/app/src/main/kotlin/com/pitchedapps/frost/kotlin/CoroutineUtils.kt deleted file mode 100644 index 6f8a60a9..00000000 --- a/app/src/main/kotlin/com/pitchedapps/frost/kotlin/CoroutineUtils.kt +++ /dev/null @@ -1,39 +0,0 @@ -/* - * Copyright 2019 Allan Wang - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - */ -package com.pitchedapps.frost.kotlin - -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.Job -import kotlinx.coroutines.channels.BroadcastChannel -import kotlinx.coroutines.launch -import kotlin.coroutines.CoroutineContext - -@UseExperimental(ExperimentalCoroutinesApi::class) -fun <T> BroadcastChannel<T>.subscribeDuringJob( - scope: CoroutineScope, - context: CoroutineContext, - onReceive: suspend (T) -> Unit -) { - val receiver = openSubscription() - scope.launch(context) { - for (r in receiver) { - onReceive(r) - } - } - scope.coroutineContext[Job]!!.invokeOnCompletion { receiver.cancel() } -} diff --git a/app/src/main/kotlin/com/pitchedapps/frost/kotlin/Flyweight.kt b/app/src/main/kotlin/com/pitchedapps/frost/kotlin/Flyweight.kt deleted file mode 100644 index 74765b58..00000000 --- a/app/src/main/kotlin/com/pitchedapps/frost/kotlin/Flyweight.kt +++ /dev/null @@ -1,174 +0,0 @@ -/* - * Copyright 2018 Allan Wang - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - */ -package com.pitchedapps.frost.kotlin - -import com.pitchedapps.frost.utils.L -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.CoroutineExceptionHandler -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.isActive -import kotlinx.coroutines.launch -import kotlinx.coroutines.selects.select -import java.util.concurrent.ConcurrentHashMap - -/** - * Flyweight to keep track of values so long as they are valid. - * Values that have been fetched within [maxAge] from the time of use will be reused. - * If multiple requests are sent with the same key, then the value should only be fetched once. - * Otherwise, they will be fetched using [fetcher]. - * All requests will stem from the supplied [scope]. - */ -class Flyweight<K, V>( - val scope: CoroutineScope, - val maxAge: Long, - private val fetcher: suspend (K) -> V -) { - - // Receives a key and a pending request - private val actionChannel = Channel<Pair<K, CompletableDeferred<V>>>(Channel.UNLIMITED) - - // Receives a key to invalidate the associated value - private val invalidatorChannel = Channel<K>(Channel.UNLIMITED) - - // Receives a key and the resulting value - private val receiverChannel = Channel<Pair<K, Result<V>>>(Channel.UNLIMITED) - - // Keeps track of keys and associated update times - private val conditionMap: MutableMap<K, Long> = mutableMapOf() - - // Keeps track of keys and associated values - private val resultMap: MutableMap<K, Result<V>> = mutableMapOf() - - // Keeps track of unfulfilled actions - // Note that the explicit type is very important here. See https://youtrack.jetbrains.net/issue/KT-18053 - private val pendingMap: MutableMap<K, MutableList<CompletableDeferred<V>>> = ConcurrentHashMap() - - private val job: Job - - private fun CompletableDeferred<V>.completeWith(result: Result<V>) { - if (result.isSuccess) - complete(result.getOrNull()!!) - else - completeExceptionally(result.exceptionOrNull()!!) - } - - private val errHandler = - CoroutineExceptionHandler { _, throwable -> L.d { "FbAuth failed ${throwable.message}" } } - - init { - job = - scope.launch(Dispatchers.IO + SupervisorJob() + errHandler) { - launch { - while (isActive) { - select<Unit> { - /* - * New request received. Continuation should be fulfilled eventually - */ - actionChannel.onReceive { (key, completable) -> - val lastUpdate = conditionMap[key] - val lastResult = resultMap[key] - // Valid value, retrieved within acceptable time - if (lastResult != null && lastUpdate != null && System.currentTimeMillis() - lastUpdate < maxAge) { - completable.completeWith(lastResult) - } else { - val valueRequestPending = key in pendingMap - pendingMap.getOrPut(key) { mutableListOf() }.add(completable) - if (!valueRequestPending) - fulfill(key) - } - } - /* - * Invalidator received. Existing result associated with key should not be used. - * Note that any unfulfilled request and future requests should still operate, but with a new value. - */ - invalidatorChannel.onReceive { key -> - if (key !in resultMap) { - // Nothing to invalidate. - // If pending requests exist, they are already in the process of being updated. - return@onReceive - } - conditionMap.remove(key) - resultMap.remove(key) - if (pendingMap[key]?.isNotEmpty() == true) - // Refetch value for pending requests - fulfill(key) - } - /* - * Value request fulfilled. Should now fulfill pending requests - */ - receiverChannel.onReceive { (key, result) -> - conditionMap[key] = System.currentTimeMillis() - resultMap[key] = result - pendingMap.remove(key)?.forEach { - it.completeWith(result) - } - } - } - } - } - } - } - - /* - * Value request received. Should fetch new value using supplied fetcher - */ - private fun fulfill(key: K) { - scope.launch { - val result = runCatching { - fetcher(key) - } - receiverChannel.send(key to result) - } - } - - /** - * Queues the request, and returns a completable once it is sent to a channel. - * The fetcher will only be suspended if the channels are full. - * - * Note that if the job is already inactive, a cancellation exception will be thrown. - * The message may default to the message for all completables under a cancelled job - */ - fun fetch(key: K): CompletableDeferred<V> { - val completable = CompletableDeferred<V>(job) - if (!job.isActive) completable.completeExceptionally(CancellationException("Flyweight is not active")) - else actionChannel.offer(key to completable) - return completable - } - - suspend fun invalidate(key: K) { - invalidatorChannel.send(key) - } - - fun cancel() { - job.cancel() - if (pendingMap.isNotEmpty()) { - val error = CancellationException("Flyweight cancelled") - pendingMap.values.flatten().forEach { it.completeExceptionally(error) } - pendingMap.clear() - } - actionChannel.close() - invalidatorChannel.close() - receiverChannel.close() - conditionMap.clear() - resultMap.clear() - } -} diff --git a/app/src/main/kotlin/com/pitchedapps/frost/utils/KotlinUtils.kt b/app/src/main/kotlin/com/pitchedapps/frost/utils/KotlinUtils.kt deleted file mode 100644 index f4357c9b..00000000 --- a/app/src/main/kotlin/com/pitchedapps/frost/utils/KotlinUtils.kt +++ /dev/null @@ -1,36 +0,0 @@ -/* - * Copyright 2018 Allan Wang - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - */ -package com.pitchedapps.frost.utils - -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.channels.ReceiveChannel -import kotlinx.coroutines.channels.produce -import kotlinx.coroutines.isActive - -@UseExperimental(ExperimentalCoroutinesApi::class) -fun <T> ReceiveChannel<T>.uniqueOnly(scope: CoroutineScope): ReceiveChannel<T> = scope.produce { - var previous: T? = null - for (current in this@uniqueOnly) { - if (!scope.isActive) { - cancel() - } else if (previous != current) { - previous = current - send(current) - } - } -} diff --git a/app/src/main/kotlin/com/pitchedapps/frost/views/FrostContentView.kt b/app/src/main/kotlin/com/pitchedapps/frost/views/FrostContentView.kt index c30ee199..c77ae590 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/views/FrostContentView.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/views/FrostContentView.kt @@ -22,7 +22,6 @@ import android.util.AttributeSet import android.view.View import android.widget.FrameLayout import android.widget.ProgressBar -import ca.allanwang.kau.utils.ContextHelper import ca.allanwang.kau.utils.bindView import ca.allanwang.kau.utils.circularReveal import ca.allanwang.kau.utils.fadeIn @@ -39,15 +38,24 @@ import com.pitchedapps.frost.contracts.FrostContentParent import com.pitchedapps.frost.facebook.FbItem import com.pitchedapps.frost.facebook.WEB_LOAD_DELAY import com.pitchedapps.frost.injectors.ThemeProvider -import com.pitchedapps.frost.kotlin.subscribeDuringJob import com.pitchedapps.frost.prefs.Prefs import com.pitchedapps.frost.utils.L +import com.pitchedapps.frost.web.FrostEmitter +import com.pitchedapps.frost.web.asFrostEmitter import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.channels.BroadcastChannel -import kotlinx.coroutines.channels.ConflatedBroadcastChannel +import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.channels.ReceiveChannel +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.runningFold +import kotlinx.coroutines.flow.transformWhile import javax.inject.Inject class FrostContentWeb @JvmOverloads constructor( @@ -70,7 +78,6 @@ class FrostContentRecycler @JvmOverloads constructor( override val layoutRes: Int = R.layout.view_content_base_recycler } -@UseExperimental(ExperimentalCoroutinesApi::class) abstract class FrostContentView<out T> @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, @@ -88,7 +95,6 @@ abstract class FrostContentView<out T> @JvmOverloads constructor( /** * Subsection of [FrostContentView] that is [AndroidEntryPoint] friendly (no generics) */ -@UseExperimental(ExperimentalCoroutinesApi::class) @AndroidEntryPoint abstract class FrostContentViewBase( context: Context, @@ -119,13 +125,35 @@ abstract class FrostContentViewBase( private val refresh: SwipeRefreshLayout by bindView(R.id.content_refresh) private val progress: ProgressBar by bindView(R.id.content_progress) + private val coreView: View by bindView(R.id.content_core) + /** * While this can be conflated, there exist situations where we wish to watch refresh cycles. * Here, we'd need to make sure we don't skip events + * + * TODO ensure there is only one flow provider is this is still separated in login + * Use case for shared flow is to avoid emitting before subscribing; buffer can probably be size 1 */ - override val refreshChannel: BroadcastChannel<Boolean> = BroadcastChannel(10) - override val progressChannel: BroadcastChannel<Int> = ConflatedBroadcastChannel() - override val titleChannel: BroadcastChannel<String> = ConflatedBroadcastChannel() + private val refreshMutableFlow = MutableSharedFlow<Boolean>( + extraBufferCapacity = 10, + onBufferOverflow = BufferOverflow.DROP_OLDEST + ) + + override val refreshFlow: SharedFlow<Boolean> = refreshMutableFlow.asSharedFlow() + + override val refreshEmit: FrostEmitter<Boolean> = refreshMutableFlow.asFrostEmitter() + + private val progressMutableFlow = MutableStateFlow(0) + + override val progressFlow: SharedFlow<Int> = progressMutableFlow.asSharedFlow() + + override val progressEmit: FrostEmitter<Int> = progressMutableFlow.asFrostEmitter() + + private val titleMutableFlow = MutableStateFlow("") + + override val titleFlow: SharedFlow<String> = titleMutableFlow.asSharedFlow() + + override val titleEmit: FrostEmitter<String> = titleMutableFlow.asFrostEmitter() override lateinit var scope: CoroutineScope @@ -160,7 +188,6 @@ abstract class FrostContentViewBase( */ protected fun init() { inflate(context, layoutRes, this) - core.parent = this reloadThemeSelf() } @@ -169,23 +196,23 @@ abstract class FrostContentViewBase( baseEnum = container.baseEnum init() scope = container - core.bind(container) + core.bind(this, container) refresh.setOnRefreshListener { core.reload(true) } - refreshChannel.subscribeDuringJob(scope, ContextHelper.coroutineContext) { r -> + refreshFlow.distinctUntilChanged().onEach { r -> L.v { "Refreshing $r" } refresh.isRefreshing = r - } + }.launchIn(scope) - progressChannel.subscribeDuringJob(scope, ContextHelper.coroutineContext) { p -> + progressFlow.onEach { p -> progress.invisibleIf(p == 100) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) progress.setProgress(p, true) else progress.progress = p - } + }.launchIn(scope) } override fun reloadTheme() { @@ -220,33 +247,37 @@ abstract class FrostContentViewBase( * The cycle only starts on the first load since there may have been another process when this is registered */ override fun registerTransition(urlChanged: Boolean, animate: Boolean): Boolean { - if (!urlChanged && refreshReceiver != null) { + if (!urlChanged && transitionStart != -1L) { L.v { "Consuming url load" } return false // still in progress; do not bother with load } + coreView.transition(animate) + return true + } + + private fun View.transition(animate: Boolean) { L.v { "Registered transition" } - with(core) { - refreshReceiver = refreshChannel.openSubscription().also { receiver -> - scope.launchMain { - var loading = false - for (r in receiver) { - if (r) { - loading = true - transitionStart = System.currentTimeMillis() - clearAnimation() - if (isVisible) - fadeOut(duration = 200L) - } else if (loading) { - if (animate && prefs.animate) circularReveal(offset = WEB_LOAD_DELAY) - else fadeIn(duration = 200L, offset = WEB_LOAD_DELAY) - L.v { "Transition loaded in ${System.currentTimeMillis() - transitionStart} ms" } - receiver.cancel() - refreshReceiver = null - } + transitionStart = 0L // Marker for pending transition + scope.launchMain { + refreshFlow.distinctUntilChanged() + // Pseudo windowed mode + .runningFold(false to false) { (_, prev), curr -> prev to curr } + // Take until prev was loading and current is not loading + // Unlike takeWhile, we include the last state (first non matching) + .transformWhile { emit(it); it != (true to false) } + .onEach { (prev, curr) -> + if (curr) { + transitionStart = System.currentTimeMillis() + clearAnimation() + if (isVisible) + fadeOut(duration = 200L) + } else if (prev) { // prev && !curr + if (animate && prefs.animate) circularReveal(offset = WEB_LOAD_DELAY) + else fadeIn(duration = 200L, offset = WEB_LOAD_DELAY) + L.v { "Transition loaded in ${System.currentTimeMillis() - transitionStart} ms" } } - } - } + }.collect() + transitionStart = -1L } - return true } } diff --git a/app/src/main/kotlin/com/pitchedapps/frost/views/FrostRecyclerView.kt b/app/src/main/kotlin/com/pitchedapps/frost/views/FrostRecyclerView.kt index 2ab00916..04ee7f3c 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/views/FrostRecyclerView.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/views/FrostRecyclerView.kt @@ -29,7 +29,6 @@ import com.pitchedapps.frost.contracts.FrostContentParent import com.pitchedapps.frost.fragments.RecyclerContentContract import com.pitchedapps.frost.prefs.Prefs import dagger.hilt.android.AndroidEntryPoint -import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.launch import javax.inject.Inject @@ -37,7 +36,6 @@ import javax.inject.Inject * Created by Allan Wang on 2017-05-29. * */ -@UseExperimental(ExperimentalCoroutinesApi::class) @AndroidEntryPoint class FrostRecyclerView @JvmOverloads constructor( context: Context, @@ -61,7 +59,8 @@ class FrostRecyclerView @JvmOverloads constructor( layoutManager = LinearLayoutManager(context) } - override fun bind(container: FrostContentContainer): View { + override fun bind(parent: FrostContentParent, container: FrostContentContainer): View { + this.parent = parent if (container !is RecyclerContentContract) throw IllegalStateException("FrostRecyclerView must bind to a container that is a RecyclerContentContract") this.recyclerContract = container @@ -78,10 +77,10 @@ class FrostRecyclerView @JvmOverloads constructor( override fun reloadBase(animate: Boolean) { if (prefs.animate) fadeOut(onFinish = onReloadClear) scope.launch { - parent.refreshChannel.offer(true) - recyclerContract.reload { parent.progressChannel.offer(it) } - parent.progressChannel.offer(100) - parent.refreshChannel.offer(false) + parent.refreshEmit(true) + recyclerContract.reload { parent.progressEmit(it) } + parent.progressEmit(100) + parent.refreshEmit(false) if (prefs.animate) circularReveal() } } diff --git a/app/src/main/kotlin/com/pitchedapps/frost/views/FrostWebView.kt b/app/src/main/kotlin/com/pitchedapps/frost/views/FrostWebView.kt index f384d134..140b4901 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/views/FrostWebView.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/views/FrostWebView.kt @@ -35,25 +35,23 @@ import com.pitchedapps.frost.db.CookieDao import com.pitchedapps.frost.db.currentCookie import com.pitchedapps.frost.facebook.FB_HOME_URL import com.pitchedapps.frost.facebook.FbCookie +import com.pitchedapps.frost.facebook.FbItem import com.pitchedapps.frost.facebook.USER_AGENT -import com.pitchedapps.frost.fragments.WebFragment import com.pitchedapps.frost.injectors.ThemeProvider import com.pitchedapps.frost.prefs.Prefs import com.pitchedapps.frost.utils.L import com.pitchedapps.frost.utils.frostDownload import com.pitchedapps.frost.web.FrostChromeClient -import com.pitchedapps.frost.web.FrostJSI +import com.pitchedapps.frost.web.FrostWebClientEntryPoint +import com.pitchedapps.frost.web.FrostWebComponentBuilder +import com.pitchedapps.frost.web.FrostWebEntryPoint import com.pitchedapps.frost.web.FrostWebViewClient +import com.pitchedapps.frost.web.FrostWebViewClientMenu +import com.pitchedapps.frost.web.FrostWebViewClientMessenger import com.pitchedapps.frost.web.NestedWebView -import dagger.BindsInstance -import dagger.hilt.DefineComponent -import dagger.hilt.EntryPoint import dagger.hilt.EntryPoints -import dagger.hilt.InstallIn import dagger.hilt.android.AndroidEntryPoint -import dagger.hilt.android.components.ViewComponent import javax.inject.Inject -import javax.inject.Scope import kotlin.math.abs import kotlin.math.max import kotlin.math.min @@ -103,9 +101,11 @@ class FrostWebView @JvmOverloads constructor( get() = url ?: "" @SuppressLint("SetJavaScriptEnabled") - override fun bind(container: FrostContentContainer): View { - val component = frostWebComponentBuilder.frostWebView(this).build() - val entryPoint = EntryPoints.get(component, FrostWebEntryPoint::class.java) + override fun bind(parent: FrostContentParent, container: FrostContentContainer): View { + this.parent = parent + val component = frostWebComponentBuilder.frostParent(parent).frostWebView(this).build() + val webEntryPoint = EntryPoints.get(component, FrostWebEntryPoint::class.java) + val clientEntryPoint = EntryPoints.get(component, FrostWebClientEntryPoint::class.java) userAgentString = USER_AGENT with(settings) { javaScriptEnabled = true @@ -116,10 +116,14 @@ class FrostWebView @JvmOverloads constructor( } setLayerType(LAYER_TYPE_HARDWARE, null) // attempt to get custom client; otherwise fallback to original - frostWebClient = (container as? WebFragment)?.client(this) ?: FrostWebViewClient(this) + frostWebClient = when (parent.baseEnum) { + FbItem.MESSENGER -> FrostWebViewClientMessenger(this) + FbItem.MENU -> FrostWebViewClientMenu(this) + else -> clientEntryPoint.webClient() + } webViewClient = frostWebClient webChromeClient = FrostChromeClient(this, themeProvider, webFileChooser) - addJavascriptInterface(entryPoint.frostJsi(), "Frost") + addJavascriptInterface(webEntryPoint.frostJsi(), "Frost") setBackgroundColor(Color.TRANSPARENT) setDownloadListener { url, userAgent, contentDisposition, mimetype, contentLength -> context.ctxCoroutine.launchMain { @@ -251,29 +255,3 @@ class FrostWebView @JvmOverloads constructor( super.destroy() } } - -@Scope -@Retention(AnnotationRetention.BINARY) -@Target( - AnnotationTarget.FUNCTION, - AnnotationTarget.TYPE, - AnnotationTarget.CLASS -) -annotation class FrostWebScoped - -@FrostWebScoped -@DefineComponent(parent = ViewComponent::class) -interface FrostWebComponent - -@DefineComponent.Builder -interface FrostWebComponentBuilder { - fun frostWebView(@BindsInstance web: FrostWebView): FrostWebComponentBuilder - fun build(): FrostWebComponent -} - -@EntryPoint -@InstallIn(FrostWebComponent::class) -interface FrostWebEntryPoint { - @FrostWebScoped - fun frostJsi(): FrostJSI -} diff --git a/app/src/main/kotlin/com/pitchedapps/frost/web/FrostChromeClients.kt b/app/src/main/kotlin/com/pitchedapps/frost/web/FrostChromeClients.kt index 61a76e70..90345aa2 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/web/FrostChromeClients.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/web/FrostChromeClients.kt @@ -34,7 +34,6 @@ import com.pitchedapps.frost.contracts.WebFileChooser import com.pitchedapps.frost.injectors.ThemeProvider import com.pitchedapps.frost.utils.L import com.pitchedapps.frost.views.FrostWebView -import kotlinx.coroutines.channels.SendChannel /** * Created by Allan Wang on 2017-05-31. @@ -51,9 +50,10 @@ class FrostChromeClient( private val webFileChooser: WebFileChooser, ) : WebChromeClient() { - private val refresh: SendChannel<Boolean> = web.parent.refreshChannel - private val progress: SendChannel<Int> = web.parent.progressChannel - private val title: SendChannel<String> = web.parent.titleChannel +// private val refresh: SendChannel<Boolean> = web.parent.refreshChannel + private val refreshEmit = web.parent.refreshEmit + private val progressEmit = web.parent.progressEmit + private val titleEmit = web.parent.titleEmit private val context = web.context!! override fun getDefaultVideoPoster(): Bitmap? = @@ -68,12 +68,12 @@ class FrostChromeClient( override fun onReceivedTitle(view: WebView, title: String) { super.onReceivedTitle(view, title) if (title.startsWith("http")) return - this.title.offer(title) + titleEmit(title) } override fun onProgressChanged(view: WebView, newProgress: Int) { super.onProgressChanged(view, newProgress) - progress.offer(newProgress) + progressEmit(newProgress) } override fun onShowFileChooser( @@ -87,8 +87,8 @@ class FrostChromeClient( private fun JsResult.frostCancel() { cancel() - refresh.offer(false) - progress.offer(100) + refreshEmit(false) + progressEmit(100) } override fun onJsAlert( diff --git a/app/src/main/kotlin/com/pitchedapps/frost/web/FrostJSI.kt b/app/src/main/kotlin/com/pitchedapps/frost/web/FrostJSI.kt index 3ead78f4..4d92e8c2 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/web/FrostJSI.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/web/FrostJSI.kt @@ -32,24 +32,24 @@ import com.pitchedapps.frost.utils.isIndependent import com.pitchedapps.frost.utils.launchImageActivity import com.pitchedapps.frost.utils.showWebContextMenu import com.pitchedapps.frost.views.FrostWebView -import kotlinx.coroutines.channels.SendChannel import kotlinx.coroutines.launch import javax.inject.Inject /** * Created by Allan Wang on 2017-06-01. */ +@FrostWebScoped class FrostJSI @Inject internal constructor( val web: FrostWebView, private val activity: Activity, private val fbCookie: FbCookie, - private val prefs: Prefs + private val prefs: Prefs, + @FrostRefresh private val refreshEmit: FrostEmitter<Boolean> ) { private val mainActivity: MainActivity? = activity as? MainActivity private val webActivity: WebOverlayActivityBase? = activity as? WebOverlayActivityBase - private val header: SendChannel<String>? = mainActivity?.headerBadgeChannel - private val refresh: SendChannel<Boolean> = web.parent.refreshChannel + private val headerEmit: FrostEmitter<String>? = mainActivity?.headerEmit private val cookies: List<CookieEntity> = activity.cookies() /** @@ -144,7 +144,8 @@ class FrostJSI @Inject internal constructor( @JavascriptInterface fun isReady() { if (web.frostWebClient !is FrostWebViewClientMenu) { - refresh.offer(false) + L.v { "JSI is ready" } + refreshEmit(false) } } @@ -157,7 +158,7 @@ class FrostJSI @Inject internal constructor( @JavascriptInterface fun handleHeader(html: String?) { html ?: return - header?.offer(html) + headerEmit?.invoke(html) } @JavascriptInterface diff --git a/app/src/main/kotlin/com/pitchedapps/frost/web/FrostWeb.kt b/app/src/main/kotlin/com/pitchedapps/frost/web/FrostWeb.kt new file mode 100644 index 00000000..ba05a2c4 --- /dev/null +++ b/app/src/main/kotlin/com/pitchedapps/frost/web/FrostWeb.kt @@ -0,0 +1,87 @@ +/* + * Copyright 2021 Allan Wang + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + */ +package com.pitchedapps.frost.web + +import com.pitchedapps.frost.contracts.FrostContentParent +import com.pitchedapps.frost.views.FrostWebView +import dagger.BindsInstance +import dagger.Module +import dagger.Provides +import dagger.hilt.DefineComponent +import dagger.hilt.EntryPoint +import dagger.hilt.InstallIn +import dagger.hilt.android.components.ViewComponent +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import javax.inject.Qualifier +import javax.inject.Scope + +/** + * Defines a new scope for Frost web related content. + * + * This is a subset of [dagger.hilt.android.scopes.ViewScoped] + */ +@Scope +@Retention(AnnotationRetention.BINARY) +@Target( + AnnotationTarget.FUNCTION, + AnnotationTarget.TYPE, + AnnotationTarget.CLASS +) +annotation class FrostWebScoped + +@FrostWebScoped +@DefineComponent(parent = ViewComponent::class) +interface FrostWebComponent + +@DefineComponent.Builder +interface FrostWebComponentBuilder { + fun frostParent(@BindsInstance parent: FrostContentParent): FrostWebComponentBuilder + fun frostWebView(@BindsInstance web: FrostWebView): FrostWebComponentBuilder + fun build(): FrostWebComponent +} + +@EntryPoint +@InstallIn(FrostWebComponent::class) +interface FrostWebEntryPoint { + fun frostJsi(): FrostJSI +} + +fun interface FrostEmitter<T> : (T) -> Unit + +fun <T> MutableSharedFlow<T>.asFrostEmitter(): FrostEmitter<T> = FrostEmitter { tryEmit(it) } + +@Module +@InstallIn(FrostWebComponent::class) +object FrostWebFlowModule { + @Provides + @FrostWebScoped + @FrostRefresh + fun refreshFlow(parent: FrostContentParent): SharedFlow<Boolean> = parent.refreshFlow + + @Provides + @FrostWebScoped + @FrostRefresh + fun refreshEmit(parent: FrostContentParent): FrostEmitter<Boolean> = parent.refreshEmit +} + +/** + * Observable to get data on whether view is refreshing or not + */ +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class FrostRefresh diff --git a/app/src/main/kotlin/com/pitchedapps/frost/web/FrostWebViewClients.kt b/app/src/main/kotlin/com/pitchedapps/frost/web/FrostWebViewClients.kt index 3b332199..ba19989d 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/web/FrostWebViewClients.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/web/FrostWebViewClients.kt @@ -53,7 +53,6 @@ import com.pitchedapps.frost.utils.isMessengerUrl import com.pitchedapps.frost.utils.launchImageActivity import com.pitchedapps.frost.utils.startActivityForUri import com.pitchedapps.frost.views.FrostWebView -import kotlinx.coroutines.channels.SendChannel import kotlinx.coroutines.launch /** @@ -83,7 +82,7 @@ open class FrostWebViewClient(val web: FrostWebView) : BaseWebViewClient() { protected val fbCookie: FbCookie get() = web.fbCookie protected val prefs: Prefs get() = web.prefs protected val themeProvider: ThemeProvider get() = web.themeProvider - protected val refresh: SendChannel<Boolean> = web.parent.refreshChannel +// protected val refresh: SendChannel<Boolean> = web.parent.refreshChannel protected val isMain = web.parent.baseEnum != null /** @@ -156,7 +155,7 @@ open class FrostWebViewClient(val web: FrostWebView) : BaseWebViewClient() { super.onPageStarted(view, url, favicon) if (url == null) return v { "loading $url ${web.settings.userAgentString}" } - refresh.offer(true) +// refresh.offer(true) } private fun injectBackgroundColor() { @@ -182,7 +181,7 @@ open class FrostWebViewClient(val web: FrostWebView) : BaseWebViewClient() { view.messengerJsInject() } else -> { - refresh.offer(false) +// refresh.offer(false) } } } @@ -191,7 +190,7 @@ open class FrostWebViewClient(val web: FrostWebView) : BaseWebViewClient() { url ?: return v { "finished $url" } if (!url.isFacebookUrl && !url.isMessengerUrl) { - refresh.offer(false) +// refresh.offer(false) return } onPageFinishedActions(url) @@ -204,9 +203,10 @@ open class FrostWebViewClient(val web: FrostWebView) : BaseWebViewClient() { injectAndFinish() } - internal fun injectAndFinish() { + // Temp open + internal open fun injectAndFinish() { v { "page finished reveal" } - refresh.offer(false) +// refresh.offer(false) injectBackgroundColor() when { web.url.isFacebookUrl -> { diff --git a/app/src/main/kotlin/com/pitchedapps/frost/web/FrostWebViewClients2.kt b/app/src/main/kotlin/com/pitchedapps/frost/web/FrostWebViewClients2.kt new file mode 100644 index 00000000..008b1197 --- /dev/null +++ b/app/src/main/kotlin/com/pitchedapps/frost/web/FrostWebViewClients2.kt @@ -0,0 +1,196 @@ +/* + * Copyright 2018 Allan Wang + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + */ +package com.pitchedapps.frost.web + +import android.graphics.Bitmap +import android.graphics.Color +import android.webkit.WebResourceRequest +import android.webkit.WebView +import ca.allanwang.kau.utils.withAlpha +import com.pitchedapps.frost.enums.ThemeCategory +import com.pitchedapps.frost.injectors.JsActions +import com.pitchedapps.frost.injectors.JsAssets +import com.pitchedapps.frost.injectors.jsInject +import com.pitchedapps.frost.utils.L +import com.pitchedapps.frost.utils.isFacebookUrl +import com.pitchedapps.frost.utils.isMessengerUrl +import com.pitchedapps.frost.utils.launchImageActivity +import com.pitchedapps.frost.views.FrostWebView +import dagger.Binds +import dagger.Module +import dagger.hilt.EntryPoint +import dagger.hilt.InstallIn +import javax.inject.Inject +import javax.inject.Qualifier + +/** + * Created by Allan Wang on 2017-05-31. + * + * Collection of webview clients + */ + +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class FrostWebClient + +@EntryPoint +@InstallIn(FrostWebComponent::class) +interface FrostWebClientEntryPoint { + + @FrostWebScoped + @FrostWebClient + fun webClient(): FrostWebViewClient +} + +@Module +@InstallIn(FrostWebComponent::class) +interface FrostWebViewClientModule { + @Binds + @FrostWebClient + fun webClient(binds: FrostWebViewClient2): FrostWebViewClient +} + +/** + * The default webview client + */ +open class FrostWebViewClient2 @Inject constructor( + web: FrostWebView, + @FrostRefresh private val refreshEmit: FrostEmitter<Boolean> +) : FrostWebViewClient(web) { + + init { + L.i { "Refresh web client 2" } + } + + override fun doUpdateVisitedHistory(view: WebView, url: String?, isReload: Boolean) { + super.doUpdateVisitedHistory(view, url, isReload) + urlSupportsRefresh = urlSupportsRefresh(url) + web.parent.swipeAllowedByPage = urlSupportsRefresh + view.jsInject( + JsAssets.AUTO_RESIZE_TEXTAREA.maybe(prefs.autoExpandTextBox), + prefs = prefs + ) + v { "History $url; refresh $urlSupportsRefresh" } + } + + private fun urlSupportsRefresh(url: String?): Boolean { + if (url == null) return false + if (url.isMessengerUrl) return false + if (!url.isFacebookUrl) return true + if (url.contains("soft=composer")) return false + if (url.contains("sharer.php") || url.contains("sharer-dialog.php")) return false + return true + } + + private fun WebView.facebookJsInject() { + jsInject(*facebookJsInjectors.toTypedArray(), prefs = prefs) + } + + private fun WebView.messengerJsInject() { + jsInject( + themeProvider.injector(ThemeCategory.MESSENGER), + prefs = prefs + ) + } + + override fun onPageStarted(view: WebView, url: String?, favicon: Bitmap?) { + super.onPageStarted(view, url, favicon) + if (url == null) return + v { "loading $url ${web.settings.userAgentString}" } + refreshEmit(true) + } + + private fun injectBackgroundColor() { + web.setBackgroundColor( + when { + isMain -> Color.TRANSPARENT + web.url.isFacebookUrl -> themeProvider.bgColor.withAlpha(255) + else -> Color.WHITE + } + ) + } + + override fun onPageCommitVisible(view: WebView, url: String?) { + super.onPageCommitVisible(view, url) + injectBackgroundColor() + when { + url.isFacebookUrl -> { + v { "FB Page commit visible" } + view.facebookJsInject() + } + url.isMessengerUrl -> { + v { "Messenger Page commit visible" } + view.messengerJsInject() + } + else -> { + refreshEmit(false) + } + } + } + + override fun onPageFinished(view: WebView, url: String?) { + url ?: return + v { "finished $url" } + if (!url.isFacebookUrl && !url.isMessengerUrl) { + refreshEmit(false) + return + } + onPageFinishedActions(url) + } + + internal override fun injectAndFinish() { + v { "page finished reveal" } + refreshEmit(false) + injectBackgroundColor() + when { + web.url.isFacebookUrl -> { + web.jsInject( + JsActions.LOGIN_CHECK, + JsAssets.TEXTAREA_LISTENER, + JsAssets.HEADER_BADGES.maybe(isMain), + prefs = prefs + ) + web.facebookJsInject() + } + web.url.isMessengerUrl -> { + web.messengerJsInject() + } + } + } + + /** + * Helper to format the request and launch it + * returns true to override the url + * returns false if we are already in an overlaying activity + */ + private fun launchRequest(request: WebResourceRequest): Boolean { + v { "Launching url: ${request.url}" } + return web.requestWebOverlay(request.url.toString()) + } + + private fun launchImage(url: String, text: String? = null, cookie: String? = null): Boolean { + v { "Launching image: $url" } + web.context.launchImageActivity(url, text, cookie) + if (web.canGoBack()) web.goBack() + return true + } +} + +private const val EMIT_THEME = 0b1 +private const val EMIT_ID = 0b10 +private const val EMIT_COMPLETE = EMIT_THEME or EMIT_ID +private const val EMIT_FINISH = 0 diff --git a/app/src/test/kotlin/com/pitchedapps/frost/kotlin/FlyweightTest.kt b/app/src/test/kotlin/com/pitchedapps/frost/kotlin/FlyweightTest.kt deleted file mode 100644 index 89289322..00000000 --- a/app/src/test/kotlin/com/pitchedapps/frost/kotlin/FlyweightTest.kt +++ /dev/null @@ -1,120 +0,0 @@ -/* - * Copyright 2018 Allan Wang - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - */ -package com.pitchedapps.frost.kotlin - -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.runBlocking -import org.junit.Rule -import org.junit.rules.Timeout -import java.util.concurrent.atomic.AtomicInteger -import kotlin.test.BeforeTest -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertFalse -import kotlin.test.assertTrue -import kotlin.test.fail - -class FlyweightTest { - - @get:Rule - val globalTimeout: Timeout = Timeout.seconds(5) - - lateinit var flyweight: Flyweight<Int, Int> - - lateinit var callCount: AtomicInteger - - private val LONG_RUNNING_KEY = -78 - - @BeforeTest - fun before() { - callCount = AtomicInteger(0) - flyweight = Flyweight(GlobalScope, 200L) { - callCount.incrementAndGet() - when (it) { - LONG_RUNNING_KEY -> Thread.sleep(100000) - else -> Thread.sleep(100) - } - it * 2 - } - } - - @Test - fun basic() { - assertEquals(2, runBlocking { flyweight.fetch(1).await() }, "Invalid result") - assertEquals(1, callCount.get(), "1 call expected") - } - - @Test - fun multipleWithOneKey() { - val results: List<Int> = runBlocking { - (0..1000).map { - flyweight.fetch(1) - }.map { it.await() } - } - assertEquals(1, callCount.get(), "1 call expected") - assertEquals(1001, results.size, "Incorrect number of results returned") - assertTrue(results.all { it == 2 }, "Result should all be 2") - } - - @Test - fun consecutiveReuse() { - runBlocking { - flyweight.fetch(1).await() - assertEquals(1, callCount.get(), "1 call expected") - flyweight.fetch(1).await() - assertEquals(1, callCount.get(), "Reuse expected") - Thread.sleep(300) - flyweight.fetch(1).await() - assertEquals(2, callCount.get(), "Refetch expected") - } - } - - @Test - fun invalidate() { - runBlocking { - flyweight.fetch(1).await() - assertEquals(1, callCount.get(), "1 call expected") - flyweight.invalidate(1) - flyweight.fetch(1).await() - assertEquals(2, callCount.get(), "New call expected") - } - } - - @Test - fun destroy() { - runBlocking { - val longRunningResult = flyweight.fetch(LONG_RUNNING_KEY) - flyweight.fetch(1).await() - flyweight.cancel() - try { - flyweight.fetch(1).await() - fail("Flyweight should not be fulfilled after it is destroyed") - } catch (ignore: CancellationException) { - } - try { - assertFalse( - longRunningResult.isActive, - "Long running result should no longer be active" - ) - longRunningResult.await() - fail("Flyweight should have cancelled previously running requests") - } catch (ignore: CancellationException) { - } - } - } -} diff --git a/app/src/test/kotlin/com/pitchedapps/frost/utils/CoroutineTest.kt b/app/src/test/kotlin/com/pitchedapps/frost/utils/CoroutineTest.kt index 2744d0d8..7acb4761 100644 --- a/app/src/test/kotlin/com/pitchedapps/frost/utils/CoroutineTest.kt +++ b/app/src/test/kotlin/com/pitchedapps/frost/utils/CoroutineTest.kt @@ -16,32 +16,21 @@ */ package com.pitchedapps.frost.utils -import com.pitchedapps.frost.kotlin.Flyweight import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.asCoroutineDispatcher import kotlinx.coroutines.async -import kotlinx.coroutines.channels.BroadcastChannel -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.channels.ReceiveChannel -import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.count import kotlinx.coroutines.flow.filterNotNull -import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.takeWhile import kotlinx.coroutines.joinAll import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.withContext import java.util.concurrent.Executors import kotlin.coroutines.EmptyCoroutineContext -import kotlin.test.Ignore import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -50,82 +39,8 @@ import kotlin.test.assertTrue /** * Collection of tests around coroutines */ -@UseExperimental(ExperimentalCoroutinesApi::class) class CoroutineTest { - /** - * Hooks onto the refresh channel for one true -> false cycle. - * Returns the list of event ids that were emitted - */ - private suspend fun transition(channel: ReceiveChannel<Pair<Boolean, Int>>): List<Pair<Boolean, Int>> { - var refreshed = false - return listen(channel) { (refreshing, _) -> - if (refreshed && !refreshing) - return@listen true - if (refreshing) - refreshed = true - return@listen false - } - } - - private suspend fun <T> listen( - channel: ReceiveChannel<T>, - shouldEnd: suspend (T) -> Boolean = { false } - ): List<T> = - withContext(Dispatchers.IO) { - val data = mutableListOf<T>() - channel.receiveAsFlow() - for (c in channel) { - data.add(c) - if (shouldEnd(c)) break - } - channel.cancel() - return@withContext data - } - - /** - * When refreshing, we have a temporary subscriber that hooks onto a single cycle. - * The refresh channel only contains booleans, but for the sake of identification, - * each boolean will have a unique integer attached. - * - * Things to note: - * Subscription should be opened outside of async, since we don't want to miss any events. - */ - @Test - fun refreshSubscriptions() { - val refreshChannel = BroadcastChannel<Pair<Boolean, Int>>(100) - runBlocking { - // Listen to all events - val fullReceiver = refreshChannel.openSubscription() - val fullDeferred = async { listen(fullReceiver) } - - refreshChannel.send(true to 1) - refreshChannel.send(false to 2) - refreshChannel.send(true to 3) - - val partialReceiver = refreshChannel.openSubscription() - val partialDeferred = async { transition(partialReceiver) } - refreshChannel.send(false to 4) - refreshChannel.send(true to 5) - refreshChannel.send(false to 6) - refreshChannel.send(true to 7) - refreshChannel.close() - val fullStream = fullDeferred.await() - val partialStream = partialDeferred.await() - - assertEquals( - 7, - fullStream.size, - "Full stream should contain all events" - ) - assertEquals( - listOf(false to 4, true to 5, false to 6), - partialStream, - "Partial stream should include up until first true false pair" - ) - } - } - private fun <T : Any> SharedFlow<T?>.takeUntilNull(): Flow<T> = takeWhile { it != null }.filterNotNull() @@ -161,142 +76,4 @@ class CoroutineTest { assertEquals(4, count, "Not all events received") } } - - /** - * Not a true throttle, but for things like fetching header badges, we want to avoid simultaneous fetches. - * As a result, I want to test that the usage of offer along with a conflated channel will work as I expect. - * Events should be consumed when there is no pending consumer on previous elements. - */ - @Test - @Ignore("Move to flow") - fun throttledChannel() { - val channel = Channel<Int>(Channel.CONFLATED) - runBlocking { - val deferred = async { - listen(channel) { - // Throttle consumer - delay(10) - return@listen false - } - } - (0..100).forEach { - channel.offer(it) - delay(1) - } - channel.close() - val received = deferred.await() - assertTrue( - received.size < 20, - "Received data should be throttled; expected that around 1/10th of all events are consumed, but received ${received.size}" - ) - println(received) - } - } - - @Test - fun uniqueOnly() { - val channel = BroadcastChannel<Int>(100) - runBlocking { - val fullReceiver = channel.openSubscription() - val uniqueReceiver = channel.openSubscription().uniqueOnly(this) - - val fullDeferred = async { listen(fullReceiver) } - val uniqueDeferred = async { listen(uniqueReceiver) } - - listOf(0, 1, 2, 3, 3, 3, 4, 3, 5, 5, 1).forEach { - channel.offer(it) - } - channel.close() - - val fullData = fullDeferred.await() - val uniqueData = uniqueDeferred.await() - - assertEquals( - listOf(0, 1, 2, 3, 3, 3, 4, 3, 5, 5, 1), - fullData, - "Full receiver should get all channel events" - ) - assertEquals( - listOf(0, 1, 2, 3, 4, 3, 5, 1), - uniqueData, - "Unique receiver should not have two consecutive events that are equal" - ) - } - } - - /** - * When using [uniqueOnly] for channels with limited capacity, - * the duplicates should not count towards the actual capacity - */ - @Ignore("Not yet working as unique only buffered removes the capacity limitation of the channel") - @Test - fun uniqueOnlyBuffer() { - val channel = Channel<Int>(3) - runBlocking { - - val deferred = async { - listen(channel.uniqueOnly(GlobalScope)) { - // Throttle consumer - delay(50) - return@listen false - } - } - - listOf(0, 1, 1, 1, 1, 1, 2, 2, 2).forEach { - delay(10) - channel.offer(it) - } - - channel.close() - - val data = deferred.await() - - assertEquals( - listOf(0, 1, 2), - data, - "Unique receiver should not have two consecutive events that are equal" - ) - } - } - - class TestException(msg: String) : RuntimeException(msg) - - @Test - fun exceptionChecks() { - val mainTag = "main-test" - val mainDispatcher = Executors.newSingleThreadExecutor { r -> - Thread(r, mainTag) - }.asCoroutineDispatcher() - val channel = Channel<Int>() - - val job = SupervisorJob() - - val flyweight = Flyweight<Int, Int>(GlobalScope, 200L) { - throw TestException("Flyweight exception") - } - - suspend fun crash(): Boolean = withContext(Dispatchers.IO) { - try { - withContext(Dispatchers.Default) { - flyweight.fetch(0).await() - } - true - } catch (e: TestException) { - false - } - } - - runBlocking(mainDispatcher + job) { - launch { - val i = channel.receive() - println("Received $i") - } - launch { - println("A") - println(crash()) - println("B") - channel.offer(1) - } - } - } } |