diff options
author | Allan Wang <me@allanwang.ca> | 2019-01-05 00:26:37 -0500 |
---|---|---|
committer | GitHub <noreply@github.com> | 2019-01-05 00:26:37 -0500 |
commit | 5c89202f74f68ee6f273296014b5fff837520246 (patch) | |
tree | 08245d02eb04045ec2c5d475ce6db4efe481a412 | |
parent | 8c77e02e89dfec7bff04a397dfc82613ccd1242a (diff) | |
parent | 635bdddebbc52ec67cfb157830c3fc8b32f9a6e7 (diff) | |
download | frost-5c89202f74f68ee6f273296014b5fff837520246.tar.gz frost-5c89202f74f68ee6f273296014b5fff837520246.tar.bz2 frost-5c89202f74f68ee6f273296014b5fff837520246.zip |
Merge pull request #1313 from AllanWang/enhancement/deferred
Enhancement/deferred
22 files changed, 184 insertions, 343 deletions
diff --git a/app/build.gradle b/app/build.gradle index 562de936..5fc249bf 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -204,7 +204,7 @@ dependencies { // androidTestImplementation "io.mockk:mockk:${MOCKK}" - implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:${COROUTINES}" + implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:${kau.coroutines}" implementation "org.apache.commons:commons-text:${COMMONS_TEXT}" @@ -251,12 +251,6 @@ dependencies { implementation "com.sothree.slidinguppanel:library:${SLIDING_PANEL}" - //Reactive Libs - implementation "io.reactivex.rxjava2:rxjava:${RX_JAVA}" - implementation "io.reactivex.rxjava2:rxkotlin:${RX_KOTLIN}" - implementation "io.reactivex.rxjava2:rxandroid:${RX_ANDROID}" - implementation "com.github.pwittchen:reactivenetwork-rx2:${RX_NETWORK}" - } // Validates code and generates apk diff --git a/app/src/main/kotlin/com/pitchedapps/frost/FrostApp.kt b/app/src/main/kotlin/com/pitchedapps/frost/FrostApp.kt index 20ad46d9..5b62afad 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/FrostApp.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/FrostApp.kt @@ -44,9 +44,6 @@ import com.raizlabs.android.dbflow.config.DatabaseConfig import com.raizlabs.android.dbflow.config.FlowConfig import com.raizlabs.android.dbflow.config.FlowManager import com.raizlabs.android.dbflow.runtime.ContentResolverNotifier -import io.reactivex.exceptions.UndeliverableException -import io.reactivex.plugins.RxJavaPlugins -import java.net.SocketTimeoutException import java.util.Random import kotlin.reflect.KClass @@ -135,15 +132,6 @@ class FrostApp : Application() { L.d { "Activity ${activity.localClassName} created" } } }) - - RxJavaPlugins.setErrorHandler { - when (it) { - is SocketTimeoutException, is UndeliverableException -> - L.e { "RxJava common error ${it.message}" } - else -> - L.e(it) { "RxJava error" } - } - } } private fun initBugsnag() { diff --git a/app/src/main/kotlin/com/pitchedapps/frost/StartActivity.kt b/app/src/main/kotlin/com/pitchedapps/frost/StartActivity.kt index aa6a0130..7622ff5c 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/StartActivity.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/StartActivity.kt @@ -73,8 +73,8 @@ class StartActivity : KauBaseActivity() { loadFbCookiesSync() }) L.i { "Cookies loaded at time ${System.currentTimeMillis()}" } - L._d { "Cookies: ${cookies.joinToString("\t")}" } - loadAssets() + L._d { "Cookies: ${cookies.joinToString("\t", transform = CookieModel::toSensitiveString)}" } + loadAssets() when { cookies.isEmpty() -> launchNewTask<LoginActivity>() // Has cookies but no selected account diff --git a/app/src/main/kotlin/com/pitchedapps/frost/activities/AboutActivity.kt b/app/src/main/kotlin/com/pitchedapps/frost/activities/AboutActivity.kt index 283477d7..8d849bff 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/activities/AboutActivity.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/activities/AboutActivity.kt @@ -66,9 +66,7 @@ class AboutActivity : AboutActivityBase(null, { val include = arrayOf( "AboutLibraries", "AndroidIconics", - "androidin_appbillingv3", "androidslidinguppanel", - "Crashlytics", "dbflow", "fastadapter", "glide", @@ -77,7 +75,6 @@ class AboutActivity : AboutActivityBase(null, { "kotterknife", "materialdialogs", "materialdrawer", - "rxjava", "subsamplingscaleimageview" ) diff --git a/app/src/main/kotlin/com/pitchedapps/frost/activities/BaseActivity.kt b/app/src/main/kotlin/com/pitchedapps/frost/activities/BaseActivity.kt index 5965e5cf..e514fa14 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/activities/BaseActivity.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/activities/BaseActivity.kt @@ -45,44 +45,6 @@ abstract class BaseActivity : KauBaseActivity() { if (this !is WebOverlayActivityBase) setFrostTheme() } - // -// private var networkDisposable: Disposable? = null -// private var networkConsumer: ((Connectivity) -> Unit)? = null -// -// fun setNetworkObserver(consumer: (connectivity: Connectivity) -> Unit) { -// this.networkConsumer = consumer -// } -// -// private fun observeNetworkConnectivity() { -// val consumer = networkConsumer ?: return -// networkDisposable = ReactiveNetwork.observeNetworkConnectivity(applicationContext) -// .subscribeOn(Schedulers.io()) -// .observeOn(AndroidSchedulers.mainThread()) -// .subscribe { connectivity: Connectivity -> -// connectivity.apply { -// L.d{"Network connectivity changed: isAvailable: $isAvailable isRoaming: $isRoaming"} -// consumer(connectivity) -// } -// } -// } -// -// private fun disposeNetworkConnectivity() { -// if (networkDisposable?.isDisposed == false) -// networkDisposable?.dispose() -// networkDisposable = null -// } -// -// override fun onResume() { -// super.onResume() -//// disposeNetworkConnectivity() -//// observeNetworkConnectivity() -// } -// -// override fun onPause() { -// super.onPause() -//// disposeNetworkConnectivity() -// } - override fun onStop() { if (this is VideoViewHolder) videoOnStop() super.onStop() diff --git a/app/src/main/kotlin/com/pitchedapps/frost/activities/DebugActivity.kt b/app/src/main/kotlin/com/pitchedapps/frost/activities/DebugActivity.kt index 6257e6f1..a1b41830 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/activities/DebugActivity.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/activities/DebugActivity.kt @@ -22,6 +22,7 @@ import android.content.Intent import android.content.res.ColorStateList import android.os.Bundle import ca.allanwang.kau.internal.KauBaseActivity +import ca.allanwang.kau.utils.launchMain import ca.allanwang.kau.utils.setIcon import ca.allanwang.kau.utils.visible import com.mikepenz.google_material_typeface_library.GoogleMaterial @@ -32,12 +33,12 @@ import com.pitchedapps.frost.utils.L import com.pitchedapps.frost.utils.Prefs import com.pitchedapps.frost.utils.createFreshDir import com.pitchedapps.frost.utils.setFrostColors -import io.reactivex.Single -import io.reactivex.android.schedulers.AndroidSchedulers -import io.reactivex.schedulers.Schedulers import kotlinx.android.synthetic.main.activity_debug.* import kotlinx.android.synthetic.main.view_main_fab.* +import kotlinx.coroutines.CoroutineExceptionHandler import java.io.File +import kotlin.coroutines.resume +import kotlin.coroutines.suspendCoroutine /** * Created by Allan Wang on 05/01/18. @@ -74,36 +75,32 @@ class DebugActivity : KauBaseActivity() { fab.setOnClickListener { _ -> fab.hide() - val parent = baseDir(this) - parent.createFreshDir() - val rxScreenshot = Single.fromCallable { - debug_webview.getScreenshot(File(parent, "screenshot.png")) - }.subscribeOn(Schedulers.io()) - val rxBody = Single.create<String> { emitter -> - debug_webview.evaluateJavascript(JsActions.RETURN_BODY.function) { - emitter.onSuccess(it) - } - }.subscribeOn(AndroidSchedulers.mainThread()) - Single.zip(listOf(rxScreenshot, rxBody)) { - val screenshot = it[0] == true - val body = it[1] as? String - screenshot to body - }.observeOn(AndroidSchedulers.mainThread()) - .subscribe { (screenshot, body), err -> - if (err != null) { - L.e { "DebugActivity error ${err.message}" } - setResult(Activity.RESULT_CANCELED) - finish() - return@subscribe + val errorHandler = CoroutineExceptionHandler { _, throwable -> + L.e { "DebugActivity error ${throwable.message}" } + setResult(Activity.RESULT_CANCELED) + finish() + } + + launchMain(errorHandler) { + val parent = baseDir(this@DebugActivity) + parent.createFreshDir() + + val body: String? = suspendCoroutine { cont -> + debug_webview.evaluateJavascript(JsActions.RETURN_BODY.function) { + cont.resume(it) } - val intent = Intent() - intent.putExtra(RESULT_URL, debug_webview.url) - intent.putExtra(RESULT_SCREENSHOT, screenshot) - if (body != null) - intent.putExtra(RESULT_BODY, body) - setResult(Activity.RESULT_OK, intent) - finish() } + + val hasScreenshot: Boolean = debug_webview.getScreenshot(File(parent, "screenshot.png")) + + val intent = Intent() + intent.putExtra(RESULT_URL, debug_webview.url) + intent.putExtra(RESULT_SCREENSHOT, hasScreenshot) + if (body != null) + intent.putExtra(RESULT_BODY, body) + setResult(Activity.RESULT_OK, intent) + finish() + } } } diff --git a/app/src/main/kotlin/com/pitchedapps/frost/activities/LoginActivity.kt b/app/src/main/kotlin/com/pitchedapps/frost/activities/LoginActivity.kt index 150d29f4..f3eb8fe6 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/activities/LoginActivity.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/activities/LoginActivity.kt @@ -33,9 +33,10 @@ import com.bumptech.glide.request.RequestListener import com.bumptech.glide.request.target.Target import com.pitchedapps.frost.R import com.pitchedapps.frost.dbflow.CookieModel -import com.pitchedapps.frost.dbflow.fetchUsername import com.pitchedapps.frost.dbflow.loadFbCookiesSuspend +import com.pitchedapps.frost.dbflow.saveFbCookie import com.pitchedapps.frost.facebook.FbCookie +import com.pitchedapps.frost.facebook.FbItem import com.pitchedapps.frost.facebook.profilePictureUrl import com.pitchedapps.frost.glide.FrostGlide import com.pitchedapps.frost.glide.GlideApp @@ -43,6 +44,7 @@ import com.pitchedapps.frost.glide.transform import com.pitchedapps.frost.utils.L import com.pitchedapps.frost.utils.Showcase 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.setFrostColors @@ -55,6 +57,8 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout +import java.net.UnknownHostException import kotlin.coroutines.resume /** @@ -88,7 +92,7 @@ class LoginActivity : BaseActivity() { } } launch { - val cookie = web.loadLogin { refresh(it != 100) } + val cookie = web.loadLogin { refresh(it != 100) }.await() L.d { "Login found" } FbCookie.save(cookie.id) webFadeOut() @@ -168,11 +172,22 @@ class LoginActivity : BaseActivity() { } private suspend fun loadUsername(cookie: CookieModel): String = withContext(Dispatchers.IO) { - suspendCancellableCoroutine<String> { cont -> - cookie.fetchUsername { - cont.resume(it) + val result: String = try { + withTimeout(5000) { + frostJsoup(cookie.cookie, FbItem.PROFILE.url).title() } + } catch (e: Exception) { + if (e !is UnknownHostException) + e.logFrostEvent("Fetch username failed") + "" } + + if (cookie.name?.isNotBlank() == false && result != cookie.name) { + cookie.name = result + saveFbCookie(cookie) + } + + cookie.name ?: "" } override fun backConsumer(): Boolean { diff --git a/app/src/main/kotlin/com/pitchedapps/frost/dbflow/CookiesDb.kt b/app/src/main/kotlin/com/pitchedapps/frost/dbflow/CookiesDb.kt index e8fb5c54..67953144 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/dbflow/CookiesDb.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/dbflow/CookiesDb.kt @@ -17,11 +17,7 @@ package com.pitchedapps.frost.dbflow import android.os.Parcelable -import com.github.pwittchen.reactivenetwork.library.rx2.ReactiveNetwork -import com.pitchedapps.frost.facebook.FbItem import com.pitchedapps.frost.utils.L -import com.pitchedapps.frost.utils.frostJsoup -import com.pitchedapps.frost.utils.logFrostEvent import com.raizlabs.android.dbflow.annotation.ConflictAction import com.raizlabs.android.dbflow.annotation.Database import com.raizlabs.android.dbflow.annotation.PrimaryKey @@ -34,12 +30,9 @@ import com.raizlabs.android.dbflow.kotlinextensions.save import com.raizlabs.android.dbflow.kotlinextensions.select import com.raizlabs.android.dbflow.kotlinextensions.where import com.raizlabs.android.dbflow.structure.BaseModel -import io.reactivex.disposables.Disposable -import io.reactivex.schedulers.Schedulers import kotlinx.android.parcel.Parcelize import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext -import java.net.UnknownHostException /** * Created by Allan Wang on 2017-05-30. @@ -54,7 +47,12 @@ object CookiesDb { @Parcelize @Table(database = CookiesDb::class, allFields = true, primaryKeyConflict = ConflictAction.REPLACE) data class CookieModel(@PrimaryKey var id: Long = -1L, var name: String? = null, var cookie: String? = null) : - BaseModel(), Parcelable + BaseModel(), Parcelable { + + override fun toString(): String = "CookieModel(${hashCode()})" + + fun toSensitiveString(): String = "CookieModel(id=$id, name=$name, cookie=$cookie)" +} fun loadFbCookie(id: Long): CookieModel? = (select from CookieModel::class where (CookieModel_Table.id eq id)).querySingle() @@ -92,26 +90,3 @@ fun removeCookie(id: Long) { L._d { id } } } - -inline fun CookieModel.fetchUsername(crossinline callback: (String) -> Unit): Disposable = - ReactiveNetwork.checkInternetConnectivity().subscribeOn(Schedulers.io()).subscribe { yes, _ -> - if (!yes) return@subscribe callback("") - var result = "" - try { - result = frostJsoup(cookie, FbItem.PROFILE.url).title() - L.d { "Fetch username found" } - } catch (e: Exception) { - if (e !is UnknownHostException) - e.logFrostEvent("Fetch username failed") - } finally { - if (result.isBlank() && (name?.isNotBlank() == true)) { - callback(name!!) - return@subscribe - } - if (name != result) { - name = result - saveFbCookie(this@fetchUsername) - } - callback(result) - } - } diff --git a/app/src/main/kotlin/com/pitchedapps/frost/facebook/requests/FbRequest.kt b/app/src/main/kotlin/com/pitchedapps/frost/facebook/requests/FbRequest.kt index 53ea6e67..b49fd970 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/facebook/requests/FbRequest.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/facebook/requests/FbRequest.kt @@ -26,10 +26,7 @@ import com.pitchedapps.frost.facebook.USER_AGENT_BASIC import com.pitchedapps.frost.facebook.get import com.pitchedapps.frost.kotlin.Flyweight import com.pitchedapps.frost.utils.L -import io.reactivex.Single -import io.reactivex.schedulers.Schedulers import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.runBlocking import okhttp3.Call import okhttp3.FormBody import okhttp3.OkHttpClient @@ -40,27 +37,11 @@ import org.apache.commons.text.StringEscapeUtils /** * Created by Allan Wang on 21/12/17. */ -val fbAuth = Flyweight<String, RequestAuth>(GlobalScope, 100, 3600000 /* an hour */) { +val fbAuth = Flyweight<String, RequestAuth>(GlobalScope, 3600000 /* an hour */) { it.getAuth() } /** - * Synchronously fetch [RequestAuth] from cookie - * [action] will only be called if a valid auth is found. - * Otherwise, [fail] will be called - */ -fun String?.fbRequest(fail: () -> Unit = {}, action: RequestAuth.() -> Unit) { - if (this == null) return fail() - try { - val auth = runBlocking { fbAuth.fetch(this@fbRequest) } - auth.action() - } catch (e: Exception) { - L.e { "Failed auth for ${hashCode()}: ${e.message}" } - fail() - } -} - -/** * Underlying container for all fb requests */ data class RequestAuth( @@ -136,7 +117,11 @@ fun String.getAuth(): RequestAuth { .call() call.execute().body()?.charStream()?.useLines { lines -> lines.forEach { - val text = StringEscapeUtils.unescapeEcmaScript(it) + val text = try { + StringEscapeUtils.unescapeEcmaScript(it) + } catch (ignore: Exception) { + return@forEach + } val fb_dtsg = FB_DTSG_MATCHER.find(text)[1] if (fb_dtsg != null) { auth = auth.copy(fb_dtsg = fb_dtsg) @@ -154,19 +139,6 @@ fun String.getAuth(): RequestAuth { return auth } -inline fun <T, reified R : Any, O> Array<T>.zip( - crossinline mapper: (List<R>) -> O, - crossinline caller: (T) -> R -): Single<O> { - if (isEmpty()) - return Single.just(mapper(emptyList())) - val singles = map { Single.fromCallable { caller(it) }.subscribeOn(Schedulers.io()) } - return Single.zip(singles) { - val results = it.mapNotNull { it as? R } - mapper(results) - } -} - /** * Execute the call and attempt to check validity * Valid = not blank & no "error" instance diff --git a/app/src/main/kotlin/com/pitchedapps/frost/facebook/requests/Images.kt b/app/src/main/kotlin/com/pitchedapps/frost/facebook/requests/Images.kt index ee18e15e..36dff6ff 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/facebook/requests/Images.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/facebook/requests/Images.kt @@ -133,7 +133,7 @@ class HdImageFetcher(private val model: HdImageMaybe) : DataFetcher<InputStream> val result: Result<InputStream?> = runCatching { runBlocking { withTimeout(20000L) { - val auth = fbAuth.fetch(model.cookie) + val auth = fbAuth.fetch(model.cookie).await() if (cancelled) throw RuntimeException("Cancelled") val url = auth.getFullSizedImage(model.id).invoke() ?: throw RuntimeException("Null url") if (cancelled) throw RuntimeException("Cancelled") diff --git a/app/src/main/kotlin/com/pitchedapps/frost/fragments/RecyclerFragments.kt b/app/src/main/kotlin/com/pitchedapps/frost/fragments/RecyclerFragments.kt index f7ed9937..d9d518b1 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/fragments/RecyclerFragments.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/fragments/RecyclerFragments.kt @@ -74,7 +74,7 @@ class MenuFragment : GenericRecyclerFragment<MenuItemData, IItem<*, *>>() { override suspend fun reloadImpl(progress: (Int) -> Unit): List<MenuItemData>? = withContext(Dispatchers.IO) { val cookie = FbCookie.webCookie ?: return@withContext null progress(10) - val auth = fbAuth.fetch(cookie) + val auth = fbAuth.fetch(cookie).await() progress(30) val data = auth.getMenuData().invoke() ?: return@withContext null if (data.data.isEmpty()) return@withContext null diff --git a/app/src/main/kotlin/com/pitchedapps/frost/glide/GlideUtils.kt b/app/src/main/kotlin/com/pitchedapps/frost/glide/GlideUtils.kt index 0c471c63..870e2ccd 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/glide/GlideUtils.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/glide/GlideUtils.kt @@ -27,7 +27,6 @@ import com.bumptech.glide.load.resource.bitmap.CircleCrop import com.bumptech.glide.module.AppGlideModule import com.bumptech.glide.request.RequestOptions import com.pitchedapps.frost.facebook.FbCookie -import com.pitchedapps.frost.utils.L import okhttp3.Interceptor import okhttp3.OkHttpClient import okhttp3.Response @@ -70,7 +69,6 @@ class FrostCookieInterceptor : Interceptor { override fun intercept(chain: Interceptor.Chain): Response { val origRequest = chain.request() val cookie = FbCookie.webCookie ?: return chain.proceed(origRequest) - L.v { "Add cookie to req $cookie" } val request = origRequest.newBuilder().addHeader("Cookie", cookie).build() return chain.proceed(request) } diff --git a/app/src/main/kotlin/com/pitchedapps/frost/kotlin/Flyweight.kt b/app/src/main/kotlin/com/pitchedapps/frost/kotlin/Flyweight.kt index 7ac80147..914ce151 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/kotlin/Flyweight.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/kotlin/Flyweight.kt @@ -17,6 +17,7 @@ package com.pitchedapps.frost.kotlin import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -25,9 +26,6 @@ import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.selects.select import java.util.concurrent.ConcurrentHashMap -import kotlin.coroutines.Continuation -import kotlin.coroutines.resumeWithException -import kotlin.coroutines.suspendCoroutine /** * Flyweight to keep track of values so long as they are valid. @@ -38,19 +36,16 @@ import kotlin.coroutines.suspendCoroutine */ class Flyweight<K, V>( val scope: CoroutineScope, - capacity: Int, val maxAge: Long, private val fetcher: suspend (K) -> V ) { // Receives a key and a pending request - private val actionChannel = Channel<Pair<K, Continuation<V>>>(capacity) + private val actionChannel = Channel<Pair<K, CompletableDeferred<V>>>(Channel.UNLIMITED) // Receives a key to invalidate the associated value - private val invalidatorChannel = Channel<K>(capacity) - // Receives a key to fetch the value - private val requesterChannel = Channel<K>(capacity) + private val invalidatorChannel = Channel<K>(Channel.UNLIMITED) // Receives a key and the resulting value - private val receiverChannel = Channel<Pair<K, Result<V>>>(capacity) + 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() @@ -58,10 +53,17 @@ class Flyweight<K, V>( 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<Continuation<V>>> = ConcurrentHashMap() + 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()!!) + } + init { job = scope.launch(Dispatchers.IO) { launch { @@ -70,17 +72,17 @@ class Flyweight<K, V>( /* * New request received. Continuation should be fulfilled eventually */ - actionChannel.onReceive { (key, continuation) -> + 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) { - continuation.resumeWith(lastResult) + completable.completeWith(lastResult) } else { val valueRequestPending = key in pendingMap - pendingMap.getOrPut(key) { mutableListOf() }.add(continuation) + pendingMap.getOrPut(key) { mutableListOf() }.add(completable) if (!valueRequestPending) - requesterChannel.send(key) + fulfill(key) } } /* @@ -97,7 +99,7 @@ class Flyweight<K, V>( resultMap.remove(key) if (pendingMap[key]?.isNotEmpty() == true) // Refetch value for pending requests - requesterChannel.send(key) + fulfill(key) } /* * Value request fulfilled. Should now fulfill pending requests @@ -106,33 +108,41 @@ class Flyweight<K, V>( conditionMap[key] = System.currentTimeMillis() resultMap[key] = result pendingMap.remove(key)?.forEach { - it.resumeWith(result) + it.completeWith(result) } } } } } - launch { - /* - * Value request received. Should fetch new value using supplied fetcher - */ - for (key in requesterChannel) { - val result = runCatching { - fetcher(key) - } - receiverChannel.send(key to result) - } - } } } - suspend fun fetch(key: K): V = suspendCoroutine { - if (!job.isActive) it.resumeWithException(IllegalStateException("Flyweight is not active")) - else scope.launch { - actionChannel.send(key to it) + /* + * 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) } @@ -141,12 +151,11 @@ class Flyweight<K, V>( job.cancel() if (pendingMap.isNotEmpty()) { val error = CancellationException("Flyweight cancelled") - pendingMap.values.flatten().forEach { it.resumeWithException(error) } + pendingMap.values.flatten().forEach { it.completeExceptionally(error) } pendingMap.clear() } actionChannel.close() invalidatorChannel.close() - requesterChannel.close() receiverChannel.close() conditionMap.clear() resultMap.clear() diff --git a/app/src/main/kotlin/com/pitchedapps/frost/services/FrostRequestService.kt b/app/src/main/kotlin/com/pitchedapps/frost/services/FrostRequestService.kt index c88f3946..a8ecb27d 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/services/FrostRequestService.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/services/FrostRequestService.kt @@ -179,7 +179,7 @@ class FrostRequestService : BaseJobService() { } launch(Dispatchers.IO) { try { - val auth = fbAuth.fetch(cookie) + val auth = fbAuth.fetch(cookie).await() command.invoke(auth, bundle) L.d { "Finished frost service for ${command.name} in ${System.currentTimeMillis() - startTime} ms" diff --git a/app/src/main/kotlin/com/pitchedapps/frost/web/DebugWebView.kt b/app/src/main/kotlin/com/pitchedapps/frost/web/DebugWebView.kt index 22668309..d2b53ab5 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/web/DebugWebView.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/web/DebugWebView.kt @@ -23,7 +23,6 @@ import android.graphics.Color import android.util.AttributeSet import android.view.View import android.webkit.WebView -import androidx.annotation.WorkerThread import ca.allanwang.kau.utils.withAlpha import com.pitchedapps.frost.facebook.USER_AGENT_BASIC import com.pitchedapps.frost.injectors.CssAssets @@ -33,6 +32,8 @@ import com.pitchedapps.frost.utils.L import com.pitchedapps.frost.utils.Prefs import com.pitchedapps.frost.utils.createFreshFile import com.pitchedapps.frost.utils.isFacebookUrl +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import java.io.File /** @@ -61,14 +62,16 @@ class DebugWebView @JvmOverloads constructor( isDrawingCacheEnabled = true } - @WorkerThread - fun getScreenshot(output: File): Boolean { + /** + * Fetches a screenshot of the current webview, returning true if successful, false otherwise. + */ + suspend fun getScreenshot(output: File): Boolean = withContext(Dispatchers.IO) { if (!output.createFreshFile()) { L.e { "Failed to create ${output.absolutePath} for debug screenshot" } - return false + return@withContext false } - return try { + try { output.outputStream().use { drawingCache.compress(Bitmap.CompressFormat.PNG, 100, it) } diff --git a/app/src/main/kotlin/com/pitchedapps/frost/web/LoginWebView.kt b/app/src/main/kotlin/com/pitchedapps/frost/web/LoginWebView.kt index 8132382a..c21ce93b 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/web/LoginWebView.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/web/LoginWebView.kt @@ -28,7 +28,7 @@ import android.webkit.WebResourceRequest import android.webkit.WebView import ca.allanwang.kau.utils.fadeIn import ca.allanwang.kau.utils.isVisible -import ca.allanwang.kau.utils.withMainContext +import ca.allanwang.kau.utils.launchMain import com.pitchedapps.frost.dbflow.CookieModel import com.pitchedapps.frost.facebook.FB_LOGIN_URL import com.pitchedapps.frost.facebook.FB_USER_MATCHER @@ -40,10 +40,8 @@ import com.pitchedapps.frost.injectors.jsInject import com.pitchedapps.frost.utils.L import com.pitchedapps.frost.utils.Prefs import com.pitchedapps.frost.utils.isFacebookUrl +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.launch -import kotlinx.coroutines.suspendCancellableCoroutine -import kotlin.coroutines.resume /** * Created by Allan Wang on 2017-05-29. @@ -54,7 +52,7 @@ class LoginWebView @JvmOverloads constructor( defStyleAttr: Int = 0 ) : WebView(context, attrs, defStyleAttr) { - private lateinit var loginCallback: (CookieModel) -> Unit + private val completable: CompletableDeferred<CookieModel> = CompletableDeferred() private lateinit var progressCallback: (Int) -> Unit @SuppressLint("SetJavaScriptEnabled") @@ -65,19 +63,15 @@ class LoginWebView @JvmOverloads constructor( webChromeClient = LoginChromeClient() } - suspend fun loadLogin(progressCallback: (Int) -> Unit): CookieModel = withMainContext { - coroutineScope { - suspendCancellableCoroutine<CookieModel> { cont -> - this@LoginWebView.progressCallback = progressCallback - this@LoginWebView.loginCallback = { cont.resume(it) } - L.d { "Begin loading login" } - launch { - FbCookie.reset() - setupWebview() - loadUrl(FB_LOGIN_URL) - } - } + suspend fun loadLogin(progressCallback: (Int) -> Unit): CompletableDeferred<CookieModel> = coroutineScope { + this@LoginWebView.progressCallback = progressCallback + L.d { "Begin loading login" } + launchMain { + FbCookie.reset() + setupWebview() + loadUrl(FB_LOGIN_URL) } + completable } private inner class LoginClient : BaseWebViewClient() { @@ -86,7 +80,7 @@ class LoginWebView @JvmOverloads constructor( super.onPageFinished(view, url) val cookieModel = checkForLogin(url) if (cookieModel != null) - loginCallback(cookieModel) + completable.complete(cookieModel) if (!view.isVisible) view.fadeIn() } diff --git a/app/src/test/kotlin/com/pitchedapps/frost/MiscTest.kt b/app/src/test/kotlin/com/pitchedapps/frost/MiscTest.kt deleted file mode 100644 index 20610b2a..00000000 --- a/app/src/test/kotlin/com/pitchedapps/frost/MiscTest.kt +++ /dev/null @@ -1,48 +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 - -import com.pitchedapps.frost.facebook.requests.zip -import org.junit.Test -import kotlin.test.assertTrue - -/** - * Created by Allan Wang on 2017-06-14. - */ -class MiscTest { - - /** - * Spin off 15 threads - * Pause each for 1 - 2s - * Ensure that total zipped process does not take over 5s - */ - @Test - fun zip() { - val now = System.currentTimeMillis() - val base = 1 - val data: LongArray = (0..15).map { Math.random() + base } - .toTypedArray().zip(List<Long>::toLongArray) { - Thread.sleep((it * 1000).toLong()) - System.currentTimeMillis() - now - }.blockingGet() - println(data.contentToString()) - assertTrue( - data.all { it >= base * 1000 && it < base * 1000 * 5 }, - "zip did not seem to work on different threads" - ) - } -} diff --git a/app/src/test/kotlin/com/pitchedapps/frost/facebook/requests/FbRequestTest.kt b/app/src/test/kotlin/com/pitchedapps/frost/facebook/requests/FbRequestTest.kt index ec765448..8610436a 100644 --- a/app/src/test/kotlin/com/pitchedapps/frost/facebook/requests/FbRequestTest.kt +++ b/app/src/test/kotlin/com/pitchedapps/frost/facebook/requests/FbRequestTest.kt @@ -84,7 +84,7 @@ class FbRequestTest { val data = AUTH.getMenuData().invoke() assertNotNull(data) println(ObjectMapper().writerWithDefaultPrettyPrinter().writeValueAsString(data)) - assertTrue(data!!.data.isNotEmpty()) + assertTrue(data.data.isNotEmpty()) assertTrue(data.footer.hasContent, "Footer may be badly parsed") val items = data.flatMapValid() assertTrue(items.size > 15, "Something may be badly parsed") diff --git a/app/src/test/kotlin/com/pitchedapps/frost/internal/Internal.kt b/app/src/test/kotlin/com/pitchedapps/frost/internal/Internal.kt index 061e7c38..b8d9635a 100644 --- a/app/src/test/kotlin/com/pitchedapps/frost/internal/Internal.kt +++ b/app/src/test/kotlin/com/pitchedapps/frost/internal/Internal.kt @@ -22,13 +22,10 @@ import com.pitchedapps.frost.facebook.get import com.pitchedapps.frost.facebook.requests.RequestAuth import com.pitchedapps.frost.facebook.requests.getAuth import com.pitchedapps.frost.utils.frostJsoup -import io.reactivex.Completable import org.junit.Assume -import org.junit.Test import java.io.File import java.io.FileInputStream import java.util.Properties -import java.util.concurrent.TimeUnit import kotlin.reflect.full.starProjectedType import kotlin.test.assertEquals import kotlin.test.assertTrue @@ -97,34 +94,3 @@ fun Any.assertComponentsNotEmpty() { fun <T : Comparable<T>> List<T>.assertDescending(tag: String) { assertEquals(sortedDescending(), this, "$tag not sorted in descending order") } - -interface CompletableCallback { - fun onComplete() - fun onError(message: String) -} - -inline fun concurrentTest(crossinline caller: (callback: CompletableCallback) -> Unit) { - val result = Completable.create { emitter -> - caller(object : CompletableCallback { - override fun onComplete() = emitter.onComplete() - override fun onError(message: String) = emitter.onError(Throwable(message)) - }) - }.blockingGet(5, TimeUnit.SECONDS) - if (result != null) - throw RuntimeException("Concurrent fail: ${result.message}") -} - -class InternalTest { - @Test - fun concurrentTest() = try { - concurrentTest { result -> - Thread().run { - Thread.sleep(100) - result.onError("Intentional fail") - } - } - fail("Did not throw exception") - } catch (e: Exception) { - // pass - } -} diff --git a/app/src/test/kotlin/com/pitchedapps/frost/kotlin/FlyweightTest.kt b/app/src/test/kotlin/com/pitchedapps/frost/kotlin/FlyweightTest.kt index 0eee530e..d1d976b6 100644 --- a/app/src/test/kotlin/com/pitchedapps/frost/kotlin/FlyweightTest.kt +++ b/app/src/test/kotlin/com/pitchedapps/frost/kotlin/FlyweightTest.kt @@ -16,8 +16,8 @@ */ package com.pitchedapps.frost.kotlin +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.async import kotlinx.coroutines.runBlocking import org.junit.Rule import org.junit.rules.Timeout @@ -25,6 +25,7 @@ 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 @@ -42,7 +43,7 @@ class FlyweightTest { @BeforeTest fun before() { callCount = AtomicInteger(0) - flyweight = Flyweight(GlobalScope, 100, 200L) { + flyweight = Flyweight(GlobalScope, 200L) { callCount.incrementAndGet() when (it) { LONG_RUNNING_KEY -> Thread.sleep(100000) @@ -54,7 +55,7 @@ class FlyweightTest { @Test fun basic() { - assertEquals(2, runBlocking { flyweight.fetch(1) }, "Invalid result") + assertEquals(2, runBlocking { flyweight.fetch(1).await() }, "Invalid result") assertEquals(1, callCount.get(), "1 call expected") } @@ -62,9 +63,7 @@ class FlyweightTest { fun multipleWithOneKey() { val results: List<Int> = runBlocking { (0..1000).map { - flyweight.scope.async { - flyweight.fetch(1) - } + flyweight.fetch(1) }.map { it.await() } } assertEquals(1, callCount.get(), "1 call expected") @@ -75,12 +74,12 @@ class FlyweightTest { @Test fun consecutiveReuse() { runBlocking { - flyweight.fetch(1) + flyweight.fetch(1).await() assertEquals(1, callCount.get(), "1 call expected") - flyweight.fetch(1) + flyweight.fetch(1).await() assertEquals(1, callCount.get(), "Reuse expected") Thread.sleep(300) - flyweight.fetch(1) + flyweight.fetch(1).await() assertEquals(2, callCount.get(), "Refetch expected") } } @@ -88,10 +87,10 @@ class FlyweightTest { @Test fun invalidate() { runBlocking { - flyweight.fetch(1) + flyweight.fetch(1).await() assertEquals(1, callCount.get(), "1 call expected") flyweight.invalidate(1) - flyweight.fetch(1) + flyweight.fetch(1).await() assertEquals(2, callCount.get(), "New call expected") } } @@ -99,24 +98,19 @@ class FlyweightTest { @Test fun destroy() { runBlocking { - val longRunningResult = async { flyweight.fetch(LONG_RUNNING_KEY) } - flyweight.fetch(1) + val longRunningResult = flyweight.fetch(LONG_RUNNING_KEY) + flyweight.fetch(1).await() flyweight.cancel() try { - flyweight.fetch(1) + flyweight.fetch(1).await() fail("Flyweight should not be fulfilled after it is destroyed") - } catch (e: Exception) { - assertEquals("Flyweight is not active", e.message, "Incorrect error found on fetch after destruction") + } 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 (e: Exception) { - assertEquals( - "Flyweight cancelled", - e.message, - "Incorrect error found on fetch cancelled by destruction" - ) + } catch (ignore: CancellationException) { } } } diff --git a/app/src/test/kotlin/com/pitchedapps/frost/utils/CoroutineTest.kt b/app/src/test/kotlin/com/pitchedapps/frost/utils/CoroutineTest.kt index 72eb6076..e7520794 100644 --- a/app/src/test/kotlin/com/pitchedapps/frost/utils/CoroutineTest.kt +++ b/app/src/test/kotlin/com/pitchedapps/frost/utils/CoroutineTest.kt @@ -18,6 +18,7 @@ package com.pitchedapps.frost.utils import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.asCoroutineDispatcher import kotlinx.coroutines.async import kotlinx.coroutines.channels.BroadcastChannel @@ -31,6 +32,7 @@ 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 @@ -206,4 +208,39 @@ class CoroutineTest { ) } } + + /** + * 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" + ) + } + } } diff --git a/gradle.properties b/gradle.properties index 11127a3e..aabfd255 100644 --- a/gradle.properties +++ b/gradle.properties @@ -14,7 +14,7 @@ org.gradle.jvmargs=-Xmx2048m -XX:MaxPermSize=512m -XX:+HeapDumpOnOutOfMemoryErro APP_ID=Frost APP_GROUP=com.pitchedapps -KAU=af43e82 +KAU=72d6461 KOTLIN=1.3.11 # https://mvnrepository.com/artifact/com.android.tools.build/gradle?repo=google @@ -23,14 +23,12 @@ ANDROID_GRADLE=3.2.1 # https://github.com/diffplug/spotless/blob/master/plugin-gradle/CHANGES.md SPOTLESS=3.17.0 -# https://github.com/Kotlin/kotlinx.coroutines/releases -COROUTINES=1.0.1 # https://github.com/bugsnag/bugsnag-android/releases BUGSNAG=4.9.3 # https://github.com/bugsnag/bugsnag-android-gradle-plugin/releases BUGSNAG_PLUGIN=3.6.0 # https://github.com/KeepSafe/dexcount-gradle-plugin/releases -DEX_PLUGIN=0.8.4 +DEX_PLUGIN=0.8.5 # https://github.com/gladed/gradle-android-git-version/releases GIT_PLUGIN=0.4.7 # https://mvnrepository.com/artifact/org.apache.commons/commons-text @@ -59,16 +57,6 @@ MATERIAL_DRAWER_KT=2.0.1 OKHTTP=3.12.1 # http://robolectric.org/getting-started/ ROBOELECTRIC=4.1 -# https://github.com/ReactiveX/RxAndroid/releases -RX_ANDROID=2.1.0 -# https://github.com/JakeWharton/RxBinding/releases -RX_BINDING=2.2.0 -# https://github.com/ReactiveX/RxJava/releases -RX_JAVA=2.2.4 -# https://github.com/ReactiveX/RxKotlin/releases -RX_KOTLIN=2.3.0 -# https://github.com/pwittchen/ReactiveNetwork/releases -RX_NETWORK=2.1.0 # https://github.com/davemorrissey/subsampling-scale-image-view#quick-start SCALE_IMAGE_VIEW=3.10.0 # https://github.com/umano/AndroidSlidingUpPanel#importing-the-library |