mirror of
https://github.com/AllanWang/Frost-for-Facebook.git
synced 2024-11-12 14:03:00 +01:00
Merge pull request #1313 from AllanWang/enhancement/deferred
Enhancement/deferred
This commit is contained in:
commit
5c89202f74
@ -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
|
||||
|
@ -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() {
|
||||
|
@ -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
|
||||
|
@ -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"
|
||||
)
|
||||
|
||||
|
@ -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()
|
||||
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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 {
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
@ -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,26 +37,10 @@ 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
|
||||
*/
|
||||
@ -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
|
||||
|
@ -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")
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -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()
|
||||
|
@ -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"
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -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()
|
||||
}
|
||||
|
||||
|
@ -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"
|
||||
)
|
||||
}
|
||||
}
|
@ -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")
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
|
@ -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) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
|
Loading…
Reference in New Issue
Block a user