1
0
mirror of https://github.com/AllanWang/Frost-for-Facebook.git synced 2024-11-09 12:32:30 +01:00

Merge pull request #1857 from AllanWang/flows

This commit is contained in:
Allan Wang 2021-11-23 18:29:05 -08:00 committed by GitHub
commit 120ad1520d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
26 changed files with 531 additions and 806 deletions

View File

@ -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}"

View File

@ -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
}
}

View File

@ -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 {

View File

@ -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)
}
}

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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() {

View File

@ -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)
/*
* -----------------------------------------

View File

@ -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() }
}

View File

@ -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()
}
}

View File

@ -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")

View File

@ -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)
}
}
}

View File

@ -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
}
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
}
}
}
}
}
coreView.transition(animate)
return true
}
private fun View.transition(animate: Boolean) {
L.v { "Registered transition" }
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
}
}
}

View File

@ -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()
}
}

View File

@ -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
}

View File

@ -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(

View File

@ -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

View File

@ -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

View File

@ -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 -> {

View File

@ -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

View File

@ -1,5 +1,3 @@
v3.1.2
v3.2.0
* Fix loading full size images
* Fix menu tab
* Always load messenger internally
* Improve loading process

View File

@ -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" />

View File

@ -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) {
}
}
}
}

View File

@ -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)
}
}
}
}

View File

@ -1,5 +1,8 @@
# Changelog
## v3.2.0
* Improve loading process
## v3.1.2
* Fix loading full size images
* Fix menu tab