aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--app/build.gradle3
-rw-r--r--app/src/main/kotlin/com/pitchedapps/frost/activities/BaseMainActivity.kt21
-rw-r--r--app/src/main/kotlin/com/pitchedapps/frost/activities/LoginActivity.kt34
-rw-r--r--app/src/main/kotlin/com/pitchedapps/frost/activities/MainActivity.kt56
-rw-r--r--app/src/main/kotlin/com/pitchedapps/frost/activities/WebOverlayActivity.kt17
-rw-r--r--app/src/main/kotlin/com/pitchedapps/frost/contracts/ActivityContract.kt13
-rw-r--r--app/src/main/kotlin/com/pitchedapps/frost/contracts/FrostContentContract.kt24
-rw-r--r--app/src/main/kotlin/com/pitchedapps/frost/fragments/BaseFragment.kt28
-rw-r--r--app/src/main/kotlin/com/pitchedapps/frost/fragments/FragmentContract.kt10
-rw-r--r--app/src/main/kotlin/com/pitchedapps/frost/kotlin/CoroutineUtils.kt39
-rw-r--r--app/src/main/kotlin/com/pitchedapps/frost/kotlin/Flyweight.kt174
-rw-r--r--app/src/main/kotlin/com/pitchedapps/frost/settings/Debug.kt14
-rw-r--r--app/src/main/kotlin/com/pitchedapps/frost/utils/KotlinUtils.kt36
-rw-r--r--app/src/main/kotlin/com/pitchedapps/frost/views/FrostContentView.kt112
-rw-r--r--app/src/main/kotlin/com/pitchedapps/frost/views/FrostRecyclerView.kt13
-rw-r--r--app/src/main/kotlin/com/pitchedapps/frost/views/FrostWebView.kt56
-rw-r--r--app/src/main/kotlin/com/pitchedapps/frost/web/FrostChromeClients.kt16
-rw-r--r--app/src/main/kotlin/com/pitchedapps/frost/web/FrostJSI.kt13
-rw-r--r--app/src/main/kotlin/com/pitchedapps/frost/web/FrostWeb.kt87
-rw-r--r--app/src/main/kotlin/com/pitchedapps/frost/web/FrostWebViewClients.kt14
-rw-r--r--app/src/main/kotlin/com/pitchedapps/frost/web/FrostWebViewClients2.kt196
-rw-r--r--app/src/main/play/en-US/whatsnew6
-rw-r--r--app/src/main/res/xml/frost_changelog.xml5
-rw-r--r--app/src/test/kotlin/com/pitchedapps/frost/kotlin/FlyweightTest.kt120
-rw-r--r--app/src/test/kotlin/com/pitchedapps/frost/utils/CoroutineTest.kt223
-rw-r--r--docs/Changelog.md3
26 files changed, 529 insertions, 804 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/settings/Debug.kt b/app/src/main/kotlin/com/pitchedapps/frost/settings/Debug.kt
index b2bb1d11..6ba2c64d 100644
--- a/app/src/main/kotlin/com/pitchedapps/frost/settings/Debug.kt
+++ b/app/src/main/kotlin/com/pitchedapps/frost/settings/Debug.kt
@@ -42,7 +42,7 @@ import com.pitchedapps.frost.utils.frostUriFromFile
import com.pitchedapps.frost.utils.sendFrostEmail
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
-import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
import java.io.File
@@ -71,6 +71,7 @@ fun SettingsActivity.getDebugPrefs(): KPrefAdapterBuilder.() -> Unit = {
val parsers = arrayOf(NotifParser, MessageParser, SearchParser)
materialDialog {
+ // noinspection CheckResult
listItems(items = parsers.map { string(it.nameRes) }) { dialog, position, _ ->
dialog.dismiss()
val parser = parsers[position]
@@ -133,20 +134,15 @@ fun SettingsActivity.sendDebug(url: String, html: String?) {
onDismiss { job.cancel() }
}
- val progressChannel = Channel<Int>(10)
+ val progressFlow = MutableStateFlow(0)
- launchMain {
- for (p in progressChannel) {
-// md.setProgress(p)
- }
- }
+// progressFlow.onEach { md.setProgress(it) }.launchIn(this)
launchMain {
val success = downloader.loadAndZip(ZIP_NAME) {
- progressChannel.offer(it)
+ progressFlow.tryEmit(it)
}
md.dismiss()
- progressChannel.close()
if (success) {
val zipUri = frostUriFromFile(
File(downloader.baseDir, "$ZIP_NAME.zip")
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..16c28c02 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,17 +38,27 @@ 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.ReceiveChannel
+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.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
+@ExperimentalCoroutinesApi
class FrostContentWeb @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
@@ -60,6 +69,7 @@ class FrostContentWeb @JvmOverloads constructor(
override val layoutRes: Int = R.layout.view_content_base_web
}
+@ExperimentalCoroutinesApi
class FrostContentRecycler @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
@@ -70,7 +80,7 @@ class FrostContentRecycler @JvmOverloads constructor(
override val layoutRes: Int = R.layout.view_content_base_recycler
}
-@UseExperimental(ExperimentalCoroutinesApi::class)
+@ExperimentalCoroutinesApi
abstract class FrostContentView<out T> @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
@@ -88,8 +98,8 @@ abstract class FrostContentView<out T> @JvmOverloads constructor(
/**
* Subsection of [FrostContentView] that is [AndroidEntryPoint] friendly (no generics)
*/
-@UseExperimental(ExperimentalCoroutinesApi::class)
@AndroidEntryPoint
+@ExperimentalCoroutinesApi
abstract class FrostContentViewBase(
context: Context,
attrs: AttributeSet?,
@@ -119,13 +129,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 +192,6 @@ abstract class FrostContentViewBase(
*/
protected fun init() {
inflate(context, layoutRes, this)
- core.parent = this
reloadThemeSelf()
}
@@ -169,23 +200,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() {
@@ -212,7 +243,6 @@ abstract class FrostContentViewBase(
}
private var transitionStart: Long = -1
- private var refreshReceiver: ReceiveChannel<Boolean>? = null
/**
* Hook onto the refresh observable for one cycle
@@ -220,33 +250,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/main/play/en-US/whatsnew b/app/src/main/play/en-US/whatsnew
index 7ca9e6af..eb46a757 100644
--- a/app/src/main/play/en-US/whatsnew
+++ b/app/src/main/play/en-US/whatsnew
@@ -1,5 +1,3 @@
-v3.1.2
+v3.2.0
-* Fix loading full size images
-* Fix menu tab
-* Always load messenger internally \ No newline at end of file
+* Improve loading process \ No newline at end of file
diff --git a/app/src/main/res/xml/frost_changelog.xml b/app/src/main/res/xml/frost_changelog.xml
index b3227a6e..2bdfef82 100644
--- a/app/src/main/res/xml/frost_changelog.xml
+++ b/app/src/main/res/xml/frost_changelog.xml
@@ -6,6 +6,11 @@
<item text="" />
-->
+ <version title="v3.2.0" />
+ <item text="Improve loading process" />
+ <item text="" />
+ <item text="" />
+
<version title="v3.1.2" />
<item text="Fix loading full size images" />
<item text="Fix menu tab" />
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)
- }
- }
- }
}
diff --git a/docs/Changelog.md b/docs/Changelog.md
index b8dace0a..0e9c638e 100644
--- a/docs/Changelog.md
+++ b/docs/Changelog.md
@@ -1,5 +1,8 @@
# Changelog
+## v3.2.0
+* Improve loading process
+
## v3.1.2
* Fix loading full size images
* Fix menu tab