1
0
mirror of https://github.com/AllanWang/Frost-for-Facebook.git synced 2024-11-10 04:52:38 +01:00

Merge pull request #1260 from AllanWang/update/coroutines

Update/coroutines
This commit is contained in:
Allan Wang 2018-12-25 22:17:14 -05:00 committed by GitHub
commit 9fb5d8a3d2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 583 additions and 224 deletions

View File

@ -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,6 +181,17 @@ dependencies {
//noinspection GradleDependency
implementation "ca.allanwang.kau:core-ui:$KAU"
// 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}"
implementation "com.devbrackets.android:exomedia:${EXOMEDIA}"
@ -214,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}"

View File

@ -0,0 +1,124 @@
/*
* 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.activities
import android.content.Intent
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.rule.ActivityTestRule
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 {
@get:Rule
val activity: ActivityTestRule<ImageActivity> = 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)
putExtra(ARG_COOKIE, cookie)
}
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 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")
}
}
}

View File

@ -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 <http://www.gnu.org/licenses/>.
*/
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()

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 165 B

View File

@ -146,6 +146,11 @@
android:enabled="true"
android:label="@string/frost_requests"
android:permission="android.permission.BIND_JOB_SERVICE" />
<service
android:name=".services.LocalService"
android:enabled="true"
android:label="@string/local_service_name"
android:permission="android.permission.BIND_JOB_SERVICE" />
<receiver
android:name=".services.UpdateReceiver"

View File

@ -16,6 +16,7 @@
*/
package com.pitchedapps.frost.activities
import android.content.Context
import android.content.Intent
import android.content.res.ColorStateList
import android.graphics.Color
@ -28,13 +29,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
@ -48,12 +50,12 @@ import com.pitchedapps.frost.facebook.get
import com.pitchedapps.frost.facebook.requests.call
import com.pitchedapps.frost.facebook.requests.getFullSizedImageUrl
import com.pitchedapps.frost.facebook.requests.requestBuilder
import com.pitchedapps.frost.services.LocalService
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.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,12 +65,13 @@ 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 okhttp3.Response
import org.jetbrains.anko.activityUiThreadWithContext
import org.jetbrains.anko.doAsync
import org.jetbrains.anko.uiThread
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.File
import java.io.FileFilter
import java.io.IOException
import java.text.SimpleDateFormat
import java.util.Date
@ -79,14 +82,13 @@ import java.util.Locale
*/
class ImageActivity : KauBaseActivity() {
@Volatile
internal var errorRef: Throwable? = null
private lateinit var tempDir: File
/**
* Reference to the temporary file path
*/
private lateinit var tempFile: File
internal lateinit var tempFile: File
/**
* Reference to path for downloaded image
* Nonnull once the image is downloaded by the user
@ -94,13 +96,12 @@ class ImageActivity : KauBaseActivity() {
internal var savedFile: File? = null
/**
* Indicator for fab's click result
* Can be called from any thread
*/
internal var fabAction: FabStates = FabStates.NOTHING
set(value) {
if (field == value) return
field = value
runOnUiThread { value.update(image_fab) }
value.update(image_fab)
}
companion object {
@ -112,21 +113,18 @@ class ImageActivity : KauBaseActivity() {
private const val TIME_FORMAT = "yyyyMMdd_HHmmss"
private const val IMG_TAG = "Frost"
private const val IMG_EXTENSION = ".png"
private const val PURGE_TIME: Long = 10 * 60 * 1000 // 10 min block
const val PURGE_TIME: Long = 10 * 60 * 1000 // 10 min block
private val L = KauLoggerExtension("Image", com.pitchedapps.frost.utils.L)
fun cacheDir(context: Context): File =
File(context.cacheDir, IMAGE_FOLDER)
}
private val cookie: String? by lazy { intent.getStringExtra(ARG_COOKIE) }
val imageUrl: String by lazy { intent.getStringExtra(ARG_IMAGE_URL).trim('"') }
private val trueImageUrl: String by lazy {
val result = if (!imageUrl.isIndirectImageUrl) imageUrl
else cookie?.getFullSizedImageUrl(imageUrl)?.blockingGet() ?: imageUrl
if (result != imageUrl)
L.v { "Launching with true url $result" }
result
}
private lateinit var trueImageUrl: Deferred<String>
private val imageText: String? by lazy { intent.getStringExtra(ARG_TEXT) }
@ -138,11 +136,27 @@ class ImageActivity : KauBaseActivity() {
)}_${Math.abs(imageUrl.hashCode())}"
}
private fun loadError(e: Throwable) {
errorRef = e
e.logFrostEvent("Image load error")
if (image_progress.isVisible)
image_progress.fadeOut()
tempFile.delete()
fabAction = FabStates.ERROR
}
override fun onCreate(savedInstanceState: Bundle?) {
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(
@ -165,82 +179,23 @@ 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 {
themeWindow = false
}
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()
}
}
tempFile = File(cacheDir(this), imageHash)
launch(CoroutineExceptionHandler { _, throwable -> loadError(throwable) }) {
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())
@ -251,20 +206,56 @@ 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.
* 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 { _, err ->
if (file.isFile && file.length() == 0L) {
file.delete()
}
throw err
}
return withContext(Dispatchers.IO + exceptionHandler) {
if (!file.isFile) {
file.parentFile.mkdirs()
file.createNewFile()
} else {
file.setLastModified(System.currentTimeMillis())
}
// Forbid overwrites
if (file.length() > 0) {
L.i { "Forbid image overwrite" }
return@withContext false
}
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 = cookie.requestBuilder()
.url(trueImageUrl.await())
.get()
.call()
.execute()
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
}
}
@ -272,45 +263,25 @@ class ImageActivity : KauBaseActivity() {
kauRequestPermissions(PERMISSION_WRITE_EXTERNAL_STORAGE) { granted, _ ->
L.d { "Download image callback granted: $granted" }
if (granted) {
doAsync {
val errorHandler = CoroutineExceptionHandler { _, throwable ->
loadError(throwable)
frostSnackbar(R.string.image_download_fail)
}
launch(errorHandler) {
val destination = createPublicMediaFile()
var success = true
try {
val temp = tempFile
if (temp != null)
temp.copyTo(destination, true)
else
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) {
}
}
activityUiThreadWithContext {
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()
}
}

View File

@ -30,50 +30,83 @@ 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
import kotlin.coroutines.suspendCoroutine
/**
* Created by Allan Wang on 2017-05-30.
*/
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 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<Boolean>()) }
cookies.forEach { (cookie, callback) -> setCookie(FB_URL_BASE, 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 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
}
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)
}
}
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(Dispatchers.Main) {
manager.suspendSetWebCookie(dbCookie)
}
}
}
@ -107,7 +140,7 @@ object FbCookie {
}
L.d { "Switching User" }
Prefs.userId = cookie.id
setWebCookie(cookie.cookie, callback)
CookieManager.getInstance().setWebCookie(cookie.cookie, callback)
}
/**

View File

@ -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()

View File

@ -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 <http://www.gnu.org/licenses/>.
*/
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

