diff options
34 files changed, 641 insertions, 592 deletions
diff --git a/app/build.gradle b/app/build.gradle index 23ba05d7..76e96599 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -90,6 +90,8 @@ android { } + def compilerArgs = ["-Xuse-experimental=kotlin.Experimental" /*, "-XXLanguage:+InlineClasses"*/] + buildTypes { debug { minifyEnabled false @@ -101,7 +103,7 @@ android { resValue "string", "frost_web", "Frost Web Debug" ext.enableBugsnag = false - kotlinOptions.freeCompilerArgs += ["-Xuse-experimental=kotlin.Experimental", "-XXLanguage:+InlineClasses"] + kotlinOptions.freeCompilerArgs += compilerArgs } releaseTest { minifyEnabled true @@ -138,9 +140,16 @@ android { targetCompatibility JavaVersion.VERSION_1_8 } - testOptions { - unitTests { - includeAndroidResources = true + testOptions.unitTests { + includeAndroidResources = true + // Don't throw runtime exceptions for android calls that are not mocked + returnDefaultValues = true + + // Always show the result of every unit test, even if it passes. + all { + testLogging { + events 'passed', 'skipped', 'failed', 'standardError' + } } } @@ -163,7 +172,6 @@ dependencies { androidTestImplementation kauDependency.espresso androidTestImplementation kauDependency.testRules androidTestImplementation kauDependency.testRunner - androidTestImplementation "com.squareup.okhttp3:mockwebserver:${OKHTTP}" testImplementation kauDependency.kotlinTest testImplementation "org.jetbrains.kotlin:kotlin-reflect:${KOTLIN}" @@ -232,9 +240,9 @@ dependencies { implementation "com.squareup.okhttp3:okhttp:${OKHTTP}" implementation "com.squareup.okhttp3:logging-interceptor:${OKHTTP}" + testImplementation "com.squareup.okhttp3:mockwebserver:${OKHTTP}" androidTestImplementation "com.squareup.okhttp3:mockwebserver:${OKHTTP}" - implementation "co.zsmb:materialdrawer-kt:${MATERIAL_DRAWER_KT}" implementation "com.bugsnag:bugsnag-android:${BUGSNAG}" diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 75abeece..c05edb53 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -8,11 +8,6 @@ # public static **[] values(); # public static ** valueOf(java.lang.String); #} -# Crashlytics --keepattributes SourceFile,LineNumberTable --keep public class * extends java.lang.Exception --keep class com.crashlytics.** { *; } --dontwarn com.crashlytics.** # JavaScript Interface -keepclassmembers class * { @android.webkit.JavascriptInterface <methods>; @@ -20,8 +15,6 @@ -keepattributes JavascriptInterface # Jsoup -keeppackagenames org.jsoup.nodes -# IAB --keep class com.android.vending.billing.** # Glide -keep public class * implements com.bumptech.glide.module.GlideModule -keep public class * extends com.bumptech.glide.AppGlideModule @@ -37,4 +30,10 @@ -keepnames class com.fasterxml.jackson.** { *; } -keepclassmembers public final enum com.fasterxml.jackson.annotation.JsonAutoDetect$Visibility { public static final com.fasterxml.jackson.annotation.JsonAutoDetect$Visibility *; +} +# Kotlin coroutines +-keepnames class kotlinx.coroutines.internal.MainDispatcherFactory {} +-keepnames class kotlinx.coroutines.CoroutineExceptionHandler {} +-keepclassmembernames class kotlinx.** { + volatile <fields>; }
\ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/pitchedapps/frost/activities/ImageActivityTest.kt b/app/src/androidTest/kotlin/com/pitchedapps/frost/activities/ImageActivityTest.kt index 23f6dab9..d32b956e 100644 --- a/app/src/androidTest/kotlin/com/pitchedapps/frost/activities/ImageActivityTest.kt +++ b/app/src/androidTest/kotlin/com/pitchedapps/frost/activities/ImageActivityTest.kt @@ -62,14 +62,14 @@ class ImageActivityTest { } private val mockServer: MockWebServer by lazy { - val magentaImg = Buffer() - magentaImg.writeAll(Okio.source(getResource("bayer-pattern.jpg"))) + val img = Buffer() + img.writeAll(Okio.source(getResource("bayer-pattern.jpg"))) MockWebServer().apply { setDispatcher(object : Dispatcher() { override fun dispatch(request: RecordedRequest): MockResponse = when { request.path.contains("text") -> MockResponse().setResponseCode(200).setBody("Valid mock text response") - request.path.contains("image") -> MockResponse().setResponseCode(200).setBody(magentaImg) + request.path.contains("image") -> MockResponse().setResponseCode(200).setBody(img) else -> MockResponse().setResponseCode(404).setBody("Error mock response") } }) diff --git a/app/src/androidTest/kotlin/com/pitchedapps/frost/facebook/FbCookieTest.kt b/app/src/androidTest/kotlin/com/pitchedapps/frost/facebook/FbCookieTest.kt index d5c8d2e1..a826391e 100644 --- a/app/src/androidTest/kotlin/com/pitchedapps/frost/facebook/FbCookieTest.kt +++ b/app/src/androidTest/kotlin/com/pitchedapps/frost/facebook/FbCookieTest.kt @@ -1,3 +1,19 @@ +/* + * 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.facebook import android.webkit.CookieManager @@ -10,4 +26,4 @@ class FbCookieTest { fun managerAcceptsCookie() { assertTrue(CookieManager.getInstance().acceptCookie(), "Cookie manager should accept cookie by default") } -}
\ No newline at end of file +} diff --git a/app/src/main/kotlin/com/pitchedapps/frost/FrostApp.kt b/app/src/main/kotlin/com/pitchedapps/frost/FrostApp.kt index 7f3d6b62..fc3751c5 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/FrostApp.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/FrostApp.kt @@ -33,7 +33,6 @@ import com.mikepenz.materialdrawer.util.DrawerImageLoader import com.pitchedapps.frost.dbflow.CookiesDb import com.pitchedapps.frost.dbflow.FbTabsDb import com.pitchedapps.frost.dbflow.NotificationDb -import com.pitchedapps.frost.facebook.FbCookie import com.pitchedapps.frost.glide.GlideApp import com.pitchedapps.frost.services.scheduleNotifications import com.pitchedapps.frost.services.setupNotificationChannels @@ -93,11 +92,6 @@ class FrostApp : Application() { KL.shouldLog = { BuildConfig.DEBUG } Prefs.verboseLogging = false L.i { "Begin Frost for Facebook" } - try { - FbCookie() - } catch (e: Exception) { - // no webview found; error will be handled in start activity - } FrostPglAdBlock.init(this) if (Prefs.installDate == -1L) Prefs.installDate = System.currentTimeMillis() if (Prefs.identifier == -1) Prefs.identifier = Random().nextInt(Int.MAX_VALUE) diff --git a/app/src/main/kotlin/com/pitchedapps/frost/StartActivity.kt b/app/src/main/kotlin/com/pitchedapps/frost/StartActivity.kt index 14cc579f..3fafa2a6 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/StartActivity.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/StartActivity.kt @@ -31,12 +31,15 @@ import com.mikepenz.google_material_typeface_library.GoogleMaterial import com.pitchedapps.frost.activities.LoginActivity import com.pitchedapps.frost.activities.MainActivity import com.pitchedapps.frost.activities.SelectorActivity -import com.pitchedapps.frost.dbflow.loadFbCookiesAsync +import com.pitchedapps.frost.dbflow.loadFbCookiesSync import com.pitchedapps.frost.facebook.FbCookie import com.pitchedapps.frost.utils.EXTRA_COOKIES import com.pitchedapps.frost.utils.L import com.pitchedapps.frost.utils.Prefs import com.pitchedapps.frost.utils.launchNewTask +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import java.util.ArrayList import java.util.IllegalFormatException @@ -52,28 +55,28 @@ class StartActivity : KauBaseActivity() { showInvalidSdkView() return } - - try { - FbCookie.switchBackUser { - loadFbCookiesAsync { - val cookies = ArrayList(it) - L.i { "Cookies loaded at time ${System.currentTimeMillis()}" } - L._d { "Cookies: ${cookies.joinToString("\t")}" } - if (cookies.isNotEmpty()) { - if (Prefs.userId != -1L) - startActivity<MainActivity>(intentBuilder = { - putParcelableArrayListExtra(EXTRA_COOKIES, cookies) - flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP or - Intent.FLAG_ACTIVITY_SINGLE_TOP - }) - else - launchNewTask<SelectorActivity>(cookies) - } else - launchNewTask<LoginActivity>() - } + launch { + try { + FbCookie.switchBackUser() + val cookies = ArrayList(withContext(Dispatchers.IO) { + loadFbCookiesSync() + }) + L.i { "Cookies loaded at time ${System.currentTimeMillis()}" } + L._d { "Cookies: ${cookies.joinToString("\t")}" } + if (cookies.isNotEmpty()) { + if (Prefs.userId != -1L) + startActivity<MainActivity>(intentBuilder = { + putParcelableArrayListExtra(EXTRA_COOKIES, cookies) + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP or + Intent.FLAG_ACTIVITY_SINGLE_TOP + }) + else + launchNewTask<SelectorActivity>(cookies) + } else + launchNewTask<LoginActivity>() + } catch (e: Exception) { + showInvalidWebView() } - } catch (e: Exception) { - showInvalidWebView() } } 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 08b5ab0c..5965e5cf 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/activities/BaseActivity.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/activities/BaseActivity.kt @@ -22,8 +22,6 @@ import ca.allanwang.kau.internal.KauBaseActivity import ca.allanwang.kau.searchview.SearchViewHolder import com.pitchedapps.frost.contracts.VideoViewHolder import com.pitchedapps.frost.utils.setFrostTheme -import io.reactivex.disposables.CompositeDisposable -import io.reactivex.disposables.Disposable /** * Created by Allan Wang on 2017-06-12. @@ -35,8 +33,6 @@ abstract class BaseActivity : KauBaseActivity() { */ protected open fun backConsumer(): Boolean = false - private val compositeDisposable = CompositeDisposable() - final override fun onBackPressed() { if (this is SearchViewHolder && searchViewOnBackPress()) return if (this is VideoViewHolder && videoOnBackPress()) return @@ -49,15 +45,6 @@ abstract class BaseActivity : KauBaseActivity() { if (this !is WebOverlayActivityBase) setFrostTheme() } - override fun onDestroy() { - compositeDisposable.dispose() - super.onDestroy() - } - - fun Disposable.disposeOnDestroy() { - compositeDisposable.add(this) - } - // // private var networkDisposable: Disposable? = null // private var networkConsumer: ((Connectivity) -> Unit)? = null diff --git a/app/src/main/kotlin/com/pitchedapps/frost/activities/BaseMainActivity.kt b/app/src/main/kotlin/com/pitchedapps/frost/activities/BaseMainActivity.kt index 7f69cc27..13253bcf 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/activities/BaseMainActivity.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/activities/BaseMainActivity.kt @@ -108,6 +108,7 @@ import kotlinx.android.synthetic.main.view_main_fab.* import kotlinx.android.synthetic.main.view_main_toolbar.* import kotlinx.android.synthetic.main.view_main_viewpager.* import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.launch /** * Created by Allan Wang on 20/12/17. @@ -276,7 +277,10 @@ abstract class BaseMainActivity : BaseActivity(), MainActivityContract, val currentCookie = loadFbCookie(Prefs.userId) if (currentCookie == null) { toast(R.string.account_not_found) - FbCookie.reset { launchLogin(cookies(), true) } + launch { + FbCookie.reset() + launchLogin(cookies(), true) + } } else { materialDialogThemed { title(R.string.kau_logout) @@ -288,15 +292,22 @@ abstract class BaseMainActivity : BaseActivity(), MainActivityContract, ) positiveText(R.string.kau_yes) negativeText(R.string.kau_no) - onPositive { _, _ -> FbCookie.logout(this@BaseMainActivity) } + onPositive { _, _ -> + launch { + FbCookie.logout(this@BaseMainActivity) + } + } } } } -3L -> launchNewTask<LoginActivity>(clearStack = false) -4L -> launchNewTask<SelectorActivity>(cookies(), false) else -> { - FbCookie.switchUser(profile.identifier, this@BaseMainActivity::refreshAll) - tabsForEachView { _, view -> view.badgeText = null } + launch { + FbCookie.switchUser(profile.identifier) + tabsForEachView { _, view -> view.badgeText = null } + refreshAll() + } } } false @@ -456,12 +467,14 @@ abstract class BaseMainActivity : BaseActivity(), MainActivityContract, override fun onResume() { super.onResume() - FbCookie.switchBackUser {} + val shouldReload = System.currentTimeMillis() - lastAccessTime > MAIN_TIMEOUT_DURATION + lastAccessTime = System.currentTimeMillis() // precaution to avoid loops controlWebview?.resumeTimers() - if (System.currentTimeMillis() - lastAccessTime > MAIN_TIMEOUT_DURATION) { - refreshAll() + launch { + FbCookie.switchBackUser() + if (shouldReload) + refreshAll() } - lastAccessTime = System.currentTimeMillis() // precaution to avoid loops } override fun onPause() { 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 8b5fe38d..9540636a 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/activities/LoginActivity.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/activities/LoginActivity.kt @@ -18,7 +18,6 @@ package com.pitchedapps.frost.activities import android.graphics.drawable.Drawable import android.os.Bundle -import android.os.Handler import android.widget.ImageView import androidx.appcompat.widget.AppCompatTextView import androidx.appcompat.widget.Toolbar @@ -26,6 +25,7 @@ import androidx.swiperefreshlayout.widget.SwipeRefreshLayout import ca.allanwang.kau.utils.bindView import ca.allanwang.kau.utils.fadeIn import ca.allanwang.kau.utils.fadeOut +import ca.allanwang.kau.utils.withMainContext import com.bumptech.glide.RequestManager import com.bumptech.glide.load.DataSource import com.bumptech.glide.load.engine.GlideException @@ -34,7 +34,7 @@ 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.loadFbCookiesAsync +import com.pitchedapps.frost.dbflow.loadFbCookiesSuspend import com.pitchedapps.frost.facebook.FbCookie import com.pitchedapps.frost.facebook.profilePictureUrl import com.pitchedapps.frost.glide.FrostGlide @@ -46,11 +46,16 @@ import com.pitchedapps.frost.utils.frostEvent import com.pitchedapps.frost.utils.launchNewTask import com.pitchedapps.frost.utils.logFrostEvent import com.pitchedapps.frost.utils.setFrostColors +import com.pitchedapps.frost.utils.uniqueOnly import com.pitchedapps.frost.web.LoginWebView -import io.reactivex.Single -import io.reactivex.android.schedulers.AndroidSchedulers -import io.reactivex.functions.BiFunction -import io.reactivex.subjects.SingleSubject +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withContext +import kotlin.coroutines.resume /** * Created by Allan Wang on 2017-06-01. @@ -63,18 +68,8 @@ class LoginActivity : BaseActivity() { private val textview: AppCompatTextView by bindView(R.id.textview) private val profile: ImageView by bindView(R.id.profile) - private val profileSubject = SingleSubject.create<Boolean>() - private val usernameSubject = SingleSubject.create<String>() private lateinit var profileLoader: RequestManager - - // Helper to set and enable swipeRefresh - private var refresh: Boolean - get() = swipeRefresh.isRefreshing - set(value) { - if (value) swipeRefresh.isEnabled = true - swipeRefresh.isRefreshing = value - if (!value) swipeRefresh.isEnabled = false - } + private val refreshChannel = Channel<Boolean>(10) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -84,80 +79,96 @@ class LoginActivity : BaseActivity() { setFrostColors { toolbar(toolbar) } - web.loadLogin({ refresh = it != 100 }) { cookie -> + profileLoader = GlideApp.with(profile) + launch { + for (refreshing in refreshChannel.uniqueOnly(this)) { + if (refreshing) swipeRefresh.isEnabled = true + swipeRefresh.isRefreshing = refreshing + if (!refreshing) swipeRefresh.isEnabled = false + } + } + launch { + val cookie = web.loadLogin { refresh(it != 100) } L.d { "Login found" } FbCookie.save(cookie.id) web.fadeOut(onFinish = { profile.fadeIn() - loadInfo(cookie) + launch { loadInfo(cookie) } }) } - profileLoader = GlideApp.with(profile) } - private fun loadInfo(cookie: CookieModel) { - refresh = true - Single.zip<Boolean, String, Pair<Boolean, String>>( - profileSubject, - usernameSubject, - BiFunction(::Pair) - ) - .observeOn(AndroidSchedulers.mainThread()).subscribe { (foundImage, name) -> - refresh = false - if (!foundImage) { - L.e { "Could not get profile photo; Invalid userId?" } - L._i { cookie } - } - textview.text = String.format(getString(R.string.welcome), name) - textview.fadeIn() - frostEvent("Login", "success" to true) - /* - * The user may have logged into an account that is already in the database - * We will let the db handle duplicates and load it now after the new account has been saved - */ - loadFbCookiesAsync { - val cookies = ArrayList(it) - Handler().postDelayed({ - if (Showcase.intro) - launchNewTask<IntroActivity>(cookies, true) - else - launchNewTask<MainActivity>(cookies, true) - }, 1000) - } - }.disposeOnDestroy() - loadProfile(cookie.id) - loadUsername(cookie) + private fun refresh(refreshing: Boolean) { + refreshChannel.offer(refreshing) } - private fun loadProfile(id: Long) { - profileLoader.load(profilePictureUrl(id)) - .transform(FrostGlide.roundCorner).listener(object : RequestListener<Drawable> { - override fun onResourceReady( - resource: Drawable?, - model: Any?, - target: Target<Drawable>?, - dataSource: DataSource?, - isFirstResource: Boolean - ): Boolean { - profileSubject.onSuccess(true) - return false - } - - override fun onLoadFailed( - e: GlideException?, - model: Any?, - target: Target<Drawable>?, - isFirstResource: Boolean - ): Boolean { - e.logFrostEvent("Profile loading exception") - profileSubject.onSuccess(false) - return false - } - }).into(profile) + private suspend fun loadInfo(cookie: CookieModel): Unit = withMainContext { + refresh(true) + + val imageDeferred = async { loadProfile(cookie.id) } + val nameDeferred = async { loadUsername(cookie) } + + val foundImage = imageDeferred.await() + val name = nameDeferred.await() + + refresh(false) + + if (!foundImage) { + L.e { "Could not get profile photo; Invalid userId?" } + L._i { cookie } + } + + textview.text = String.format(getString(R.string.welcome), name) + textview.fadeIn() + frostEvent("Login", "success" to true) + + /* + * The user may have logged into an account that is already in the database + * We will let the db handle duplicates and load it now after the new account has been saved + */ + val cookies = ArrayList(loadFbCookiesSuspend()) + delay(1000) + if (Showcase.intro) + launchNewTask<IntroActivity>(cookies, true) + else + launchNewTask<MainActivity>(cookies, true) + } + + private suspend fun loadProfile(id: Long): Boolean = withMainContext { + suspendCancellableCoroutine<Boolean> { cont -> + profileLoader.load(profilePictureUrl(id)) + .transform(FrostGlide.roundCorner).listener(object : RequestListener<Drawable> { + override fun onResourceReady( + resource: Drawable?, + model: Any?, + target: Target<Drawable>?, + dataSource: DataSource?, + isFirstResource: Boolean + ): Boolean { + cont.resume(true) + return false + } + + override fun onLoadFailed( + e: GlideException?, + model: Any?, + target: Target<Drawable>?, + isFirstResource: Boolean + ): Boolean { + e.logFrostEvent("Profile loading exception") + cont.resume(false) + return false + } + }).into(profile) + } } - private fun loadUsername(cookie: CookieModel) { - cookie.fetchUsername(usernameSubject::onSuccess).disposeOnDestroy() + private suspend fun loadUsername(cookie: CookieModel): String = withContext(Dispatchers.IO) { + suspendCancellableCoroutine<String> { cont -> + cookie.fetchUsername { + cont.resume(it) + } + } } override fun backConsumer(): Boolean { diff --git a/app/src/main/kotlin/com/pitchedapps/frost/activities/MainActivity.kt b/app/src/main/kotlin/com/pitchedapps/frost/activities/MainActivity.kt index 90f38e9e..9a0c6270 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/activities/MainActivity.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/activities/MainActivity.kt @@ -18,6 +18,7 @@ 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.utils.L @@ -27,7 +28,6 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.channels.BroadcastChannel import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext import org.jsoup.Jsoup @UseExperimental(ExperimentalCoroutinesApi::class) @@ -97,8 +97,8 @@ class MainActivity : BaseMainActivity() { .map { "[data-sigil*=$it] [data-sigil=count]" } .map { doc.select(it) } .map { e -> e?.getOrNull(0)?.ownText() } - L._d { "Badges $feed $requests $messages $notifications" } - withContext(Dispatchers.Main) { + L.v { "Badges $feed $requests $messages $notifications" } + withMainContext { tabsForEachView { _, view -> when (view.iicon) { FbItem.FEED.icon -> view.badgeText = feed diff --git a/app/src/main/kotlin/com/pitchedapps/frost/activities/SelectorActivity.kt b/app/src/main/kotlin/com/pitchedapps/frost/activities/SelectorActivity.kt index 2907bac6..c3224237 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/activities/SelectorActivity.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/activities/SelectorActivity.kt @@ -32,6 +32,7 @@ import com.pitchedapps.frost.utils.cookies import com.pitchedapps.frost.utils.launchNewTask import com.pitchedapps.frost.utils.setFrostColors import com.pitchedapps.frost.views.AccountItem +import kotlinx.coroutines.launch /** * Created by Allan Wang on 2017-06-04. @@ -55,7 +56,10 @@ class SelectorActivity : BaseActivity() { override fun onClick(v: View, position: Int, fastAdapter: FastAdapter<AccountItem>, item: AccountItem) { if (item.cookie == null) this@SelectorActivity.launchNewTask<LoginActivity>() - else FbCookie.switchUser(item.cookie) { launchNewTask<MainActivity>(cookies()) } + else launch { + FbCookie.switchUser(item.cookie) + launchNewTask<MainActivity>(cookies()) + } } }) setFrostColors { diff --git a/app/src/main/kotlin/com/pitchedapps/frost/activities/WebOverlayActivity.kt b/app/src/main/kotlin/com/pitchedapps/frost/activities/WebOverlayActivity.kt index 19a1109f..a8c25050 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/activities/WebOverlayActivity.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/activities/WebOverlayActivity.kt @@ -43,6 +43,7 @@ import ca.allanwang.kau.utils.tint import ca.allanwang.kau.utils.toDrawable import ca.allanwang.kau.utils.toast import ca.allanwang.kau.utils.withAlpha +import ca.allanwang.kau.utils.withMainContext import com.google.android.material.snackbar.BaseTransientBottomBar import com.mikepenz.community_material_typeface_library.CommunityMaterial import com.mikepenz.google_material_typeface_library.GoogleMaterial @@ -74,7 +75,6 @@ import com.pitchedapps.frost.views.FrostWebView import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext import okhttp3.HttpUrl /** @@ -106,7 +106,7 @@ class FrostWebActivity : WebOverlayActivityBase(false) { content.scope.launch(Dispatchers.IO) { refreshReceiver.receive() refreshReceiver.cancel() - withContext(Dispatchers.Main) { + withMainContext { materialDialogThemed { title(R.string.invalid_share_url) content(R.string.invalid_share_url_desc) @@ -216,12 +216,15 @@ open class WebOverlayActivityBase(private val forceBasicAgent: Boolean) : BaseAc if (forceBasicAgent) //todo check; the webview already adds it dynamically userAgentString = USER_AGENT_BASIC Prefs.prevId = Prefs.userId - if (userId != Prefs.userId) FbCookie.switchUser(userId) { reloadBase(true) } - else reloadBase(true) - if (Showcase.firstWebOverlay) { - coordinator.frostSnackbar(R.string.web_overlay_swipe_hint) { - duration = BaseTransientBottomBar.LENGTH_INDEFINITE - setAction(R.string.kau_got_it) { _ -> this.dismiss() } + launch { + if (userId != Prefs.userId) + FbCookie.switchUser(userId) + reloadBase(true) + if (Showcase.firstWebOverlay) { + coordinator.frostSnackbar(R.string.web_overlay_swipe_hint) { + duration = BaseTransientBottomBar.LENGTH_INDEFINITE + setAction(R.string.kau_got_it) { dismiss() } + } } } } diff --git a/app/src/main/kotlin/com/pitchedapps/frost/contracts/ActivityContract.kt b/app/src/main/kotlin/com/pitchedapps/frost/contracts/ActivityContract.kt index 17753ce6..81469ff3 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/contracts/ActivityContract.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/contracts/ActivityContract.kt @@ -19,6 +19,7 @@ package com.pitchedapps.frost.contracts import com.mikepenz.iconics.typeface.IIcon import com.pitchedapps.frost.activities.MainActivity import com.pitchedapps.frost.fragments.BaseFragment +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.channels.BroadcastChannel import kotlinx.coroutines.channels.Channel @@ -27,6 +28,7 @@ import kotlinx.coroutines.channels.Channel */ interface ActivityContract : FileChooserActivityContract +@UseExperimental(ExperimentalCoroutinesApi::class) interface MainActivityContract : ActivityContract, MainFabContract { val fragmentChannel: BroadcastChannel<Int> val headerBadgeChannel: Channel<String> 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 8678f997..e8fb5c54 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/dbflow/CookiesDb.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/dbflow/CookiesDb.kt @@ -37,6 +37,8 @@ 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 /** @@ -71,6 +73,11 @@ fun loadFbCookiesAsync(callback: (cookies: List<CookieModel>) -> Unit) { fun loadFbCookiesSync(): List<CookieModel> = (select from CookieModel::class).orderBy(CookieModel_Table.name, true).queryList() +// TODO temp method until dbflow supports coroutines +suspend fun loadFbCookiesSuspend(): List<CookieModel> = withContext(Dispatchers.IO) { + loadFbCookiesSync() +} + inline fun saveFbCookie(cookie: CookieModel, crossinline callback: (() -> Unit) = {}) { cookie.async save { L.d { "Fb cookie saved" } diff --git a/app/src/main/kotlin/com/pitchedapps/frost/debugger/OfflineWebsite.kt b/app/src/main/kotlin/com/pitchedapps/frost/debugger/OfflineWebsite.kt index f5009cc5..30c812db 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/debugger/OfflineWebsite.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/debugger/OfflineWebsite.kt @@ -17,19 +17,21 @@ package com.pitchedapps.frost.debugger import ca.allanwang.kau.logging.KauLoggerExtension +import ca.allanwang.kau.utils.copyFromInputStream import com.pitchedapps.frost.facebook.FB_CSS_URL_MATCHER import com.pitchedapps.frost.facebook.USER_AGENT_BASIC import com.pitchedapps.frost.facebook.get import com.pitchedapps.frost.facebook.requests.call -import com.pitchedapps.frost.facebook.requests.zip import com.pitchedapps.frost.utils.createFreshDir import com.pitchedapps.frost.utils.createFreshFile import com.pitchedapps.frost.utils.frostJsoup import com.pitchedapps.frost.utils.unescapeHtml -import io.reactivex.disposables.CompositeDisposable -import io.reactivex.rxkotlin.addTo +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.withContext +import kotlinx.coroutines.yield +import okhttp3.HttpUrl import okhttp3.Request -import okhttp3.ResponseBody import org.jsoup.Jsoup import org.jsoup.nodes.Document import org.jsoup.nodes.Element @@ -63,13 +65,14 @@ class OfflineWebsite( /** * Supplied url without the queries */ - private val baseUrl = (baseUrl ?: url.substringBefore("?") - .substringBefore(".com")).trim('/') + private val baseUrl: String = baseUrl ?: run { + val url: HttpUrl = HttpUrl.parse(url) ?: throw IllegalArgumentException("Malformed url") + return@run "${url.scheme()}://${url.host()}" + } private val mainFile = File(baseDir, "index.html") private val assetDir = File(baseDir, "assets") - private var cancelled = false private val urlMapper = ConcurrentHashMap<String, String>() private val atomicInt = AtomicInteger() @@ -91,35 +94,33 @@ class OfflineWebsite( .get() .call() - private val compositeDisposable = CompositeDisposable() - /** * Caller to bind callbacks and start the load * Callback is guaranteed to be called unless the load is cancelled */ - fun load(progress: (Int) -> Unit = {}, callback: (Boolean) -> Unit) { + suspend fun load(progress: (Int) -> Unit = {}): Boolean = withContext(Dispatchers.IO) { reset() L.v { "Saving $url to ${baseDir.absolutePath}" } - if (!baseDir.exists() && !baseDir.mkdirs()) { + if (!baseDir.isDirectory && !baseDir.mkdirs()) { L.e { "Could not make directory" } - return callback(false) + return@withContext false } if (!mainFile.createNewFile()) { L.e { "Could not create ${mainFile.absolutePath}" } - return callback(false) + return@withContext false } if (!assetDir.createFreshDir()) { L.e { "Could not create ${assetDir.absolutePath}" } - return callback(false) + return@withContext false } progress(10) - if (cancelled) return + yield() val doc: Document if (html == null || html.length < 100) { @@ -132,10 +133,10 @@ class OfflineWebsite( doc.outputSettings().escapeMode(Entities.EscapeMode.extended) if (doc.childNodeSize() == 0) { L.e { "No content found" } - return callback(false) + return@withContext false } - if (cancelled) return + yield() progress(35) @@ -151,32 +152,41 @@ class OfflineWebsite( it.attr("href", absLink) } - if (cancelled) return + yield() mainFile.writeText(doc.html()) progress(50) - downloadCss().subscribe { cssLinks, cssThrowable -> + fun partialProgress(from: Int, to: Int, steps: Int): (Int) -> Unit { + if (steps == 0) return { progress(to) } + val section = (to - from) / steps + return { progress(from + it * section) } + } - if (cssThrowable != null) { - L.e { "CSS parsing failed: ${cssThrowable.message} $cssThrowable" } - callback(false) - return@subscribe - } + val cssProgress = partialProgress(50, 70, cssQueue.size) - progress(70) + cssQueue.clean().forEachIndexed { index, url -> + yield() + cssProgress(index) + val newUrls = downloadCss(url) + fileQueue.addAll(newUrls) + } - fileQueue.addAll(cssLinks) + progress(70) - if (cancelled) return@subscribe + val fileProgress = partialProgress(70, 100, fileQueue.size) - downloadFiles().subscribe { success, throwable -> - L.v { "All files downloaded: $success with throwable $throwable" } - progress(100) - callback(true) - } - }.addTo(compositeDisposable) + fileQueue.clean().forEachIndexed { index, url -> + yield() + fileProgress(index) + if (!downloadFile(url)) + return@withContext false + } + + yield() + progress(100) + return@withContext true } fun zip(name: String): Boolean { @@ -198,11 +208,12 @@ class OfflineWebsite( out.closeEntry() delete() } + baseDir.listFiles { file -> file != zip } + .forEach { it.zip() } + assetDir.listFiles() + .forEach { it.zip("assets/${it.name}") } - baseDir.listFiles { _, n -> n != "$name.zip" }.forEach { it.zip() } - assetDir.listFiles().forEach { - it.zip("assets/${it.name}") - } + assetDir.delete() } return true } catch (e: Exception) { @@ -211,76 +222,55 @@ class OfflineWebsite( } } - fun loadAndZip(name: String, progress: (Int) -> Unit = {}, callback: (Boolean) -> Unit) { - - load({ progress((it * 0.85f).toInt()) }) { - if (cancelled) return@load - if (!it) callback(false) - else { - val result = zip(name) - progress(100) - callback(result) - } + suspend fun loadAndZip(name: String, progress: (Int) -> Unit = {}): Boolean = withContext(Dispatchers.IO) { + coroutineScope { + val success = load { progress((it * 0.85f).toInt()) } + if (!success) return@coroutineScope false + val result = zip(name) + progress(100) + return@coroutineScope result } } - private fun downloadFiles() = fileQueue.clean().toTypedArray().zip<String, Boolean, Boolean>({ - it.all { self -> self } - }, { - it.downloadUrl({ false }) { file, body -> - body.byteStream().use { input -> - file.outputStream().use { output -> - input.copyTo(output) - return@downloadUrl true - } - } + private fun downloadFile(url: String): Boolean { + return try { + val file = File(assetDir, fileName(url)) + file.createNewFile() + val stream = request(url).execute().body()?.byteStream() + ?: throw IllegalArgumentException("Response body not found for $url") + file.copyFromInputStream(stream) + true + } catch (e: Exception) { + L.e(e) { "Download file failed" } + false } - }) + } + + private fun downloadCss(url: String): Set<String> { + return try { + val file = File(assetDir, fileName(url)) + file.createNewFile() - private fun downloadCss() = cssQueue.clean().toTypedArray().zip<String, Set<String>, Set<String>>({ - it.flatMap { l -> l }.toSet() - }, { cssUrl -> - cssUrl.downloadUrl({ emptySet() }) { file, body -> - var content = body.string() + var content = request(url).execute().body()?.string() + ?: throw IllegalArgumentException("Response body not found for $url") val links = FB_CSS_URL_MATCHER.findAll(content).mapNotNull { it[1] } val absLinks = links.mapNotNull { - val url = when { + val newUrl = when { it.startsWith("http") -> it it.startsWith("/") -> "$baseUrl$it" else -> return@mapNotNull null } // css files are already in the asset folder, // so the url does not point to another subfolder - content = content.replace(it, url.fileName()) - url + content = content.replace(it, fileName(newUrl)) + newUrl }.toSet() - L.v { "Abs links $absLinks" } - file.writeText(content) - return@downloadUrl absLinks - } - }) - - private inline fun <T> String.downloadUrl( - fallback: () -> T, - action: (file: File, body: ResponseBody) -> T - ): T { - - val file = File(assetDir, fileName()) - if (!file.createNewFile()) { - L.e { "Could not create path for ${file.absolutePath}" } - return fallback() - } - - val body = request(this).execute().body() ?: return fallback() - - try { - body.use { - return action(file, it) - } + absLinks } catch (e: Exception) { - return fallback() + L.e(e) { "Download css failed" } + emptySet() } } @@ -291,7 +281,7 @@ class OfflineWebsite( val absLink = it.attr("abs:$key") if (!absLink.isValid) return@forEach collector.add(absLink) - it.attr(key, "assets/${absLink.fileName()}") + it.attr(key, "assets/${fileName(absLink)}") } } @@ -303,11 +293,11 @@ class OfflineWebsite( * or create a new one * This is thread-safe */ - private fun String.fileName(): String { - val mapped = urlMapper[this] + private fun fileName(url: String): String { + val mapped = urlMapper[url] if (mapped != null) return mapped - val candidate = substringBefore("?").trim('/') + val candidate = url.substringBefore("?").trim('/') .substringAfterLast("/").shorten() val index = atomicInt.getAndIncrement() @@ -321,7 +311,7 @@ class OfflineWebsite( if (newUrl.endsWith(".js")) newUrl = "$newUrl.txt" - urlMapper[this] = newUrl + urlMapper[url] = newUrl return newUrl } @@ -332,16 +322,9 @@ class OfflineWebsite( filter(String::isNotBlank).filter { it.startsWith("http") } private fun reset() { - cancelled = false urlMapper.clear() atomicInt.set(0) fileQueue.clear() cssQueue.clear() } - - fun cancel() { - cancelled = true - compositeDisposable.dispose() - L.v { "Request cancelled" } - } } diff --git a/app/src/main/kotlin/com/pitchedapps/frost/facebook/FbCookie.kt b/app/src/main/kotlin/com/pitchedapps/frost/facebook/FbCookie.kt index 2eb37ba4..5683526a 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/facebook/FbCookie.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/facebook/FbCookie.kt @@ -23,19 +23,14 @@ import com.pitchedapps.frost.dbflow.CookieModel import com.pitchedapps.frost.dbflow.loadFbCookie import com.pitchedapps.frost.dbflow.removeCookie import com.pitchedapps.frost.dbflow.saveFbCookie -import com.pitchedapps.frost.facebook.FbCookie.webCookie import com.pitchedapps.frost.utils.L import com.pitchedapps.frost.utils.Prefs import com.pitchedapps.frost.utils.cookies import com.pitchedapps.frost.utils.launchLogin -import io.reactivex.Observable -import io.reactivex.android.schedulers.AndroidSchedulers -import io.reactivex.subjects.SingleSubject -import kotlinx.coroutines.Deferred -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.launch -import kotlinx.coroutines.yield +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.withContext import kotlin.coroutines.resume import kotlin.coroutines.suspendCoroutine @@ -46,7 +41,7 @@ import kotlin.coroutines.suspendCoroutine */ object FbCookie { - const val COOKIE_DOMAIN = FACEBOOK_COM + const val COOKIE_DOMAIN = FB_URL_BASE /** * Retrieves the facebook cookie if it exists @@ -55,49 +50,29 @@ object FbCookie { inline val webCookie: String? get() = CookieManager.getInstance().getCookie(COOKIE_DOMAIN) - private fun CookieManager.setWebCookie(cookie: String?, callback: (() -> Unit)?) { - removeAllCookies { _ -> - if (cookie == null) { - callback?.invoke() - return@removeAllCookies - } - L.d { "Setting cookie" } - val cookies = cookie.split(";").map { Pair(it, SingleSubject.create<Boolean>()) } - cookies.forEach { (cookie, callback) -> setCookie(COOKIE_DOMAIN, cookie) { callback.onSuccess(it) } } - Observable.zip<Boolean, Unit>(cookies.map { (_, callback) -> callback.toObservable() }) {} - .observeOn(AndroidSchedulers.mainThread()) - .subscribe { - callback?.invoke() - L.d { "Cookies set" } - L._d { cookie } - flush() - } - } - } - private suspend fun CookieManager.suspendSetWebCookie(cookie: String?): Boolean { cookie ?: return true - L.test { "Orig $webCookie" } - removeAllCookies() - L.test { "Save $cookie" } - // Save all cookies regardless of result, then check if all succeeded - val result = cookie.split(";").map { setSingleWebCookie(it) }.all { it } - L.test { "AAAA $webCookie" } - flush() - L.test { "SSSS $webCookie" } - return result + return withContext(NonCancellable) { + removeAllCookies() + // Save all cookies regardless of result, then check if all succeeded + val result = cookie.split(";") + .map { async { setSingleWebCookie(it) } } + .awaitAll().all { it } + flush() + L.d { "Cookies set" } + L._d { "Set $cookie\n\tResult $webCookie" } + result + } } private suspend fun CookieManager.removeAllCookies(): Boolean = suspendCoroutine { cont -> removeAllCookies { - L.test { "Removed all cookies $webCookie" } cont.resume(it) } } private suspend fun CookieManager.setSingleWebCookie(cookie: String): Boolean = suspendCoroutine { cont -> setCookie(COOKIE_DOMAIN, cookie.trim()) { - L.test { "Save single $cookie\n\n\t$webCookie" } cont.resume(it) } } @@ -110,67 +85,63 @@ object FbCookie { saveFbCookie(cookie) } - fun reset(callback: () -> Unit) { + suspend fun reset() { Prefs.userId = -1L with(CookieManager.getInstance()) { - removeAllCookies { - flush() - callback() - } + removeAllCookies() + flush() } } - fun switchUser(id: Long, callback: () -> Unit) = switchUser(loadFbCookie(id), callback) + suspend fun switchUser(id: Long) = switchUser(loadFbCookie(id)) - fun switchUser(name: String, callback: () -> Unit) = switchUser(loadFbCookie(name), callback) + suspend fun switchUser(name: String) = switchUser(loadFbCookie(name)) - fun switchUser(cookie: CookieModel?, callback: () -> Unit) { + suspend fun switchUser(cookie: CookieModel?) { if (cookie == null) { L.d { "Switching User; null cookie" } - callback() return } - L.d { "Switching User" } - Prefs.userId = cookie.id - CookieManager.getInstance().setWebCookie(cookie.cookie, callback) + withContext(NonCancellable) { + L.d { "Switching User" } + Prefs.userId = cookie.id + CookieManager.getInstance().suspendSetWebCookie(cookie.cookie) + } } /** * Helper function to remove the current cookies * and launch the proper login page */ - fun logout(context: Context) { + suspend fun logout(context: Context) { val cookies = arrayListOf<CookieModel>() if (context is Activity) cookies.addAll(context.cookies().filter { it.id != Prefs.userId }) - logout(Prefs.userId) { - context.launchLogin(cookies, true) - } + logout(Prefs.userId) + context.launchLogin(cookies, true) } /** * Clear the cookies of the given id */ - fun logout(id: Long, callback: () -> Unit) { + suspend fun logout(id: Long) { L.d { "Logging out user" } removeCookie(id) - reset(callback) + reset() } /** * Notifications may come from different accounts, and we need to switch the cookies to load them * When coming back to the main app, switch back to our original account before continuing */ - fun switchBackUser(callback: () -> Unit) { - if (Prefs.prevId == -1L) return callback() + suspend fun switchBackUser() { + if (Prefs.prevId == -1L) return val prevId = Prefs.prevId Prefs.prevId = -1L if (prevId != Prefs.userId) { - switchUser(prevId) { - L.d { "Switch back user" } - L._d { "${Prefs.userId} to $prevId" } - callback() - } - } else callback() + switchUser(prevId) + L.d { "Switch back user" } + L._d { "${Prefs.userId} to $prevId" } + } } } 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 50da367d..53ea6e67 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 @@ -24,7 +24,7 @@ import com.pitchedapps.frost.facebook.FB_URL_BASE import com.pitchedapps.frost.facebook.FB_USER_MATCHER import com.pitchedapps.frost.facebook.USER_AGENT_BASIC import com.pitchedapps.frost.facebook.get -import com.pitchedapps.frost.rx.Flyweight +import com.pitchedapps.frost.kotlin.Flyweight import com.pitchedapps.frost.utils.L import io.reactivex.Single import io.reactivex.schedulers.Schedulers diff --git a/app/src/main/kotlin/com/pitchedapps/frost/facebook/requests/Notifications.kt b/app/src/main/kotlin/com/pitchedapps/frost/facebook/requests/Notifications.kt index 43815b67..a098b6c0 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/facebook/requests/Notifications.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/facebook/requests/Notifications.kt @@ -35,9 +35,4 @@ fun RequestAuth.markNotificationRead(notifId: Long): FrostRequest<Boolean> { url("${FB_URL_BASE}a/jewel_notifications_log.php") post(body.toForm()) } -} - -fun RequestAuth.markNotificationsRead(vararg notifId: Long) = - notifId.toTypedArray().zip<Long, Boolean, Boolean>( - { it.all { self -> self } }, - { markNotificationRead(it).invoke() }) +}
\ No newline at end of file diff --git a/app/src/main/kotlin/com/pitchedapps/frost/fragments/FragmentBase.kt b/app/src/main/kotlin/com/pitchedapps/frost/fragments/FragmentBase.kt index 72150ddd..ea549026 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/fragments/FragmentBase.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/fragments/FragmentBase.kt @@ -21,6 +21,7 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.fragment.app.Fragment +import ca.allanwang.kau.utils.ContextHelper import ca.allanwang.kau.utils.fadeScaleTransition import ca.allanwang.kau.utils.setIcon import ca.allanwang.kau.utils.withArguments @@ -39,7 +40,6 @@ import com.pitchedapps.frost.utils.REQUEST_REFRESH import com.pitchedapps.frost.utils.REQUEST_TEXT_ZOOM import com.pitchedapps.frost.utils.frostEvent import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob @@ -80,7 +80,7 @@ abstract class BaseFragment : Fragment(), CoroutineScope, FragmentContract, Dyna open lateinit var job: Job override val coroutineContext: CoroutineContext - get() = Dispatchers.Main + job + get() = ContextHelper.dispatcher + job override val baseUrl: String by lazy { arguments!!.getString(ARG_URL) } override val baseEnum: FbItem by lazy { FbItem[arguments]!! } diff --git a/app/src/main/kotlin/com/pitchedapps/frost/fragments/RecyclerFragmentBase.kt b/app/src/main/kotlin/com/pitchedapps/frost/fragments/RecyclerFragmentBase.kt index 7a8309ff..00d04a3e 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/fragments/RecyclerFragmentBase.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/fragments/RecyclerFragmentBase.kt @@ -17,6 +17,7 @@ package com.pitchedapps.frost.fragments import ca.allanwang.kau.adapters.fastAdapter +import ca.allanwang.kau.utils.withMainContext import com.mikepenz.fastadapter.FastAdapter import com.mikepenz.fastadapter.IItem import com.mikepenz.fastadapter.adapters.ItemAdapter @@ -29,7 +30,6 @@ import com.pitchedapps.frost.utils.L import com.pitchedapps.frost.utils.frostJsoup import com.pitchedapps.frost.views.FrostRecyclerView import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.isActive import kotlinx.coroutines.withContext /** @@ -49,22 +49,20 @@ abstract class RecyclerFragment<T, Item : IItem<*, *>> : BaseFragment(), Recycle } } - final override suspend fun reload(progress: (Int) -> Unit): Boolean { + final override suspend fun reload(progress: (Int) -> Unit): Boolean = withContext(Dispatchers.IO) { val data = try { reloadImpl(progress) } catch (e: Exception) { L.e(e) { "Recycler reload fail" } null } - if (!isActive) - return false - return withContext(Dispatchers.Main) { + withMainContext { if (data == null) { valid = false - return@withContext false + false } else { adapter.setNewList(data) - return@withContext true + true } } } @@ -134,6 +132,7 @@ abstract class FrostParserFragment<T : Any, Item : IItem<*, *>> : RecyclerFragme val response = parser.parse(cookie, doc) if (response == null) { L.i { "RecyclerFragment failed for ${baseEnum.name}" } + L._d { "Cookie used: $cookie" } return@withContext null } progress(80) diff --git a/app/src/main/kotlin/com/pitchedapps/frost/rx/Flyweight.kt b/app/src/main/kotlin/com/pitchedapps/frost/kotlin/Flyweight.kt index 8bba4c3c..7ac80147 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/rx/Flyweight.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/kotlin/Flyweight.kt @@ -14,7 +14,7 @@ * 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.rx +package com.pitchedapps.frost.kotlin import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope diff --git a/app/src/main/kotlin/com/pitchedapps/frost/rx/RxFlyweight.kt b/app/src/main/kotlin/com/pitchedapps/frost/rx/RxFlyweight.kt deleted file mode 100644 index 25f6d6aa..00000000 --- a/app/src/main/kotlin/com/pitchedapps/frost/rx/RxFlyweight.kt +++ /dev/null @@ -1,101 +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.rx - -import io.reactivex.Single -import io.reactivex.schedulers.Schedulers -import java.util.concurrent.TimeUnit - -/** - * Created by Allan Wang on 07/01/18. - * - * Reactive flyweight to help deal with prolonged executions - * Each call will output a [Single], which may be new if none exist or the old one is invalidated, - * or reused if an old one is still valid - * - * Types: - * T input argument for caller - * C condition condition to check against for validity - * R response response within reactive output - */ -abstract class RxFlyweight<in T : Any, C : Any, R : Any> { - - /** - * Given an input emit the desired response - * This will be executed in a separate thread - */ - protected abstract fun call(input: T): R - - /** - * Given an input and condition, check if - * we may used cache data or if we need to make a new request - * Return [true] to use cache, [false] otherwise - */ - protected abstract fun validate(input: T, cond: C): Boolean - - /** - * Given an input, create a new condition to be used - * for future requests - */ - protected abstract fun cache(input: T): C - - private val conditionals = mutableMapOf<T, C>() - private val sources = mutableMapOf<T, Single<R>>() - - private val lock = Any() - - /** - * Entry point to give an input a receive a [Single] - * Note that the observer is not bound to any particular thread, - * as it is dependent on [createNewSource] - */ - operator fun invoke(input: T): Single<R> { - synchronized(lock) { - val source = sources[input] - - // update condition and retrieve old one - val condition = conditionals.put(input, cache(input)) - - // check to reuse observable - if (source != null && condition != null && validate(input, condition)) - return source - - val newSource = createNewSource(input).cache().doOnError { sources.remove(input) } - - sources[input] = newSource - return newSource - } - } - - /** - * Open source creator - * Result will then be created with [Single.cache] - * If you don't have a need for cache, - * you likely won't have a need for flyweights - */ - protected open fun createNewSource(input: T): Single<R> = - Single.fromCallable { call(input) } - .timeout(15, TimeUnit.SECONDS) - .subscribeOn(Schedulers.io()) - - fun reset() { - synchronized(lock) { - sources.clear() - conditionals.clear() - } - } -} diff --git a/app/src/main/kotlin/com/pitchedapps/frost/services/BaseJobService.kt b/app/src/main/kotlin/com/pitchedapps/frost/services/BaseJobService.kt index 3cc7deaf..0db08d0f 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/services/BaseJobService.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/services/BaseJobService.kt @@ -19,8 +19,8 @@ package com.pitchedapps.frost.services import android.app.job.JobParameters import android.app.job.JobService import androidx.annotation.CallSuper +import ca.allanwang.kau.utils.ContextHelper import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlin.coroutines.CoroutineContext @@ -28,7 +28,7 @@ abstract class BaseJobService : JobService(), CoroutineScope { private lateinit var job: Job override val coroutineContext: CoroutineContext - get() = Dispatchers.Main + job + get() = ContextHelper.dispatcher + job protected val startTime = System.currentTimeMillis() diff --git a/app/src/main/kotlin/com/pitchedapps/frost/services/NotificationService.kt b/app/src/main/kotlin/com/pitchedapps/frost/services/NotificationService.kt index 7360c191..b1e0ac8c 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/services/NotificationService.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/services/NotificationService.kt @@ -30,6 +30,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import kotlinx.coroutines.yield /** * Created by Allan Wang on 2017-06-14. @@ -81,11 +82,11 @@ class NotificationService : BaseJobService() { private suspend fun sendNotifications(params: JobParameters?): Unit = withContext(Dispatchers.Default) { val currentId = Prefs.userId val cookies = loadFbCookiesSync() - if (!isActive) return@withContext + yield() val jobId = params?.extras?.getInt(NOTIFICATION_PARAM_ID, -1) ?: -1 var notifCount = 0 for (cookie in cookies) { - if (!isActive) break + yield() val current = cookie.id == currentId if (Prefs.notificationsGeneral && (current || Prefs.notificationAllAccounts) diff --git a/app/src/main/kotlin/com/pitchedapps/frost/settings/Debug.kt b/app/src/main/kotlin/com/pitchedapps/frost/settings/Debug.kt index ece1f677..08f13a10 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/settings/Debug.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/settings/Debug.kt @@ -18,10 +18,12 @@ package com.pitchedapps.frost.settings import android.content.Context import ca.allanwang.kau.kpref.activity.KPrefAdapterBuilder +import ca.allanwang.kau.utils.launchMain import ca.allanwang.kau.utils.materialDialog import ca.allanwang.kau.utils.startActivityForResult import ca.allanwang.kau.utils.string import ca.allanwang.kau.utils.toast +import ca.allanwang.kau.utils.withMainContext import com.pitchedapps.frost.R import com.pitchedapps.frost.activities.DebugActivity import com.pitchedapps.frost.activities.SettingsActivity @@ -39,9 +41,7 @@ import com.pitchedapps.frost.utils.sendFrostEmail import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.isActive import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext import java.io.File /** @@ -87,9 +87,7 @@ fun SettingsActivity.getDebugPrefs(): KPrefAdapterBuilder.() -> Unit = { attempt = launch(Dispatchers.IO) { try { val data = parser.parse(FbCookie.webCookie) - withContext(Dispatchers.Main) { - if (!isActive) - return@withContext + withMainContext { loading.dismiss() createEmail(parser, data?.data) } @@ -120,43 +118,44 @@ fun SettingsActivity.sendDebug(url: String, html: String?) { baseDir = DebugActivity.baseDir(this) ) + val job = Job() + val md = materialDialog { title(R.string.parsing_data) progress(false, 100) negativeText(R.string.kau_cancel) onNegative { dialog, _ -> dialog.dismiss() } canceledOnTouchOutside(false) - dismissListener { downloader.cancel() } + dismissListener { job.cancel() } } val progressChannel = Channel<Int>(10) - launch(Dispatchers.Main) { + launchMain { for (p in progressChannel) { md.setProgress(p) } } - launch(Dispatchers.IO) { - downloader.loadAndZip(ZIP_NAME, { progressChannel.offer(it) }) { success -> - launch(Dispatchers.Main) { - if (!isActive) return@launch - md.dismiss() - if (success) { - val zipUri = frostUriFromFile( - File(downloader.baseDir, "$ZIP_NAME.zip") - ) - L.i { "Sending debug zip with uri $zipUri" } - sendFrostEmail(R.string.debug_report_email_title) { - addItem("Url", url) - addAttachment(zipUri) - extras = { - type = "application/zip" - } - } - } else { - toast(R.string.error_generic) + + launchMain { + val success = downloader.loadAndZip(ZIP_NAME) { + progressChannel.offer(it) + } + md.dismiss() + if (success) { + val zipUri = frostUriFromFile( + File(downloader.baseDir, "$ZIP_NAME.zip") + ) + L.i { "Sending debug zip with uri $zipUri" } + sendFrostEmail(R.string.debug_report_email_title) { + addItem("Url", url) + addAttachment(zipUri) + extras = { + type = "application/zip" } } + } else { + toast(R.string.error_generic) } } } diff --git a/app/src/main/kotlin/com/pitchedapps/frost/utils/Utils.kt b/app/src/main/kotlin/com/pitchedapps/frost/utils/Utils.kt index cca7ace0..3f92c41d 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/utils/Utils.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/utils/Utils.kt @@ -70,6 +70,9 @@ import com.pitchedapps.frost.facebook.FbItem import com.pitchedapps.frost.facebook.FbUrlFormatter.Companion.VIDEO_REDIRECT import com.pitchedapps.frost.facebook.USER_AGENT_BASIC import com.pitchedapps.frost.facebook.formattedFbUrl +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch import org.apache.commons.text.StringEscapeUtils import org.jsoup.Jsoup import org.jsoup.nodes.Element @@ -88,6 +91,14 @@ const val ARG_IMAGE_URL = "arg_image_url" const val ARG_TEXT = "arg_text" const val ARG_COOKIE = "arg_cookie" +/** + * Most context items implement [CoroutineScope] by default. + * We will add a fallback just in case. + * It is expected that the scope returned always has the Android main dispatcher as part of the context. + */ +internal inline val Context.ctxCoroutine: CoroutineScope + get() = this as? CoroutineScope ?: GlobalScope + inline fun <reified T : Activity> Context.launchNewTask( cookieList: ArrayList<CookieModel> = arrayListOf(), clearStack: Boolean = false @@ -116,7 +127,9 @@ private inline fun <reified T : WebOverlayActivityBase> Context.launchWebOverlay L.v { "Launch received: $url\nLaunch web overlay: $argUrl" } if (argUrl.isFacebookUrl && argUrl.contains("/logout.php")) { L.d { "Logout php found" } - FbCookie.logout(this) + ctxCoroutine.launch { + FbCookie.logout(this@launchWebOverlayImpl) + } } else if (!(Prefs.linksInDefaultApp && resolveActivityForUri(Uri.parse(argUrl)))) startActivity<T>(false, intentBuilder = { putExtra(ARG_URL, argUrl) @@ -375,10 +388,8 @@ fun frostJsoup(url: String) = frostJsoup(FbCookie.webCookie, url) fun frostJsoup(cookie: String?, url: String) = Jsoup.connect(url).run { - if (cookie != null) cookie( - FACEBOOK_COM, - cookie - ) else this + if (cookie.isNullOrBlank()) this + else cookie(FACEBOOK_COM, cookie) }.userAgent(USER_AGENT_BASIC).get()!! fun Element.first(vararg select: String): Element? { diff --git a/app/src/main/kotlin/com/pitchedapps/frost/views/FrostContentView.kt b/app/src/main/kotlin/com/pitchedapps/frost/views/FrostContentView.kt index 9619eecc..72d8803c 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/views/FrostContentView.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/views/FrostContentView.kt @@ -29,6 +29,7 @@ import ca.allanwang.kau.utils.fadeIn import ca.allanwang.kau.utils.fadeOut import ca.allanwang.kau.utils.invisibleIf import ca.allanwang.kau.utils.isVisible +import ca.allanwang.kau.utils.launchMain import ca.allanwang.kau.utils.tint import ca.allanwang.kau.utils.withAlpha import com.pitchedapps.frost.R @@ -39,14 +40,11 @@ import com.pitchedapps.frost.facebook.FbItem import com.pitchedapps.frost.facebook.WEB_LOAD_DELAY import com.pitchedapps.frost.utils.L import com.pitchedapps.frost.utils.Prefs -import io.reactivex.disposables.Disposable import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.channels.BroadcastChannel import kotlinx.coroutines.channels.ReceiveChannel import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext class FrostContentWeb @JvmOverloads constructor( context: Context, @@ -128,24 +126,20 @@ abstract class FrostContentView<out T> @JvmOverloads constructor( val refreshReceiver = refreshChannel.openSubscription() val progressReceiver = progressChannel.openSubscription() - scope.launch(Dispatchers.Default) { + scope.launchMain { launch { for (r in refreshReceiver) { - withContext(Dispatchers.Main) { - refresh.isRefreshing = r - refresh.isEnabled = true - } + refresh.isRefreshing = r + refresh.isEnabled = true } } launch { for (p in progressReceiver) { - withContext(Dispatchers.Main) { - progress.invisibleIf(p == 100) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) - progress.setProgress(p, true) - else - progress.progress = p - } + progress.invisibleIf(p == 100) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) + progress.setProgress(p, true) + else + progress.progress = p } } } @@ -177,7 +171,6 @@ abstract class FrostContentView<out T> @JvmOverloads constructor( core.destroy() } - private var dispose: Disposable? = null private var transitionStart: Long = -1 private var refreshReceiver: ReceiveChannel<Boolean>? = null @@ -194,7 +187,7 @@ abstract class FrostContentView<out T> @JvmOverloads constructor( L.v { "Registered transition" } with(coreView) { refreshReceiver = refreshChannel.openSubscription().also { receiver -> - scope.launch(Dispatchers.Main) { + scope.launchMain { var loading = false for (r in receiver) { if (r) { diff --git a/app/src/main/kotlin/com/pitchedapps/frost/views/FrostRecyclerView.kt b/app/src/main/kotlin/com/pitchedapps/frost/views/FrostRecyclerView.kt index f7cb2214..860bf36c 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/views/FrostRecyclerView.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/views/FrostRecyclerView.kt @@ -28,12 +28,14 @@ import com.pitchedapps.frost.contracts.FrostContentCore import com.pitchedapps.frost.contracts.FrostContentParent import com.pitchedapps.frost.fragments.RecyclerContentContract import com.pitchedapps.frost.utils.Prefs +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.launch /** * Created by Allan Wang on 2017-05-29. * */ +@UseExperimental(ExperimentalCoroutinesApi::class) class FrostRecyclerView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, diff --git a/app/src/main/kotlin/com/pitchedapps/frost/web/FrostJSI.kt b/app/src/main/kotlin/com/pitchedapps/frost/web/FrostJSI.kt index c8b54e7a..19d16e87 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/web/FrostJSI.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/web/FrostJSI.kt @@ -16,31 +16,35 @@ */ package com.pitchedapps.frost.web +import android.content.Context import android.webkit.JavascriptInterface import com.pitchedapps.frost.activities.MainActivity import com.pitchedapps.frost.contracts.MainActivityContract import com.pitchedapps.frost.contracts.VideoViewHolder +import com.pitchedapps.frost.dbflow.CookieModel import com.pitchedapps.frost.facebook.FbCookie import com.pitchedapps.frost.utils.L import com.pitchedapps.frost.utils.Prefs import com.pitchedapps.frost.utils.WebContext import com.pitchedapps.frost.utils.cookies +import com.pitchedapps.frost.utils.ctxCoroutine 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 /** * Created by Allan Wang on 2017-06-01. */ class FrostJSI(val web: FrostWebView) { - private val context = web.context - private val activity = context as? MainActivity + private val context: Context = web.context + private val activity: MainActivity? = context as? MainActivity private val header: SendChannel<String>? = activity?.headerBadgeChannel private val refresh: SendChannel<Boolean> = web.parent.refreshChannel - private val cookies = activity?.cookies() ?: arrayListOf() + private val cookies: List<CookieModel> = activity?.cookies() ?: arrayListOf() /** * Attempts to load the url in an overlay @@ -103,7 +107,9 @@ class FrostJSI(val web: FrostWebView) { @JavascriptInterface fun loadLogin() { L.d { "Sign up button found; load login" } - FbCookie.logout(context) + context.ctxCoroutine.launch { + FbCookie.logout(context) + } } /** 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 2fe78f02..8132382a 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/web/LoginWebView.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/web/LoginWebView.kt @@ -28,6 +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 com.pitchedapps.frost.dbflow.CookieModel import com.pitchedapps.frost.facebook.FB_LOGIN_URL import com.pitchedapps.frost.facebook.FB_USER_MATCHER @@ -39,6 +40,10 @@ 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.coroutineScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlin.coroutines.resume /** * Created by Allan Wang on 2017-05-29. @@ -60,13 +65,18 @@ class LoginWebView @JvmOverloads constructor( webChromeClient = LoginChromeClient() } - fun loadLogin(progressCallback: (Int) -> Unit, loginCallback: (CookieModel) -> Unit) { - this.progressCallback = progressCallback - this.loginCallback = loginCallback - L.d { "Begin loading login" } - FbCookie.reset { - setupWebview() - loadUrl(FB_LOGIN_URL) + 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) + } + } } } diff --git a/app/src/test/kotlin/com/pitchedapps/frost/debugger/OfflineWebsiteTest.kt b/app/src/test/kotlin/com/pitchedapps/frost/debugger/OfflineWebsiteTest.kt index f7dad4d3..07c92fbf 100644 --- a/app/src/test/kotlin/com/pitchedapps/frost/debugger/OfflineWebsiteTest.kt +++ b/app/src/test/kotlin/com/pitchedapps/frost/debugger/OfflineWebsiteTest.kt @@ -18,24 +18,231 @@ package com.pitchedapps.frost.debugger import com.pitchedapps.frost.facebook.FB_URL_BASE import com.pitchedapps.frost.internal.COOKIE +import kotlinx.coroutines.runBlocking +import okhttp3.mockwebserver.Dispatcher +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import okhttp3.mockwebserver.RecordedRequest +import org.junit.Assume.assumeTrue import org.junit.Test import java.io.File -import java.util.concurrent.CountDownLatch +import java.util.zip.ZipFile +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue /** * Created by Allan Wang on 05/01/18. */ class OfflineWebsiteTest { + lateinit var server: MockWebServer + lateinit var baseDir: File + + @BeforeTest + fun before() { + val buildPath = if (File("").absoluteFile.name == "app") "build/offline_test" else "app/build/offline_test" + baseDir = File(buildPath) + assertTrue(baseDir.deleteRecursively(), "Failed to clean base dir") + server = MockWebServer() + server.start() + } + + @AfterTest + fun after() { + server.shutdown() + } + + private fun zipAndFetch(url: String = server.url("/").toString(), cookie: String = ""): ZipFile { + val name = "test${System.currentTimeMillis()}" + runBlocking { + val success = OfflineWebsite(url, cookie, baseDir = baseDir) + .loadAndZip(name) + assertTrue(success, "An error occurred") + } + + return ZipFile(File(baseDir, "$name.zip")) + } + + private val tagWhitespaceRegex = Regex(">\\s+<", setOf(RegexOption.MULTILINE)) + + private fun ZipFile.assertContentEquals(path: String, content: String) { + val entry = getEntry(path) + assertNotNull(entry, "Entry $path not found") + val actualContent = getInputStream(entry).bufferedReader().use { it.readText() } + assertEquals( + content.replace(tagWhitespaceRegex, "><").toLowerCase(), + actualContent.replace(tagWhitespaceRegex, "><").toLowerCase(), "Content mismatch for $path" + ) + } + + @Test + fun fbOffline() { + // Not really a test. Skip in CI + assumeTrue(COOKIE.isNotEmpty()) + zipAndFetch(FB_URL_BASE) + } + + @Test + fun basicSingleFile() { + val content = """ + <!DOCTYPE html> + <html> + <head></head> + <body> + <h1>Single File Test</h1> + </body> + </html> + """.trimIndent() + + server.enqueue(MockResponse().setBody(content)) + + val zip = zipAndFetch() + + assertEquals(1, zip.size(), "1 file expected") + zip.assertContentEquals("index.html", content) + } + @Test - fun basic() { - val countdown = CountDownLatch(1) - val buildPath = if (File(".").parentFile?.name == "app") "build/offline_test" else "app/build/offline_test" - OfflineWebsite(FB_URL_BASE, COOKIE, baseDir = File(buildPath)) - .loadAndZip("test") { - println("Outcome $it") - countdown.countDown() + fun withCssAsset() { + val cssUrl = server.url("1.css") + + val content = """ + <!DOCTYPE html> + <html> + <head> + <link rel="stylesheet" href="$cssUrl"> + </head> + <body> + <h1>Css File Test</h1> + </body> + </html> + """.trimIndent() + + val css1 = """ + .hello { + display: none; } - countdown.await() + """.trimIndent() + + server.setDispatcher(object : Dispatcher() { + override fun dispatch(request: RecordedRequest): MockResponse = + when { + request.path.contains(cssUrl.encodedPath()) -> MockResponse().setBody(css1) + else -> MockResponse().setBody(content) + } + }) + + val zip = zipAndFetch() + + assertEquals(2, zip.size(), "2 files expected") + zip.assertContentEquals("index.html", content.replace(cssUrl.toString(), "assets/a0_1.css")) + zip.assertContentEquals("assets/a0_1.css", css1) + } + + @Test + fun withJsAsset() { + val jsUrl = server.url("1.js") + + val content = """ + <!DOCTYPE html> + <html> + <head></head> + <body> + <h1>Js File Test</h1> + <script type="text/javascript" src="$jsUrl"></script> + </body> + </html> + """.trimIndent() + + val js1 = """ + console.log('hello'); + """.trimIndent() + + server.setDispatcher(object : Dispatcher() { + override fun dispatch(request: RecordedRequest): MockResponse = + when { + request.path.contains(jsUrl.encodedPath()) -> MockResponse().setBody(js1) + else -> MockResponse().setBody(content) + } + }) + + val zip = zipAndFetch() + + assertEquals(2, zip.size(), "2 files expected") + zip.assertContentEquals("index.html", content.replace(jsUrl.toString(), "assets/a0_1.js.txt")) + zip.assertContentEquals("assets/a0_1.js.txt", js1) + } + + @Test + fun fullTest() { + val css1Url = server.url("1.css") + val css2Url = server.url("2.css") + val js1Url = server.url("1.js") + val js2Url = server.url("2.js") + + val content = """ + <!DOCTYPE html> + <html> + <head> + <link rel="stylesheet" href="$css1Url"> + <link rel="stylesheet" href="$css2Url"> + </head> + <body> + <h1>Multi File Test</h1> + <script type="text/javascript" src="$js1Url"></script> + <script type="text/javascript" src="$js2Url"></script> + </body> + </html> + """.trimIndent() + + val css1 = """ + .hello { + display: none; + } + """.trimIndent() + + val css2 = """ + .world { + display: none; + } + """.trimIndent() + + val js1 = """ + console.log('hello'); + """.trimIndent() + + val js2 = """ + console.log('world'); + """.trimIndent() + + server.setDispatcher(object : Dispatcher() { + override fun dispatch(request: RecordedRequest): MockResponse = + when { + request.path.contains(css1Url.encodedPath()) -> MockResponse().setBody(css1) + request.path.contains(css2Url.encodedPath()) -> MockResponse().setBody(css2) + request.path.contains(js1Url.encodedPath()) -> MockResponse().setBody(js1) + request.path.contains(js2Url.encodedPath()) -> MockResponse().setBody(js2) + else -> MockResponse().setBody(content) + } + }) + + val zip = zipAndFetch() + + assertEquals(5, zip.size(), "2 files expected") + zip.assertContentEquals( + "index.html", content + .replace(css1Url.toString(), "assets/a0_1.css") + .replace(css2Url.toString(), "assets/a1_2.css") + .replace(js1Url.toString(), "assets/a2_1.js.txt") + .replace(js2Url.toString(), "assets/a3_2.js.txt") + ) + + zip.assertContentEquals("assets/a0_1.css", css1) + zip.assertContentEquals("assets/a1_2.css", css2) + zip.assertContentEquals("assets/a2_1.js.txt", js1) + zip.assertContentEquals("assets/a3_2.js.txt", js2) } } diff --git a/app/src/test/kotlin/com/pitchedapps/frost/rx/FlyweightTest.kt b/app/src/test/kotlin/com/pitchedapps/frost/kotlin/FlyweightTest.kt index b58878cb..0eee530e 100644 --- a/app/src/test/kotlin/com/pitchedapps/frost/rx/FlyweightTest.kt +++ b/app/src/test/kotlin/com/pitchedapps/frost/kotlin/FlyweightTest.kt @@ -14,7 +14,7 @@ * 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.rx +package com.pitchedapps.frost.kotlin import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.async @@ -118,7 +118,6 @@ class FlyweightTest { "Incorrect error found on fetch cancelled by destruction" ) } - println("Done") } } } diff --git a/app/src/test/kotlin/com/pitchedapps/frost/rx/ResettableFlyweightTest.kt b/app/src/test/kotlin/com/pitchedapps/frost/rx/ResettableFlyweightTest.kt deleted file mode 100644 index 26a5a8de..00000000 --- a/app/src/test/kotlin/com/pitchedapps/frost/rx/ResettableFlyweightTest.kt +++ /dev/null @@ -1,73 +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.rx - -import com.pitchedapps.frost.internal.concurrentTest -import org.junit.Before -import org.junit.Test - -/** - * Created by Allan Wang on 07/01/18. - */ -private inline val threadId - get() = Thread.currentThread().id - -class ResettableFlyweightTest { - - class IntFlyweight : RxFlyweight<Int, Long, Long>() { - override fun call(input: Int): Long { - println("Call for $input on thread $threadId") - Thread.sleep(20) - return System.currentTimeMillis() - } - - override fun validate(input: Int, cond: Long) = System.currentTimeMillis() - cond < 500 - - override fun cache(input: Int): Long = System.currentTimeMillis() - } - - private lateinit var flyweight: IntFlyweight - - @Before - fun init() { - flyweight = IntFlyweight() - } - - @Test - fun testCache() = concurrentTest { result -> - flyweight(1).subscribe { i, _ -> - flyweight(1).subscribe { j, _ -> - if (i != null && i == j) - result.onComplete() - else - result.onError("Did not use cache during calls") - } - } - } - - @Test - fun testNoCache() = concurrentTest { result -> - flyweight(1).subscribe { i, _ -> - flyweight(2).subscribe { j, _ -> - if (i != null && i != j) - result.onComplete() - else - result.onError("Should not use cache for calls with different keys") - } - } - } -} diff --git a/gradle.properties b/gradle.properties index 62b16ce4..11127a3e 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=d850474 +KAU=af43e82 KOTLIN=1.3.11 # https://mvnrepository.com/artifact/com.android.tools.build/gradle?repo=google |