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:
commit
120ad1520d
@ -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}"
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
|
@ -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 {
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
|
||||
|
@ -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
|
||||
|
@ -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() {
|
||||
|
@ -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)
|
||||
|
||||
/*
|
||||
* -----------------------------------------
|
||||
|
@ -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() }
|
||||
}
|
@ -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()
|
||||
}
|
||||
}
|
@ -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")
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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(
|
||||
|
@ -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
|
||||
|
87
app/src/main/kotlin/com/pitchedapps/frost/web/FrostWeb.kt
Normal file
87
app/src/main/kotlin/com/pitchedapps/frost/web/FrostWeb.kt
Normal 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
|
@ -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 -> {
|
||||
|
@ -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
|
@ -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
|
@ -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" />
|
||||
|
@ -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) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,5 +1,8 @@
|
||||
# Changelog
|
||||
|
||||
## v3.2.0
|
||||
* Improve loading process
|
||||
|
||||
## v3.1.2
|
||||
* Fix loading full size images
|
||||
* Fix menu tab
|
||||
|
Loading…
Reference in New Issue
Block a user