View File

@ -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<NotificationService>(NOTIFICATION_PERIODIC_JOB, minutes)
const val NOTIFICATION_JOB_NOW = 6
fun Context.fetchNotifications(): Boolean =
fetchJob<NotificationService>(NOTIFICATION_JOB_NOW)

View File

@ -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)

View File

@ -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 <http://www.gnu.org/licenses/>.
*/
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() }
}
}
}

View File

@ -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,8 +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 org.jetbrains.anko.doAsync
import java.util.concurrent.Future
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* Created by Allan Wang on 2017-06-14.
@ -38,68 +39,69 @@ import java.util.concurrent.Future
*
* All fetching is done through parsers
*/
class NotificationService : JobService() {
private var future: Future<Unit>? = null
private val startTime = System.currentTimeMillis()
class NotificationService : BaseJobService() {
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
super.onStopJob(params)
prepareFinish(true)
return false
}
fun finish(params: JobParameters?) {
private var preparedFinish = false
private fun prepareFinish(abrupt: Boolean) {
if (preparedFinish)
return
preparedFinish = true
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
}
override fun onStartJob(params: JobParameters?): Boolean {
super.onStartJob(params)
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)
launch {
try {
sendNotifications(params)
} finally {
if (!isActive)
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 = withContext(Dispatchers.Default) {
val currentId = Prefs.userId
val cookies = loadFbCookiesSync()
if (!isActive) return@withContext
val jobId = params?.extras?.getInt(NOTIFICATION_PARAM_ID, -1) ?: -1
var notifCount = 0
for (cookie in cookies) {
if (!isActive) 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

View File

@ -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<String> = 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))
}
}

View File

@ -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?) {

View File

@ -3,6 +3,7 @@
<string name="share_link">Share Link</string>
<string name="debug_link">Debug Link</string>
<string name="local_service_name" translatable="false">Local Frost Service</string>
<string name="debug_link_subject" translatable="false">Frost for Facebook: Link Debug</string>
<string name="debug_link_content" translatable="false">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.</string>
<string name="debug_link_desc">If a link isn\'t loading properly, you can email me so I can help debug it. Clicking okay will open an email request</string>

View File

@ -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()
"<EFBFBD>PNG\n\u001A\nIDA<EFBFBD>c<EFBFBD><EFBFBD><EFBFBD><EFBFBD>?\u0000\u0006<EFBFBD>\u0002<EFBFBD><EFBFBD>p<EFBFBD>\u0000\u0000\u0000\u0000IEND<EFBFBD>B`<60>"
println("Hello")
println(s)
}
}

View File

@ -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
@ -23,6 +23,8 @@ ANDROID_GRADLE=3.2.1
# https://github.com/diffplug/spotless/blob/master/plugin-gradle/CHANGES.md
SPOTLESS=3.17.0
# https://github.com/Kotlin/kotlinx.coroutines/releases
COROUTINES=1.0.1
# https://github.com/bugsnag/bugsnag-android/releases
BUGSNAG=4.9.3
# https://github.com/bugsnag/bugsnag-android-gradle-plugin/releases
@ -38,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