From 46fb61e53327c6eb1ebc3bfced956f3e05f55abc Mon Sep 17 00:00:00 2001 From: Allan Wang Date: Thu, 20 Dec 2018 23:12:41 -0500 Subject: Add initial coroutines --- app/build.gradle | 2 + .../com/pitchedapps/frost/facebook/FbCookie.kt | 72 +++++++++++++++------- gradle.properties | 2 + 3 files changed, 53 insertions(+), 23 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 34e57f84..0e2bff04 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -179,6 +179,8 @@ dependencies { //noinspection GradleDependency implementation "ca.allanwang.kau:core-ui:$KAU" + implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$COROUTINES" + implementation "org.apache.commons:commons-text:${COMMONS_TEXT}" implementation "com.devbrackets.android:exomedia:${EXOMEDIA}" 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 ab7e165a..57d5a88d 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/facebook/FbCookie.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/facebook/FbCookie.kt @@ -14,6 +14,10 @@ import com.pitchedapps.frost.utils.launchLogin import io.reactivex.Observable import io.reactivex.android.schedulers.AndroidSchedulers import io.reactivex.subjects.SingleSubject +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch +import kotlin.coroutines.resume +import kotlin.coroutines.suspendCoroutine /** * Created by Allan Wang on 2017-05-30. @@ -27,37 +31,59 @@ object FbCookie { inline val webCookie: String? get() = CookieManager.getInstance().getCookie(FB_URL_BASE) - private fun setWebCookie(cookie: String?, callback: (() -> Unit)?) { - with(CookieManager.getInstance()) { - removeAllCookies { _ -> - if (cookie == null) { - callback?.invoke() - return@removeAllCookies - } - L.d { "Setting cookie" } - val cookies = cookie.split(";").map { Pair(it, SingleSubject.create()) } - cookies.forEach { (cookie, callback) -> setCookie(FB_URL_BASE, cookie) { callback.onSuccess(it) } } - Observable.zip(cookies.map { (_, callback) -> callback.toObservable() }) {} - .observeOn(AndroidSchedulers.mainThread()) - .subscribe { - callback?.invoke() - L.d { "Cookies set" } - L._d { cookie } - flush() - } + 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()) } + cookies.forEach { (cookie, callback) -> setCookie(FB_URL_BASE, cookie) { callback.onSuccess(it) } } + Observable.zip(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 + removeAllCookies() + val result = cookie.split(":").all { + setSingleWebCookie(it) + } + flush() + return result + } + + private suspend fun CookieManager.removeAllCookies(): Boolean = suspendCoroutine { cont -> + removeAllCookies { + cont.resume(it) } } + private suspend fun CookieManager.setSingleWebCookie(cookie: String): Boolean = suspendCoroutine { cont -> + setCookie(FB_URL_BASE, cookie) { + cont.resume(it) + } + } + + operator fun invoke() { L.d { "FbCookie Invoke User" } - with(CookieManager.getInstance()) { - setAcceptCookie(true) - } + val manager = CookieManager.getInstance() + manager.setAcceptCookie(true) val dbCookie = loadFbCookie(Prefs.userId)?.cookie if (dbCookie != null && webCookie == null) { L.d { "DbCookie found & WebCookie is null; setting webcookie" } - setWebCookie(dbCookie, null) + GlobalScope.launch { + manager.suspendSetWebCookie(dbCookie) + } } } @@ -91,7 +117,7 @@ object FbCookie { } L.d { "Switching User" } Prefs.userId = cookie.id - setWebCookie(cookie.cookie, callback) + CookieManager.getInstance().setWebCookie(cookie.cookie, callback) } /** diff --git a/gradle.properties b/gradle.properties index 825b2b33..08365da7 100644 --- a/gradle.properties +++ b/gradle.properties @@ -17,6 +17,8 @@ APP_GROUP=com.pitchedapps KAU=572d470 KOTLIN=1.3.10 +# https://github.com/Kotlin/kotlinx.coroutines/releases +COROUTINES=1.0.1 # https://github.com/bugsnag/bugsnag-android/releases BUGSNAG=4.9.2 # https://github.com/bugsnag/bugsnag-android-gradle-plugin/releases -- cgit v1.2.3 From fce0bf0a6df09de78a804dc874f48f67336d9d9c Mon Sep 17 00:00:00 2001 From: Allan Wang Date: Fri, 21 Dec 2018 20:51:12 -0500 Subject: Add logging and switch domain url --- .../com/pitchedapps/frost/facebook/FbCookie.kt | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) 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 57d5a88d..d54c82bc 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/facebook/FbCookie.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/facebook/FbCookie.kt @@ -14,6 +14,7 @@ import com.pitchedapps.frost.utils.launchLogin import io.reactivex.Observable import io.reactivex.android.schedulers.AndroidSchedulers import io.reactivex.subjects.SingleSubject +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch import kotlin.coroutines.resume @@ -24,12 +25,14 @@ import kotlin.coroutines.suspendCoroutine */ object FbCookie { + const val COOKIE_DOMAIN = FACEBOOK_COM + /** * Retrieves the facebook cookie if it exists * Note that this is a synchronized call */ inline val webCookie: String? - get() = CookieManager.getInstance().getCookie(FB_URL_BASE) + get() = CookieManager.getInstance().getCookie(COOKIE_DOMAIN) private fun CookieManager.setWebCookie(cookie: String?, callback: (() -> Unit)?) { removeAllCookies { _ -> @@ -39,7 +42,7 @@ object FbCookie { } L.d { "Setting cookie" } val cookies = cookie.split(";").map { Pair(it, SingleSubject.create()) } - cookies.forEach { (cookie, callback) -> setCookie(FB_URL_BASE, cookie) { callback.onSuccess(it) } } + cookies.forEach { (cookie, callback) -> setCookie(COOKIE_DOMAIN, cookie) { callback.onSuccess(it) } } Observable.zip(cookies.map { (_, callback) -> callback.toObservable() }) {} .observeOn(AndroidSchedulers.mainThread()) .subscribe { @@ -53,22 +56,27 @@ object FbCookie { private suspend fun CookieManager.suspendSetWebCookie(cookie: String?): Boolean { cookie ?: return true + L.test { "Orig ${webCookie}" } removeAllCookies() - val result = cookie.split(":").all { - setSingleWebCookie(it) - } + 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 } 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(FB_URL_BASE, cookie) { + setCookie(COOKIE_DOMAIN, cookie.trim()) { + L.test { "Save single $cookie\n\n\t$webCookie" } cont.resume(it) } } @@ -81,7 +89,7 @@ object FbCookie { val dbCookie = loadFbCookie(Prefs.userId)?.cookie if (dbCookie != null && webCookie == null) { L.d { "DbCookie found & WebCookie is null; setting webcookie" } - GlobalScope.launch { + GlobalScope.launch(Dispatchers.Main) { manager.suspendSetWebCookie(dbCookie) } } -- cgit v1.2.3 From d12e04e088ce3da967947c955846f76bc73e47a8 Mon Sep 17 00:00:00 2001 From: Allan Wang Date: Mon, 24 Dec 2018 16:27:16 -0500 Subject: Remove login web async --- .../com/pitchedapps/frost/facebook/FbCookie.kt | 15 +++++++-------- .../kotlin/com/pitchedapps/frost/web/LoginWebView.kt | 20 +++++++++----------- 2 files changed, 16 insertions(+), 19 deletions(-) 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 c7931e53..627b0186 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/facebook/FbCookie.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/facebook/FbCookie.kt @@ -60,13 +60,13 @@ object FbCookie { val cookies = cookie.split(";").map { Pair(it, SingleSubject.create()) } cookies.forEach { (cookie, callback) -> setCookie(COOKIE_DOMAIN, cookie) { callback.onSuccess(it) } } Observable.zip(cookies.map { (_, callback) -> callback.toObservable() }) {} - .observeOn(AndroidSchedulers.mainThread()) - .subscribe { - callback?.invoke() - L.d { "Cookies set" } - L._d { cookie } - flush() - } + .observeOn(AndroidSchedulers.mainThread()) + .subscribe { + callback?.invoke() + L.d { "Cookies set" } + L._d { cookie } + flush() + } } } @@ -97,7 +97,6 @@ object FbCookie { } } - operator fun invoke() { L.d { "FbCookie Invoke User" } val manager = CookieManager.getInstance() 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 392cb353..2fe78f02 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/web/LoginWebView.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/web/LoginWebView.kt @@ -39,8 +39,6 @@ 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 org.jetbrains.anko.doAsync -import org.jetbrains.anko.uiThread /** * Created by Allan Wang on 2017-05-29. @@ -76,18 +74,18 @@ class LoginWebView @JvmOverloads constructor( override fun onPageFinished(view: WebView, url: String?) { super.onPageFinished(view, url) - checkForLogin(url) { id, cookie -> loginCallback(CookieModel(id, "", cookie)) } + val cookieModel = checkForLogin(url) + if (cookieModel != null) + loginCallback(cookieModel) if (!view.isVisible) view.fadeIn() } - fun checkForLogin(url: String?, onFound: (id: Long, cookie: String) -> Unit) { - doAsync { - if (!url.isFacebookUrl) return@doAsync - val cookie = CookieManager.getInstance().getCookie(url) ?: return@doAsync - L.d { "Checking cookie for login" } - val id = FB_USER_MATCHER.find(cookie)[1]?.toLong() ?: return@doAsync - uiThread { onFound(id, cookie) } - } + fun checkForLogin(url: String?): CookieModel? { + if (!url.isFacebookUrl) return null + val cookie = CookieManager.getInstance().getCookie(url) ?: return null + L.d { "Checking cookie for login" } + val id = FB_USER_MATCHER.find(cookie)[1]?.toLong() ?: return null + return CookieModel(id, "", cookie) } override fun onPageCommitVisible(view: WebView, url: String?) { -- cgit v1.2.3 From 9e1fe4bb7c692626c9aa336bebcb6c26ca49ea48 Mon Sep 17 00:00:00 2001 From: Allan Wang Date: Mon, 24 Dec 2018 17:27:43 -0500 Subject: Convert notification service to coroutines --- .../frost/services/NotificationService.kt | 88 ++++++++++++---------- .../com/pitchedapps/frost/utils/AdBlocker.kt | 7 +- 2 files changed, 51 insertions(+), 44 deletions(-) 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 4ede5163..ea215b5c 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/services/NotificationService.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/services/NotificationService.kt @@ -27,8 +27,13 @@ import com.pitchedapps.frost.dbflow.loadFbCookiesSync import com.pitchedapps.frost.utils.L import com.pitchedapps.frost.utils.Prefs import com.pitchedapps.frost.utils.frostEvent -import org.jetbrains.anko.doAsync -import java.util.concurrent.Future +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.async +import kotlinx.coroutines.launch +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlin.coroutines.CoroutineContext /** * Created by Allan Wang on 2017-06-14. @@ -38,68 +43,69 @@ import java.util.concurrent.Future * * All fetching is done through parsers */ -class NotificationService : JobService() { +class NotificationService : JobService(), CoroutineScope { - private var future: Future? = null + private lateinit var job: Job + override val coroutineContext: CoroutineContext + get() = Dispatchers.Main + job private val startTime = System.currentTimeMillis() override fun onStopJob(params: JobParameters?): Boolean { - val time = System.currentTimeMillis() - startTime - L.d { "Notification service has finished abruptly in $time ms" } - frostEvent( - "NotificationTime", - "Type" to "Service force stop", - "IM Included" to Prefs.notificationsInstantMessages, - "Duration" to time - ) - future?.cancel(true) - future = null + prepareFinish(true) return false } - fun finish(params: JobParameters?) { + private fun prepareFinish(abrupt: Boolean) { val time = System.currentTimeMillis() - startTime - L.i { "Notification service has finished in $time ms" } + L.i { "Notification service has ${if (abrupt) "finished abruptly" else "finished"} in $time ms" } frostEvent( "NotificationTime", - "Type" to "Service", + "Type" to (if (abrupt) "Service force stop" else "Service"), "IM Included" to Prefs.notificationsInstantMessages, "Duration" to time ) - jobFinished(params, false) - future?.cancel(true) - future = null + job.cancel() } override fun onStartJob(params: JobParameters?): Boolean { L.i { "Fetching notifications" } - future = doAsync { - val currentId = Prefs.userId - val cookies = loadFbCookiesSync() - val jobId = params?.extras?.getInt(NOTIFICATION_PARAM_ID, -1) ?: -1 - var notifCount = 0 - cookies.forEach { - val current = it.id == currentId - if (Prefs.notificationsGeneral && - (current || Prefs.notificationAllAccounts) - ) - notifCount += fetch(jobId, NotificationType.GENERAL, it) - if (Prefs.notificationsInstantMessages && - (current || Prefs.notificationsImAllAccounts) - ) - notifCount += fetch(jobId, NotificationType.MESSAGE, it) + job = Job() + launch { + try { + async { sendNotifications(params) }.await() + } finally { + prepareFinish(false) + jobFinished(params, false) } - - L.i { "Sent $notifCount notifications" } - if (notifCount == 0 && jobId == NOTIFICATION_JOB_NOW) - generalNotification(665, R.string.no_new_notifications, BuildConfig.DEBUG) - - finish(params) } return true } + private suspend fun sendNotifications(params: JobParameters?): Unit = suspendCancellableCoroutine { cont -> + val currentId = Prefs.userId + val cookies = loadFbCookiesSync() + if (cont.isCancelled) return@suspendCancellableCoroutine + val jobId = params?.extras?.getInt(NOTIFICATION_PARAM_ID, -1) ?: -1 + var notifCount = 0 + for (cookie in cookies) { + if (cont.isCancelled) break + val current = cookie.id == currentId + if (Prefs.notificationsGeneral && + (current || Prefs.notificationAllAccounts) + ) + notifCount += fetch(jobId, NotificationType.GENERAL, cookie) + if (Prefs.notificationsInstantMessages && + (current || Prefs.notificationsImAllAccounts) + ) + notifCount += fetch(jobId, NotificationType.MESSAGE, cookie) + } + + L.i { "Sent $notifCount notifications" } + if (notifCount == 0 && jobId == NOTIFICATION_JOB_NOW) + generalNotification(665, R.string.no_new_notifications, BuildConfig.DEBUG) + } + /** * Implemented fetch to also notify when an error occurs * Also normalized the output to return the number of notifications received diff --git a/app/src/main/kotlin/com/pitchedapps/frost/utils/AdBlocker.kt b/app/src/main/kotlin/com/pitchedapps/frost/utils/AdBlocker.kt index 61a90024..d14c6cd3 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/utils/AdBlocker.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/utils/AdBlocker.kt @@ -19,8 +19,9 @@ package com.pitchedapps.frost.utils import android.content.Context import android.text.TextUtils import ca.allanwang.kau.utils.use +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch import okhttp3.HttpUrl -import org.jetbrains.anko.doAsync /** * Created by Allan Wang on 2017-09-24. @@ -38,7 +39,7 @@ open class AdBlocker(val assetPath: String) { val data: MutableSet = mutableSetOf() fun init(context: Context) { - doAsync { + GlobalScope.launch { val content = context.assets.open(assetPath).bufferedReader().use { f -> f.readLines().filter { !it.startsWith("#") } } @@ -58,7 +59,7 @@ open class AdBlocker(val assetPath: String) { return false val index = host.indexOf(".") if (index < 0 || index + 1 < host.length) return false - if (host.contains(host)) return true + if (data.contains(host)) return true return isAdHost(host.substring(index + 1)) } } -- cgit v1.2.3 From 25760fa2d066a23e7fc72747f59c964e76ed0889 Mon Sep 17 00:00:00 2001 From: Allan Wang Date: Mon, 24 Dec 2018 17:41:08 -0500 Subject: Avoid events for duplicate cancellations --- .../kotlin/com/pitchedapps/frost/services/NotificationService.kt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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 ea215b5c..40a78b04 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/services/NotificationService.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/services/NotificationService.kt @@ -57,6 +57,8 @@ class NotificationService : JobService(), CoroutineScope { } private fun prepareFinish(abrupt: Boolean) { + if (job.isCancelled) + return val time = System.currentTimeMillis() - startTime L.i { "Notification service has ${if (abrupt) "finished abruptly" else "finished"} in $time ms" } frostEvent( @@ -75,7 +77,8 @@ class NotificationService : JobService(), CoroutineScope { try { async { sendNotifications(params) }.await() } finally { - prepareFinish(false) + if (!job.isCancelled) + prepareFinish(false) jobFinished(params, false) } } -- cgit v1.2.3 From 697e457da453568ca703c2b655a2dd490157b443 Mon Sep 17 00:00:00 2001 From: Allan Wang Date: Tue, 25 Dec 2018 16:32:51 -0500 Subject: Clean up image activity and prepare for tests --- app/build.gradle | 6 +- .../frost/activities/ImageActivityTest.kt | 32 +++++ .../pitchedapps/frost/activities/ImageActivity.kt | 157 +++++++++------------ .../com/pitchedapps/frost/facebook/FbCookie.kt | 6 +- .../pitchedapps/frost/fragments/FragmentBase.kt | 17 ++- .../frost/services/NotificationService.kt | 9 +- gradle.properties | 2 +- 7 files changed, 131 insertions(+), 98 deletions(-) create mode 100644 app/src/androidTest/kotlin/com/pitchedapps/frost/activities/ImageActivityTest.kt diff --git a/app/build.gradle b/app/build.gradle index ebf3ecf0..25361854 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -156,6 +156,7 @@ 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}" @@ -180,7 +181,10 @@ dependencies { //noinspection GradleDependency implementation "ca.allanwang.kau:core-ui:$KAU" - implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$COROUTINES" + // TODO temp + implementation "org.jetbrains.anko:anko-commons:0.10.8" + + implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:${COROUTINES}" implementation "org.apache.commons:commons-text:${COMMONS_TEXT}" diff --git a/app/src/androidTest/kotlin/com/pitchedapps/frost/activities/ImageActivityTest.kt b/app/src/androidTest/kotlin/com/pitchedapps/frost/activities/ImageActivityTest.kt new file mode 100644 index 00000000..abffb106 --- /dev/null +++ b/app/src/androidTest/kotlin/com/pitchedapps/frost/activities/ImageActivityTest.kt @@ -0,0 +1,32 @@ +package com.pitchedapps.frost.activities + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.rule.ActivityTestRule +import org.junit.Rule +import org.junit.runner.RunWith +import android.content.Intent +import com.pitchedapps.frost.utils.ARG_COOKIE +import com.pitchedapps.frost.utils.ARG_IMAGE_URL +import com.pitchedapps.frost.utils.ARG_TEXT +import org.junit.Test + +@RunWith(AndroidJUnit4::class) +class ImageActivityTest { + + @get:Rule + val activity: ActivityTestRule = ActivityTestRule(ImageActivity::class.java, true, false) + + private fun launchActivity(imageUrl: String, text: String? = null, cookie: String? = null) { + val intent = Intent().apply { + putExtra(ARG_IMAGE_URL, imageUrl) + putExtra(ARG_TEXT, text) + putExtra(ARG_COOKIE, cookie) + } + activity.launchActivity(intent) + } + + @Test + fun intent() { + + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/pitchedapps/frost/activities/ImageActivity.kt b/app/src/main/kotlin/com/pitchedapps/frost/activities/ImageActivity.kt index 83f617ba..bbd0463a 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/activities/ImageActivity.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/activities/ImageActivity.kt @@ -28,13 +28,14 @@ import ca.allanwang.kau.mediapicker.scanMedia import ca.allanwang.kau.permissions.PERMISSION_WRITE_EXTERNAL_STORAGE import ca.allanwang.kau.permissions.kauRequestPermissions import ca.allanwang.kau.utils.colorToForeground +import ca.allanwang.kau.utils.copyFromInputStream import ca.allanwang.kau.utils.fadeOut import ca.allanwang.kau.utils.fadeScaleTransition import ca.allanwang.kau.utils.isHidden +import ca.allanwang.kau.utils.isVisible import ca.allanwang.kau.utils.scaleXY import ca.allanwang.kau.utils.setIcon import ca.allanwang.kau.utils.tint -import ca.allanwang.kau.utils.use import ca.allanwang.kau.utils.withAlpha import ca.allanwang.kau.utils.withMinAlpha import com.davemorrissey.labs.subscaleview.ImageSource @@ -53,7 +54,6 @@ import com.pitchedapps.frost.utils.ARG_IMAGE_URL import com.pitchedapps.frost.utils.ARG_TEXT import com.pitchedapps.frost.utils.L import com.pitchedapps.frost.utils.Prefs -import com.pitchedapps.frost.utils.createFreshFile import com.pitchedapps.frost.utils.frostSnackbar import com.pitchedapps.frost.utils.frostUriFromFile import com.pitchedapps.frost.utils.isIndirectImageUrl @@ -63,10 +63,11 @@ import com.pitchedapps.frost.utils.sendFrostEmail import com.pitchedapps.frost.utils.setFrostColors import com.sothree.slidinguppanel.SlidingUpPanelLayout import kotlinx.android.synthetic.main.activity_image.* +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import okhttp3.Response -import org.jetbrains.anko.activityUiThreadWithContext -import org.jetbrains.anko.doAsync -import org.jetbrains.anko.uiThread import java.io.File import java.io.FileFilter import java.io.IOException @@ -79,6 +80,7 @@ import java.util.Locale */ class ImageActivity : KauBaseActivity() { + @Volatile internal var errorRef: Throwable? = null private lateinit var tempDir: File @@ -138,6 +140,16 @@ class ImageActivity : KauBaseActivity() { )}_${Math.abs(imageUrl.hashCode())}" } + private fun loadError(e: Throwable) { + errorRef = e + e.logFrostEvent("Image load error") + L.e { "Failed to load image $imageHash" } + if (image_progress.isVisible) + image_progress.fadeOut() + tempFile.delete() + fabAction = FabStates.ERROR + } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) intent?.extras ?: return finish() @@ -165,12 +177,8 @@ class ImageActivity : KauBaseActivity() { }) image_fab.setOnClickListener { fabAction.onClick(this) } image_photo.setOnImageEventListener(object : SubsamplingScaleImageView.DefaultOnImageEventListener() { - override fun onImageLoadError(e: Exception?) { - errorRef = e - e.logFrostEvent("Image load error") - L.e { "Failed to load image $imageUrl" } - tempFile?.delete() - fabAction = FabStates.ERROR + override fun onImageLoadError(e: Exception) { + loadError(e) } }) setFrostColors { @@ -178,69 +186,15 @@ class ImageActivity : KauBaseActivity() { } tempDir = File(cacheDir, IMAGE_FOLDER) tempFile = File(tempDir, imageHash) - doAsync({ - L.e(it) { "Failed to load image $imageHash" } - errorRef = it - runOnUiThread { image_progress.fadeOut() } - tempFile.delete() - fabAction = FabStates.ERROR - }) { - val loaded = loadImage(tempFile) - uiThread { - image_progress.fadeOut() - if (!loaded) { - fabAction = FabStates.ERROR - } else { - image_photo.setImage(ImageSource.uri(frostUriFromFile(tempFile))) - fabAction = FabStates.DOWNLOAD - image_photo.animate().alpha(1f).scaleXY(1f).start() - } - } + launch(CoroutineExceptionHandler { _, err -> loadError(err) }) { + downloadImageTo(tempFile) + image_progress.fadeOut() + image_photo.setImage(ImageSource.uri(frostUriFromFile(tempFile))) + fabAction = FabStates.DOWNLOAD + image_photo.animate().alpha(1f).scaleXY(1f).start() } } - /** - * Attempts to load the image to [file] - * Returns true if successful - * Note that this is a long execution and should not be done on the UI thread - */ - private fun loadImage(file: File): Boolean { - if (file.exists() && file.length() > 1) { - file.setLastModified(System.currentTimeMillis()) - L.d { "Loading from local cache ${file.absolutePath}" } - return true - } - val response = getImageResponse() - - if (!response.isSuccessful) { - L.e { "Unsuccessful response for image" } - errorRef = Throwable("Unsuccessful response for image") - return false - } - - if (!file.createFreshFile()) { - L.e { "Could not create temp file" } - return false - } - - var valid = false - - response.body()?.byteStream()?.use { input -> - file.outputStream().use { output -> - input.copyTo(output) - valid = true - } - } - - if (!valid) { - L.e { "Failed to copy file" } - file.delete() - return false - } - - return true - } - @Throws(IOException::class) private fun createPublicMediaFile(): File { val timeStamp = SimpleDateFormat(TIME_FORMAT, Locale.getDefault()).format(Date()) @@ -257,30 +211,59 @@ class ImageActivity : KauBaseActivity() { .call() .execute() + /** + * Saves the image to the specified file, creating it if it doesn't exist. + * Returns true if a change is made, false otherwise. + * Throws an error if something goes wrong. + */ @Throws(IOException::class) - private fun downloadImageTo(file: File) { - val body = getImageResponse().body() - ?: throw IOException("Failed to retrieve image body") - body.byteStream().use { input -> - file.outputStream().use { output -> - input.copyTo(output) + private suspend fun downloadImageTo(file: File): Boolean { + val exceptionHandler = CoroutineExceptionHandler { _, _ -> + if (file.isFile && file.length() == 0L) { + file.delete() } } + return withContext(Dispatchers.IO + exceptionHandler) { + if (!file.isFile) { + file.mkdirs() + file.createNewFile() + } + + file.setLastModified(System.currentTimeMillis()) + + // Forbid overwrites + if (file.length() > 1) + return@withContext false + if (tempFile.isFile && tempFile.length() > 1) { + if (tempFile == file) + return@withContext false + tempFile.copyTo(file) + return@withContext true + } + // No temp file, download ourselves + val response = getImageResponse() + + if (!response.isSuccessful) { + throw IOException("Unsuccessful response for image: ${response.peekBody(128).string()}") + } + + val body = response.body() ?: throw IOException("Failed to retrieve image body") + + file.copyFromInputStream(body.byteStream()) + + return@withContext true + } } internal fun saveImage() { kauRequestPermissions(PERMISSION_WRITE_EXTERNAL_STORAGE) { granted, _ -> L.d { "Download image callback granted: $granted" } if (granted) { - doAsync { + launch { val destination = createPublicMediaFile() var success = true try { - val temp = tempFile - if (temp != null) - temp.copyTo(destination, true) - else - downloadImageTo(destination) + downloadImageTo(destination) } catch (e: Exception) { errorRef = e success = false @@ -295,11 +278,9 @@ class ImageActivity : KauBaseActivity() { } catch (ignore: Exception) { } } - activityUiThreadWithContext { - val text = if (success) R.string.image_download_success else R.string.image_download_fail - frostSnackbar(text) - if (success) fabAction = FabStates.SHARE - } + val text = if (success) R.string.image_download_success else R.string.image_download_fail + frostSnackbar(text) + if (success) fabAction = FabStates.SHARE } } } 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 627b0186..47c0ecc4 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/facebook/FbCookie.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/facebook/FbCookie.kt @@ -72,14 +72,14 @@ object FbCookie { private suspend fun CookieManager.suspendSetWebCookie(cookie: String?): Boolean { cookie ?: return true - L.test { "Orig ${webCookie}" } + 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}" } + L.test { "AAAA $webCookie" } flush() - L.test { "SSSS ${webCookie}" } + L.test { "SSSS $webCookie" } return result } 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 98e28bd3..2c46edbc 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/fragments/FragmentBase.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/fragments/FragmentBase.kt @@ -41,6 +41,11 @@ import com.pitchedapps.frost.utils.REQUEST_TEXT_ZOOM import com.pitchedapps.frost.utils.frostEvent import io.reactivex.android.schedulers.AndroidSchedulers import io.reactivex.disposables.Disposable +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlin.coroutines.CoroutineContext /** * Created by Allan Wang on 2017-11-07. @@ -48,7 +53,7 @@ import io.reactivex.disposables.Disposable * All fragments pertaining to the main view * Must be attached to activities implementing [MainActivityContract] */ -abstract class BaseFragment : Fragment(), FragmentContract, DynamicUiContract { +abstract class BaseFragment : Fragment(), CoroutineScope, FragmentContract, DynamicUiContract { companion object { private const val ARG_POSITION = "arg_position" @@ -71,6 +76,10 @@ abstract class BaseFragment : Fragment(), FragmentContract, DynamicUiContract { } } + open lateinit var job: Job + override val coroutineContext: CoroutineContext + get() = Dispatchers.Main + job + override val baseUrl: String by lazy { arguments!!.getString(ARG_URL) } override val baseEnum: FbItem by lazy { FbItem[arguments]!! } override val position: Int by lazy { arguments!!.getInt(ARG_POSITION) } @@ -98,6 +107,7 @@ abstract class BaseFragment : Fragment(), FragmentContract, DynamicUiContract { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + job = SupervisorJob() firstLoad = true if (context !is MainActivityContract) throw IllegalArgumentException("${this::class.java.simpleName} is not attached to a context implementing MainActivityContract") @@ -207,6 +217,11 @@ abstract class BaseFragment : Fragment(), FragmentContract, DynamicUiContract { super.onDestroyView() } + override fun onDestroy() { + job.cancel() + super.onDestroy() + } + override fun reloadTheme() { reloadThemeSelf() content?.reloadTextSize() 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 40a78b04..3470ca07 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/services/NotificationService.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/services/NotificationService.kt @@ -31,6 +31,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.async +import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.suspendCancellableCoroutine import kotlin.coroutines.CoroutineContext @@ -77,7 +78,7 @@ class NotificationService : JobService(), CoroutineScope { try { async { sendNotifications(params) }.await() } finally { - if (!job.isCancelled) + if (!isActive) prepareFinish(false) jobFinished(params, false) } @@ -85,14 +86,14 @@ class NotificationService : JobService(), CoroutineScope { return true } - private suspend fun sendNotifications(params: JobParameters?): Unit = suspendCancellableCoroutine { cont -> + private suspend fun sendNotifications(params: JobParameters?): Unit = suspendCancellableCoroutine { val currentId = Prefs.userId val cookies = loadFbCookiesSync() - if (cont.isCancelled) return@suspendCancellableCoroutine + if (it.isCancelled) return@suspendCancellableCoroutine val jobId = params?.extras?.getInt(NOTIFICATION_PARAM_ID, -1) ?: -1 var notifCount = 0 for (cookie in cookies) { - if (cont.isCancelled) break + if (it.isCancelled) break val current = cookie.id == currentId if (Prefs.notificationsGeneral && (current || Prefs.notificationAllAccounts) diff --git a/gradle.properties b/gradle.properties index 792f6ac0..6778bba4 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=b4a2ded +KAU=d850474 KOTLIN=1.3.11 # https://mvnrepository.com/artifact/com.android.tools.build/gradle?repo=google -- cgit v1.2.3 From 49a67bc7c6d0ea38c88d8b424a2f188941dc609e Mon Sep 17 00:00:00 2001 From: Allan Wang Date: Tue, 25 Dec 2018 22:14:56 -0500 Subject: Update imageactivity and add tests, resolves #1107 --- app/build.gradle | 8 ++ .../frost/activities/ImageActivityTest.kt | 102 ++++++++++++++++++- .../kotlin/com/pitchedapps/frost/helper/Helper.kt | 32 ++++++ app/src/androidTest/resources/bayer-pattern.jpg | Bin 0 -> 6195 bytes app/src/androidTest/resources/magenta.png | Bin 0 -> 165 bytes app/src/main/AndroidManifest.xml | 5 + .../pitchedapps/frost/activities/ImageActivity.kt | 110 ++++++++++----------- .../pitchedapps/frost/services/BaseJobService.kt | 59 +++++++++++ .../frost/services/FrostNotifications.kt | 4 - .../frost/services/FrostRequestService.kt | 3 +- .../com/pitchedapps/frost/services/LocalService.kt | 90 +++++++++++++++++ .../frost/services/NotificationService.kt | 32 +++--- app/src/main/res/values/strings_web_context.xml | 1 + .../test/kotlin/com/pitchedapps/frost/MiscTest.kt | 13 +++ gradle.properties | 4 + 15 files changed, 372 insertions(+), 91 deletions(-) create mode 100644 app/src/androidTest/kotlin/com/pitchedapps/frost/helper/Helper.kt create mode 100644 app/src/androidTest/resources/bayer-pattern.jpg create mode 100644 app/src/androidTest/resources/magenta.png create mode 100644 app/src/main/kotlin/com/pitchedapps/frost/services/BaseJobService.kt create mode 100644 app/src/main/kotlin/com/pitchedapps/frost/services/LocalService.kt diff --git a/app/build.gradle b/app/build.gradle index 25361854..a869c7bf 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -184,6 +184,12 @@ dependencies { // TODO temp implementation "org.jetbrains.anko:anko-commons:0.10.8" +// implementation "org.koin:koin-android:${KOIN}" +// testImplementation "org.koin:koin-test:${KOIN}" +// androidTestImplementation "org.koin:koin-test:${KOIN}" + +// androidTestImplementation "io.mockk:mockk:${MOCKK}" + implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:${COROUTINES}" implementation "org.apache.commons:commons-text:${COMMONS_TEXT}" @@ -220,6 +226,8 @@ dependencies { implementation "com.squareup.okhttp3:okhttp:${OKHTTP}" implementation "com.squareup.okhttp3:logging-interceptor:${OKHTTP}" + androidTestImplementation "com.squareup.okhttp3:mockwebserver:${OKHTTP}" + implementation "co.zsmb:materialdrawer-kt:${MATERIAL_DRAWER_KT}" 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 abffb106..23f6dab9 100644 --- a/app/src/androidTest/kotlin/com/pitchedapps/frost/activities/ImageActivityTest.kt +++ b/app/src/androidTest/kotlin/com/pitchedapps/frost/activities/ImageActivityTest.kt @@ -1,14 +1,43 @@ +/* + * 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 . + */ package com.pitchedapps.frost.activities +import android.content.Intent import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.rule.ActivityTestRule -import org.junit.Rule -import org.junit.runner.RunWith -import android.content.Intent +import com.pitchedapps.frost.helper.getResource import com.pitchedapps.frost.utils.ARG_COOKIE import com.pitchedapps.frost.utils.ARG_IMAGE_URL import com.pitchedapps.frost.utils.ARG_TEXT +import com.pitchedapps.frost.utils.isIndirectImageUrl +import okhttp3.mockwebserver.Dispatcher +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import okhttp3.mockwebserver.RecordedRequest +import okio.Buffer +import okio.Okio +import org.junit.Rule import org.junit.Test +import org.junit.rules.Timeout +import org.junit.runner.RunWith +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue @RunWith(AndroidJUnit4::class) class ImageActivityTest { @@ -16,7 +45,14 @@ class ImageActivityTest { @get:Rule val activity: ActivityTestRule = ActivityTestRule(ImageActivity::class.java, true, false) + @get:Rule + val globalTimeout: Timeout = Timeout.seconds(15) + private fun launchActivity(imageUrl: String, text: String? = null, cookie: String? = null) { + assertFalse( + imageUrl.isIndirectImageUrl, + "For simplicity, urls that are direct will be used without modifications in the production code." + ) val intent = Intent().apply { putExtra(ARG_IMAGE_URL, imageUrl) putExtra(ARG_TEXT, text) @@ -25,8 +61,64 @@ class ImageActivityTest { activity.launchActivity(intent) } + private val mockServer: MockWebServer by lazy { + val magentaImg = Buffer() + magentaImg.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) + else -> MockResponse().setResponseCode(404).setBody("Error mock response") + } + }) + start() + } + } + @Test - fun intent() { + fun validImageTest() { + launchActivity(mockServer.url("image").toString()) + mockServer.takeRequest() + with(activity.activity) { + assertEquals(1, mockServer.requestCount, "One http request expected") + assertEquals(fabAction, FabStates.DOWNLOAD, "Image should be successful, image should be downloaded") + assertTrue(tempFile.exists(), "Image should be located at temp file") + assertTrue( + System.currentTimeMillis() - tempFile.lastModified() < 2000L, + "Image should have been modified within the last few seconds" + ) + assertNull(errorRef, "No error should exist") + tempFile.delete() + } + } + @Test + fun invalidImageTest() { + launchActivity(mockServer.url("text").toString()) + mockServer.takeRequest() + with(activity.activity) { + assertEquals(1, mockServer.requestCount, "One http request expected") + assertEquals(fabAction, FabStates.ERROR, "Text should not be a valid image format, error state expected") + assertEquals("Image format not supported", errorRef?.message, "Error message mismatch") + assertFalse(tempFile.exists(), "Temp file should have been removed") + } + } + + @Test + fun errorTest() { + launchActivity(mockServer.url("error").toString()) + mockServer.takeRequest() + with(activity.activity) { + assertEquals(1, mockServer.requestCount, "One http request expected") + assertEquals(fabAction, FabStates.ERROR, "Error response code, error state expected") + assertEquals( + "Unsuccessful response for image: Error mock response", + errorRef?.message, + "Error message mismatch" + ) + assertFalse(tempFile.exists(), "Temp file should have been removed") + } } -} \ No newline at end of file +} diff --git a/app/src/androidTest/kotlin/com/pitchedapps/frost/helper/Helper.kt b/app/src/androidTest/kotlin/com/pitchedapps/frost/helper/Helper.kt new file mode 100644 index 00000000..f7484cb3 --- /dev/null +++ b/app/src/androidTest/kotlin/com/pitchedapps/frost/helper/Helper.kt @@ -0,0 +1,32 @@ +/* + * 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 . + */ +package com.pitchedapps.frost.helper + +import android.content.Context +import androidx.test.platform.app.InstrumentationRegistry +import java.io.InputStream + +val context: Context + get() = InstrumentationRegistry.getInstrumentation().targetContext + +fun getAsset(asset: String): InputStream = + context.assets.open(asset) + +private class Helper + +fun getResource(resource: String): InputStream = + Helper::class.java.classLoader!!.getResource(resource).openStream() diff --git a/app/src/androidTest/resources/bayer-pattern.jpg b/app/src/androidTest/resources/bayer-pattern.jpg new file mode 100644 index 00000000..672ac178 Binary files /dev/null and b/app/src/androidTest/resources/bayer-pattern.jpg differ diff --git a/app/src/androidTest/resources/magenta.png b/app/src/androidTest/resources/magenta.png new file mode 100644 index 00000000..14afbce8 Binary files /dev/null and b/app/src/androidTest/resources/magenta.png differ diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index ed197510..213da26b 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -146,6 +146,11 @@ android:enabled="true" android:label="@string/frost_requests" android:permission="android.permission.BIND_JOB_SERVICE" /> + private val imageText: String? by lazy { intent.getStringExtra(ARG_TEXT) } @@ -143,7 +139,6 @@ class ImageActivity : KauBaseActivity() { private fun loadError(e: Throwable) { errorRef = e e.logFrostEvent("Image load error") - L.e { "Failed to load image $imageHash" } if (image_progress.isVisible) image_progress.fadeOut() tempFile.delete() @@ -154,7 +149,14 @@ class ImageActivity : KauBaseActivity() { super.onCreate(savedInstanceState) intent?.extras ?: return finish() L.i { "Displaying image" } - L.v { "Displaying image $imageUrl" } + trueImageUrl = async(Dispatchers.IO) { + val result = if (!imageUrl.isIndirectImageUrl) imageUrl + else cookie?.getFullSizedImageUrl(imageUrl)?.blockingGet() ?: imageUrl + if (result != imageUrl) + L.v { "Launching with true url $result" } + result + } + val layout = if (!imageText.isNullOrBlank()) R.layout.activity_image else R.layout.activity_image_textless setContentView(layout) image_container.setBackgroundColor( @@ -184,9 +186,8 @@ class ImageActivity : KauBaseActivity() { setFrostColors { themeWindow = false } - tempDir = File(cacheDir, IMAGE_FOLDER) - tempFile = File(tempDir, imageHash) - launch(CoroutineExceptionHandler { _, err -> loadError(err) }) { + tempFile = File(cacheDir(this), imageHash) + launch(CoroutineExceptionHandler { _, throwable -> loadError(throwable) }) { downloadImageTo(tempFile) image_progress.fadeOut() image_photo.setImage(ImageSource.uri(frostUriFromFile(tempFile))) @@ -205,12 +206,6 @@ class ImageActivity : KauBaseActivity() { return File.createTempFile(imageFileName, IMG_EXTENSION, frostDir) } - private fun getImageResponse(): Response = cookie.requestBuilder() - .url(trueImageUrl) - .get() - .call() - .execute() - /** * Saves the image to the specified file, creating it if it doesn't exist. * Returns true if a change is made, false otherwise. @@ -218,30 +213,39 @@ class ImageActivity : KauBaseActivity() { */ @Throws(IOException::class) private suspend fun downloadImageTo(file: File): Boolean { - val exceptionHandler = CoroutineExceptionHandler { _, _ -> + val exceptionHandler = CoroutineExceptionHandler { _, err -> if (file.isFile && file.length() == 0L) { file.delete() } + throw err } return withContext(Dispatchers.IO + exceptionHandler) { if (!file.isFile) { - file.mkdirs() + file.parentFile.mkdirs() file.createNewFile() + } else { + file.setLastModified(System.currentTimeMillis()) } - file.setLastModified(System.currentTimeMillis()) - // Forbid overwrites - if (file.length() > 1) + if (file.length() > 0) { + L.i { "Forbid image overwrite" } return@withContext false - if (tempFile.isFile && tempFile.length() > 1) { - if (tempFile == file) + } + if (tempFile.isFile && tempFile.length() > 0) { + if (tempFile == file) { return@withContext false + } tempFile.copyTo(file) return@withContext true } + // No temp file, download ourselves - val response = getImageResponse() + val response = cookie.requestBuilder() + .url(trueImageUrl.await()) + .get() + .call() + .execute() if (!response.isSuccessful) { throw IOException("Unsuccessful response for image: ${response.peekBody(128).string()}") @@ -259,39 +263,25 @@ class ImageActivity : KauBaseActivity() { kauRequestPermissions(PERMISSION_WRITE_EXTERNAL_STORAGE) { granted, _ -> L.d { "Download image callback granted: $granted" } if (granted) { - launch { + val errorHandler = CoroutineExceptionHandler { _, throwable -> + loadError(throwable) + frostSnackbar(R.string.image_download_fail) + } + launch(errorHandler) { val destination = createPublicMediaFile() - var success = true - try { - downloadImageTo(destination) - } catch (e: Exception) { - errorRef = e - success = false - } finally { - L.d { "Download image async finished: $success" } - if (success) { - scanMedia(destination) - savedFile = destination - } else { - try { - destination.delete() - } catch (ignore: Exception) { - } - } - val text = if (success) R.string.image_download_success else R.string.image_download_fail - frostSnackbar(text) - if (success) fabAction = FabStates.SHARE - } + downloadImageTo(destination) + L.d { "Download image async finished" } + scanMedia(destination) + savedFile = destination + frostSnackbar(R.string.image_download_success) + fabAction = FabStates.SHARE } } } } override fun onDestroy() { - val purge = System.currentTimeMillis() - PURGE_TIME - tempDir.listFiles(FileFilter { it.isFile && it.lastModified() < purge })?.forEach { - it.delete() - } + LocalService.schedule(this, LocalService.Flag.PURGE_IMAGE) super.onDestroy() } } diff --git a/app/src/main/kotlin/com/pitchedapps/frost/services/BaseJobService.kt b/app/src/main/kotlin/com/pitchedapps/frost/services/BaseJobService.kt new file mode 100644 index 00000000..3cc7deaf --- /dev/null +++ b/app/src/main/kotlin/com/pitchedapps/frost/services/BaseJobService.kt @@ -0,0 +1,59 @@ +/* + * 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 . + */ +package com.pitchedapps.frost.services + +import android.app.job.JobParameters +import android.app.job.JobService +import androidx.annotation.CallSuper +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlin.coroutines.CoroutineContext + +abstract class BaseJobService : JobService(), CoroutineScope { + + private lateinit var job: Job + override val coroutineContext: CoroutineContext + get() = Dispatchers.Main + job + + protected val startTime = System.currentTimeMillis() + + /** + * Note that if a job plans on running asynchronously, it should return true + */ + @CallSuper + override fun onStartJob(params: JobParameters?): Boolean { + job = Job() + return false + } + + @CallSuper + override fun onStopJob(params: JobParameters?): Boolean { + job.cancel() + return false + } +} + +/* + * Collection of ids for job services. + * These should all be unique + */ + +const val NOTIFICATION_JOB_NOW = 6 +const val NOTIFICATION_PERIODIC_JOB = 7 +const val LOCAL_SERVICE_BASE = 110 +const val REQUEST_SERVICE_BASE = 220 diff --git a/app/src/main/kotlin/com/pitchedapps/frost/services/FrostNotifications.kt b/app/src/main/kotlin/com/pitchedapps/frost/services/FrostNotifications.kt index ee515a55..d036d3a8 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/services/FrostNotifications.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/services/FrostNotifications.kt @@ -274,12 +274,8 @@ data class FrostNotification( NotificationManagerCompat.from(context).notify(tag, id, notif.build()) } -const val NOTIFICATION_PERIODIC_JOB = 7 - fun Context.scheduleNotifications(minutes: Long): Boolean = scheduleJob(NOTIFICATION_PERIODIC_JOB, minutes) -const val NOTIFICATION_JOB_NOW = 6 - fun Context.fetchNotifications(): Boolean = fetchJob(NOTIFICATION_JOB_NOW) diff --git a/app/src/main/kotlin/com/pitchedapps/frost/services/FrostRequestService.kt b/app/src/main/kotlin/com/pitchedapps/frost/services/FrostRequestService.kt index 22acc9fb..d41f0b3c 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/services/FrostRequestService.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/services/FrostRequestService.kt @@ -82,7 +82,6 @@ private const val ARG_0 = "frost_request_arg_0" private const val ARG_1 = "frost_request_arg_1" private const val ARG_2 = "frost_request_arg_2" private const val ARG_3 = "frost_request_arg_3" -private const val JOB_REQUEST_BASE = 928 private fun BaseBundle.getCookie() = getString(ARG_COOKIE) private fun BaseBundle.putCookie(cookie: String) = putString(ARG_COOKIE, cookie) @@ -145,7 +144,7 @@ object FrostRunnable { return false } - val builder = JobInfo.Builder(JOB_REQUEST_BASE + command.ordinal, serviceComponent) + val builder = JobInfo.Builder(REQUEST_SERVICE_BASE + command.ordinal, serviceComponent) .setMinimumLatency(0L) .setExtras(bundle) .setOverrideDeadline(2000L) diff --git a/app/src/main/kotlin/com/pitchedapps/frost/services/LocalService.kt b/app/src/main/kotlin/com/pitchedapps/frost/services/LocalService.kt new file mode 100644 index 00000000..3d66f1ee --- /dev/null +++ b/app/src/main/kotlin/com/pitchedapps/frost/services/LocalService.kt @@ -0,0 +1,90 @@ +/* + * 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 . + */ +package com.pitchedapps.frost.services + +import android.app.job.JobInfo +import android.app.job.JobParameters +import android.app.job.JobScheduler +import android.content.ComponentName +import android.content.Context +import android.os.PersistableBundle +import com.pitchedapps.frost.activities.ImageActivity +import com.pitchedapps.frost.utils.L +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.io.FileFilter + +class LocalService : BaseJobService() { + + enum class Flag { + PURGE_IMAGE + } + + companion object { + private const val FLAG = "extra_local_flag" + + /** + * Launches a local service with the provided flag + */ + fun schedule(context: Context, flag: Flag): Boolean { + val scheduler = context.getSystemService(Context.JOB_SCHEDULER_SERVICE) as JobScheduler + val serviceComponent = ComponentName(context, LocalService::class.java) + val bundle = PersistableBundle() + bundle.putString(FLAG, flag.name) + + val builder = JobInfo.Builder(LOCAL_SERVICE_BASE + flag.ordinal, serviceComponent) + .setMinimumLatency(0L) + .setExtras(bundle) + .setOverrideDeadline(2000L) + + val result = scheduler.schedule(builder.build()) + if (result <= 0) { + L.eThrow("FrostRequestService scheduler failed for ${flag.name}") + return false + } + L.d { "Scheduled ${flag.name}" } + return true + } + } + + override fun onStartJob(params: JobParameters?): Boolean { + super.onStartJob(params) + val flagString = params?.extras?.getString(FLAG) + val flag: Flag = try { + Flag.valueOf(flagString!!) + } catch (e: Exception) { + L.e { "Local service with invalid flag $flagString" } + return true + } + launch { + when (flag) { + Flag.PURGE_IMAGE -> purgeImages() + } + } + return false + } + + private suspend fun purgeImages() { + withContext(Dispatchers.IO) { + val purge = System.currentTimeMillis() - ImageActivity.PURGE_TIME + ImageActivity.cacheDir(this@LocalService) + .listFiles(FileFilter { it.isFile && it.lastModified() < purge }) + ?.forEach { it.delete() } + } + } +} 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 3470ca07..7360c191 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/services/NotificationService.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/services/NotificationService.kt @@ -17,7 +17,6 @@ package com.pitchedapps.frost.services import android.app.job.JobParameters -import android.app.job.JobService import androidx.core.app.NotificationManagerCompat import ca.allanwang.kau.utils.string import com.pitchedapps.frost.BuildConfig @@ -27,14 +26,10 @@ import com.pitchedapps.frost.dbflow.loadFbCookiesSync import com.pitchedapps.frost.utils.L import com.pitchedapps.frost.utils.Prefs import com.pitchedapps.frost.utils.frostEvent -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.async import kotlinx.coroutines.isActive import kotlinx.coroutines.launch -import kotlinx.coroutines.suspendCancellableCoroutine -import kotlin.coroutines.CoroutineContext +import kotlinx.coroutines.withContext /** * Created by Allan Wang on 2017-06-14. @@ -44,22 +39,20 @@ import kotlin.coroutines.CoroutineContext * * All fetching is done through parsers */ -class NotificationService : JobService(), CoroutineScope { - - private lateinit var job: Job - override val coroutineContext: CoroutineContext - get() = Dispatchers.Main + job - - private val startTime = System.currentTimeMillis() +class NotificationService : BaseJobService() { override fun onStopJob(params: JobParameters?): Boolean { + super.onStopJob(params) prepareFinish(true) return false } + private var preparedFinish = false + private fun prepareFinish(abrupt: Boolean) { - if (job.isCancelled) + if (preparedFinish) return + preparedFinish = true val time = System.currentTimeMillis() - startTime L.i { "Notification service has ${if (abrupt) "finished abruptly" else "finished"} in $time ms" } frostEvent( @@ -68,15 +61,14 @@ class NotificationService : JobService(), CoroutineScope { "IM Included" to Prefs.notificationsInstantMessages, "Duration" to time ) - job.cancel() } override fun onStartJob(params: JobParameters?): Boolean { + super.onStartJob(params) L.i { "Fetching notifications" } - job = Job() launch { try { - async { sendNotifications(params) }.await() + sendNotifications(params) } finally { if (!isActive) prepareFinish(false) @@ -86,14 +78,14 @@ class NotificationService : JobService(), CoroutineScope { return true } - private suspend fun sendNotifications(params: JobParameters?): Unit = suspendCancellableCoroutine { + private suspend fun sendNotifications(params: JobParameters?): Unit = withContext(Dispatchers.Default) { val currentId = Prefs.userId val cookies = loadFbCookiesSync() - if (it.isCancelled) return@suspendCancellableCoroutine + if (!isActive) return@withContext val jobId = params?.extras?.getInt(NOTIFICATION_PARAM_ID, -1) ?: -1 var notifCount = 0 for (cookie in cookies) { - if (it.isCancelled) break + if (!isActive) break val current = cookie.id == currentId if (Prefs.notificationsGeneral && (current || Prefs.notificationAllAccounts) diff --git a/app/src/main/res/values/strings_web_context.xml b/app/src/main/res/values/strings_web_context.xml index 7c8a9196..756a681b 100644 --- a/app/src/main/res/values/strings_web_context.xml +++ b/app/src/main/res/values/strings_web_context.xml @@ -3,6 +3,7 @@ Share Link Debug Link + Local Frost Service Frost for Facebook: Link Debug Write here. Note that your link may contain private information, but I won\'t be able to see it as the post isn\'t public. The url will still help with debugging though. If a link isn\'t loading properly, you can email me so I can help debug it. Clicking okay will open an email request diff --git a/app/src/test/kotlin/com/pitchedapps/frost/MiscTest.kt b/app/src/test/kotlin/com/pitchedapps/frost/MiscTest.kt index 20610b2a..ce125298 100644 --- a/app/src/test/kotlin/com/pitchedapps/frost/MiscTest.kt +++ b/app/src/test/kotlin/com/pitchedapps/frost/MiscTest.kt @@ -16,7 +16,9 @@ */ package com.pitchedapps.frost +import com.pitchedapps.frost.facebook.requests.call import com.pitchedapps.frost.facebook.requests.zip +import okhttp3.Request import org.junit.Test import kotlin.test.assertTrue @@ -45,4 +47,15 @@ class MiscTest { "zip did not seem to work on different threads" ) } + + @Test + fun a() { + val s = Request.Builder() + .url("https://www.allanwang.ca/ecse429/magenta.png") + .get() + .call().execute().body()!!.string() + "�PNG\n\u001A\nIDA�c����?\u0000\u0006�\u0002��p�\u0000\u0000\u0000\u0000IEND�B`�" + println("Hello") + println(s) + } } diff --git a/gradle.properties b/gradle.properties index 6778bba4..eb374206 100644 --- a/gradle.properties +++ b/gradle.properties @@ -40,6 +40,10 @@ COMMONS_TEXT=1.4 DBFLOW=4.2.4 # https://github.com/brianwernick/ExoMedia/releases EXOMEDIA=4.3.0 +# https://github.com/InsertKoinIO/koin/blob/master/CHANGELOG.md +KOIN=1.0.2 +# https://github.com/mockk/mockk/releases +MOCKK=1.8.13.kotlin13 # https://github.com/FasterXML/jackson-core/releases JACKSON=2.9.8 -- cgit v1.2.3