mirror of
https://github.com/AllanWang/Frost-for-Facebook.git
synced 2024-11-08 20:12:39 +01:00
Misc (#614)
* Add locale log * Add flyweight design for authenticator * Add option to have instant messages only * Update interceptor * Add hd image model loader (#613) * Launch image view for view full image * Update changelog * Greatly improve ImageActivity loading * Update hashes * Add back keyword filter * Clean up
This commit is contained in:
parent
ad97b4ff94
commit
fd5f2a82eb
@ -177,6 +177,7 @@ dependencies {
|
||||
implementation"com.mikepenz:fastadapter-extensions:${FAST_ADAPTER_EXTENSIONS}@aar"
|
||||
|
||||
implementation "com.github.bumptech.glide:okhttp3-integration:${GLIDE}"
|
||||
kapt "com.github.bumptech.glide:compiler:${GLIDE}"
|
||||
|
||||
implementation "com.fasterxml.jackson.core:jackson-databind:2.9.3"
|
||||
|
||||
|
@ -7,7 +7,6 @@ import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.widget.ImageView
|
||||
import ca.allanwang.kau.logging.KL
|
||||
import com.bumptech.glide.Glide
|
||||
import com.bumptech.glide.request.RequestOptions
|
||||
import com.bumptech.glide.signature.ApplicationVersionSignature
|
||||
import com.crashlytics.android.Crashlytics
|
||||
@ -18,6 +17,7 @@ import com.pitchedapps.frost.dbflow.CookiesDb
|
||||
import com.pitchedapps.frost.dbflow.FbTabsDb
|
||||
import com.pitchedapps.frost.dbflow.NotificationDb
|
||||
import com.pitchedapps.frost.facebook.FbCookie
|
||||
import com.pitchedapps.frost.glide.GlideApp
|
||||
import com.pitchedapps.frost.services.scheduleNotifications
|
||||
import com.pitchedapps.frost.services.setupNotificationChannels
|
||||
import com.pitchedapps.frost.utils.FrostPglAdBlock
|
||||
@ -86,8 +86,9 @@ class FrostApp : Application() {
|
||||
DrawerImageLoader.init(object : AbstractDrawerImageLoader() {
|
||||
override fun set(imageView: ImageView, uri: Uri, placeholder: Drawable, tag: String) {
|
||||
val c = imageView.context
|
||||
val old = Glide.with(c).load(uri).apply(RequestOptions().placeholder(placeholder))
|
||||
Glide.with(c).load(uri).apply(RequestOptions()
|
||||
val request = GlideApp.with(c)
|
||||
val old = request.load(uri).apply(RequestOptions().placeholder(placeholder))
|
||||
request.load(uri).apply(RequestOptions()
|
||||
.signature(ApplicationVersionSignature.obtain(c)))
|
||||
.thumbnail(old).into(imageView)
|
||||
}
|
||||
|
@ -119,10 +119,6 @@ abstract class BaseMainActivity : BaseActivity(), MainActivityContract,
|
||||
viewPager.offscreenPageLimit = TAB_COUNT
|
||||
setupDrawer(savedInstanceState)
|
||||
|
||||
// fab.setOnClickListener { view ->
|
||||
// Snackbar.make(view, "Replace with your own action", Snackbar.LENGTH_LONG)
|
||||
// .setAction("Action", null).show()
|
||||
// }
|
||||
setFrostColors {
|
||||
toolbar(toolbar)
|
||||
themeWindow = false
|
||||
|
@ -1,12 +1,8 @@
|
||||
package com.pitchedapps.frost.activities
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.res.ColorStateList
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Color
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.os.Environment
|
||||
import android.support.design.widget.FloatingActionButton
|
||||
@ -20,24 +16,22 @@ 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.*
|
||||
import com.bumptech.glide.Glide
|
||||
import com.bumptech.glide.RequestManager
|
||||
import com.bumptech.glide.request.target.BaseTarget
|
||||
import com.bumptech.glide.request.target.SizeReadyCallback
|
||||
import com.bumptech.glide.request.target.Target
|
||||
import com.bumptech.glide.request.transition.Transition
|
||||
import com.davemorrissey.labs.subscaleview.ImageSource
|
||||
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
|
||||
import com.mikepenz.google_material_typeface_library.GoogleMaterial
|
||||
import com.mikepenz.iconics.typeface.IIcon
|
||||
import com.pitchedapps.frost.R
|
||||
import com.pitchedapps.frost.facebook.FB_IMAGE_ID_MATCHER
|
||||
import com.pitchedapps.frost.facebook.get
|
||||
import com.pitchedapps.frost.facebook.requests.call
|
||||
import com.pitchedapps.frost.utils.*
|
||||
import com.sothree.slidinguppanel.SlidingUpPanelLayout
|
||||
import okhttp3.Request
|
||||
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
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
@ -70,12 +64,13 @@ 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
|
||||
value.update(fab)
|
||||
runOnUiThread { value.update(fab) }
|
||||
}
|
||||
|
||||
companion object {
|
||||
@ -87,25 +82,27 @@ 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
|
||||
private val L = KauLoggerExtension("Image", com.pitchedapps.frost.utils.L)
|
||||
}
|
||||
|
||||
val imageUrl: String by lazy { intent.getStringExtra(ARG_IMAGE_URL).trim('"') }
|
||||
val IMAGE_URL: String by lazy { intent.getStringExtra(ARG_IMAGE_URL).trim('"') }
|
||||
|
||||
val text: String? by lazy { intent.getStringExtra(ARG_TEXT) }
|
||||
val TEXT: String? by lazy { intent.getStringExtra(ARG_TEXT) }
|
||||
|
||||
private val glide: RequestManager by lazy { Glide.with(this) }
|
||||
// a unique image identifier based on the id (if it exists), and its hash
|
||||
val IMAGE_HASH: String by lazy { "${Math.abs(FB_IMAGE_ID_MATCHER.find(IMAGE_URL)[1]?.hashCode() ?: 0)}_${Math.abs(IMAGE_URL.hashCode())}" }
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
intent?.extras ?: return finish()
|
||||
L.i { "Displaying image" }
|
||||
val layout = if (!text.isNullOrBlank()) R.layout.activity_image else R.layout.activity_image_textless
|
||||
val layout = if (!TEXT.isNullOrBlank()) R.layout.activity_image else R.layout.activity_image_textless
|
||||
setContentView(layout)
|
||||
container.setBackgroundColor(Prefs.bgColor.withMinAlpha(222))
|
||||
caption?.setTextColor(Prefs.textColor)
|
||||
caption?.setBackgroundColor(Prefs.bgColor.colorToForeground(0.2f).withAlpha(255))
|
||||
caption?.text = text
|
||||
caption?.text = TEXT
|
||||
progress.tint(Prefs.accentColor)
|
||||
panel?.addPanelSlideListener(object : SlidingUpPanelLayout.SimplePanelSlideListener() {
|
||||
override fun onPanelSlide(panel: View, slideOffset: Float) {
|
||||
@ -119,90 +116,76 @@ class ImageActivity : KauBaseActivity() {
|
||||
override fun onImageLoadError(e: Exception?) {
|
||||
errorRef = e
|
||||
e.logFrostAnswers("Image load error")
|
||||
imageCallback(null, false)
|
||||
fabAction = FabStates.ERROR
|
||||
}
|
||||
})
|
||||
glide.asBitmap().load(imageUrl).into(PhotoTarget(this::imageCallback))
|
||||
setFrostColors {
|
||||
themeWindow = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback to add image to view
|
||||
* [resource] is guaranteed to be nonnull when [success] is true
|
||||
* and null when it is false
|
||||
*/
|
||||
private fun imageCallback(resource: Bitmap?, success: Boolean) {
|
||||
if (progress.isVisible) progress.fadeOut()
|
||||
if (success) {
|
||||
saveTempImage(resource!!, {
|
||||
if (it == null) {
|
||||
imageCallback(null, false)
|
||||
} else {
|
||||
photo.setImage(ImageSource.uri(it))
|
||||
fabAction = FabStates.DOWNLOAD
|
||||
photo.animate().alpha(1f).scaleXY(1f).withEndAction(fab::show).start()
|
||||
}
|
||||
})
|
||||
} else {
|
||||
doAsync({
|
||||
L.e(it) { "Failed to load image $IMAGE_HASH" }
|
||||
errorRef = it
|
||||
fabAction = FabStates.ERROR
|
||||
fab.show()
|
||||
}) {
|
||||
loadImage { file ->
|
||||
if (file == null) {
|
||||
fabAction = FabStates.ERROR
|
||||
return@loadImage
|
||||
}
|
||||
tempFile = file
|
||||
L.d { "Temp image path ${file.absolutePath}" }
|
||||
uiThread {
|
||||
photo.setImage(ImageSource.uri(frostUriFromFile(file)))
|
||||
fabAction = FabStates.DOWNLOAD
|
||||
photo.animate().alpha(1f).scaleXY(1f).start()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bitmap load handler
|
||||
* Returns a file pointing to the image, or null if something goes wrong
|
||||
*/
|
||||
class PhotoTarget(val callback: (resource: Bitmap?, success: Boolean) -> Unit) : BaseTarget<Bitmap>() {
|
||||
|
||||
override fun removeCallback(cb: SizeReadyCallback?) {}
|
||||
|
||||
override fun onResourceReady(resource: Bitmap, transition: Transition<in Bitmap>?) =
|
||||
callback(resource, true)
|
||||
|
||||
override fun onLoadFailed(errorDrawable: Drawable?) =
|
||||
callback(null, false)
|
||||
|
||||
override fun getSize(cb: SizeReadyCallback) =
|
||||
cb.onSizeReady(Target.SIZE_ORIGINAL, Target.SIZE_ORIGINAL)
|
||||
private inline fun loadImage(callback: (file: File?) -> Unit) {
|
||||
val local = File(tempDir, IMAGE_HASH)
|
||||
if (local.exists() && local.length() > 1) {
|
||||
local.setLastModified(System.currentTimeMillis())
|
||||
L.d { "Loading from local cache ${local.absolutePath}" }
|
||||
return callback(local)
|
||||
}
|
||||
val response = Request.Builder()
|
||||
.url(IMAGE_URL)
|
||||
.get()
|
||||
.call()
|
||||
.execute()
|
||||
|
||||
if (!response.isSuccessful) {
|
||||
L.e { "Unsuccessful response for image" }
|
||||
errorRef = Throwable("Unsuccessful response for image")
|
||||
return callback(null)
|
||||
}
|
||||
|
||||
private fun saveTempImage(resource: Bitmap, callback: (uri: Uri?) -> Unit) {
|
||||
var photoFile: File? = null
|
||||
try {
|
||||
photoFile = createPrivateMediaFile()
|
||||
} catch (e: IOException) {
|
||||
errorRef = e
|
||||
logImage(e)
|
||||
} finally {
|
||||
if (photoFile == null) {
|
||||
callback(null)
|
||||
} else {
|
||||
tempFile = photoFile
|
||||
L.d { "Temp image path ${tempFile?.absolutePath}" }
|
||||
// File created; proceed with request
|
||||
val photoURI = frostUriFromFile(photoFile)
|
||||
photoFile.outputStream().use { resource.compress(Bitmap.CompressFormat.PNG, 100, it) }
|
||||
callback(photoURI)
|
||||
if (!local.createFreshFile()) {
|
||||
L.e { "Could not create temp file" }
|
||||
return callback(null)
|
||||
}
|
||||
|
||||
var valid = false
|
||||
|
||||
response.body()?.byteStream()?.use { input ->
|
||||
local.outputStream().use { output ->
|
||||
input.copyTo(output)
|
||||
valid = true
|
||||
}
|
||||
}
|
||||
|
||||
private fun logImage(e: Exception?) {
|
||||
if (!Prefs.analytics) return
|
||||
val error = e ?: IOException("$imageUrl failed to load")
|
||||
L.e(error) { "$imageUrl failed to load" }
|
||||
if (!valid) {
|
||||
L.e { "Failed to copy file" }
|
||||
local.delete()
|
||||
return callback(null)
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
private fun createPrivateMediaFile(): File {
|
||||
val timeStamp = SimpleDateFormat(TIME_FORMAT, Locale.getDefault()).format(Date())
|
||||
val imageFileName = "${IMG_TAG}_${timeStamp}_"
|
||||
if (!tempDir.exists())
|
||||
tempDir.mkdirs()
|
||||
return File.createTempFile(imageFileName, IMG_EXTENSION, tempDir)
|
||||
callback(local)
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
@ -218,7 +201,7 @@ class ImageActivity : KauBaseActivity() {
|
||||
@Throws(IOException::class)
|
||||
private fun downloadImageTo(file: File) {
|
||||
val body = Request.Builder()
|
||||
.url(imageUrl)
|
||||
.url(IMAGE_URL)
|
||||
.get()
|
||||
.call()
|
||||
.execute()
|
||||
@ -270,8 +253,10 @@ class ImageActivity : KauBaseActivity() {
|
||||
|
||||
override fun onDestroy() {
|
||||
tempFile = null
|
||||
tempDir.deleteRecursively()
|
||||
L.d { "Closing $localClassName" }
|
||||
val purge = System.currentTimeMillis() - PURGE_TIME
|
||||
tempDir.listFiles(FileFilter { it.isFile && it.lastModified() < purge }).forEach {
|
||||
it.delete()
|
||||
}
|
||||
super.onDestroy()
|
||||
}
|
||||
}
|
||||
@ -287,7 +272,7 @@ internal enum class FabStates(val iicon: IIcon, val iconColor: Int = Prefs.iconC
|
||||
if (activity.errorRef != null)
|
||||
L.e(activity.errorRef) { "ImageActivity error report" }
|
||||
activity.sendFrostEmail(R.string.debug_image_link_subject) {
|
||||
addItem("Url", activity.imageUrl)
|
||||
addItem("Url", activity.IMAGE_URL)
|
||||
addItem("Message", activity.errorRef?.message ?: "Null")
|
||||
}
|
||||
}
|
||||
|
@ -10,7 +10,6 @@ import android.widget.ImageView
|
||||
import ca.allanwang.kau.utils.bindView
|
||||
import ca.allanwang.kau.utils.fadeIn
|
||||
import ca.allanwang.kau.utils.fadeOut
|
||||
import com.bumptech.glide.Glide
|
||||
import com.bumptech.glide.RequestManager
|
||||
import com.bumptech.glide.load.DataSource
|
||||
import com.bumptech.glide.load.engine.GlideException
|
||||
@ -24,6 +23,7 @@ import com.pitchedapps.frost.dbflow.loadFbCookiesAsync
|
||||
import com.pitchedapps.frost.facebook.FbCookie
|
||||
import com.pitchedapps.frost.facebook.PROFILE_PICTURE_URL
|
||||
import com.pitchedapps.frost.glide.FrostGlide
|
||||
import com.pitchedapps.frost.glide.GlideApp
|
||||
import com.pitchedapps.frost.glide.transform
|
||||
import com.pitchedapps.frost.utils.*
|
||||
import com.pitchedapps.frost.web.LoginWebView
|
||||
@ -73,7 +73,7 @@ class LoginActivity : BaseActivity() {
|
||||
loadInfo(cookie)
|
||||
})
|
||||
}
|
||||
profileLoader = Glide.with(profile)
|
||||
profileLoader = GlideApp.with(profile)
|
||||
}
|
||||
|
||||
private fun loadInfo(cookie: CookieModel) {
|
||||
|
@ -25,6 +25,7 @@ val FB_NOTIF_ID_MATCHER: Regex by lazy { Regex("notif_([0-9]+)") }
|
||||
val FB_MESSAGE_NOTIF_ID_MATCHER: Regex by lazy { Regex("[thread|user]_fbid_([0-9]+)") }
|
||||
val FB_CSS_URL_MATCHER: Regex by lazy { Regex("url\\([\"|']?(.*?)[\"|']?\\)") }
|
||||
val FB_JSON_URL_MATCHER: Regex by lazy { Regex("\"(http.*?)\"") }
|
||||
val FB_IMAGE_ID_MATCHER: Regex by lazy { Regex("fbcdn.*?/[0-9]+_([0-9]+)_") }
|
||||
|
||||
operator fun MatchResult?.get(groupIndex: Int) = this?.groupValues?.get(groupIndex)
|
||||
|
||||
|
@ -2,6 +2,7 @@ package com.pitchedapps.frost.facebook.requests
|
||||
|
||||
import com.pitchedapps.frost.BuildConfig
|
||||
import com.pitchedapps.frost.facebook.*
|
||||
import com.pitchedapps.frost.rx.RxFlyweight
|
||||
import com.pitchedapps.frost.utils.L
|
||||
import io.reactivex.Single
|
||||
import io.reactivex.schedulers.Schedulers
|
||||
@ -12,7 +13,17 @@ import org.apache.commons.text.StringEscapeUtils
|
||||
/**
|
||||
* Created by Allan Wang on 21/12/17.
|
||||
*/
|
||||
private val authMap: MutableMap<String, RequestAuth> = mutableMapOf()
|
||||
private class RxAuth : RxFlyweight<String, Long, RequestAuth>() {
|
||||
|
||||
override fun call(input: String) = input.getAuth()
|
||||
|
||||
override fun validate(input: String, cond: Long) =
|
||||
System.currentTimeMillis() - cond < 3600000 // valid for an hour
|
||||
|
||||
override fun cache(input: String) = System.currentTimeMillis()
|
||||
}
|
||||
|
||||
private val auth = RxAuth()
|
||||
|
||||
/**
|
||||
* Synchronously fetch [RequestAuth] from cookie
|
||||
@ -21,18 +32,13 @@ private val authMap: MutableMap<String, RequestAuth> = mutableMapOf()
|
||||
*/
|
||||
fun String?.fbRequest(fail: () -> Unit = {}, action: RequestAuth.() -> Unit) {
|
||||
if (this == null) return fail()
|
||||
val savedAuth = authMap[this]
|
||||
if (savedAuth != null) {
|
||||
savedAuth.action()
|
||||
} else {
|
||||
val auth = getAuth()
|
||||
if (!auth.isValid) {
|
||||
L.e { "Attempted fbrequest with invalid auth" }
|
||||
return fail()
|
||||
auth(this).subscribe { a: RequestAuth?, _ ->
|
||||
if (a?.isValid == true)
|
||||
a.action()
|
||||
else {
|
||||
L.e { "Failed auth for ${hashCode()}" }
|
||||
fail()
|
||||
}
|
||||
authMap.put(this, auth)
|
||||
L._i { "Found auth $auth" }
|
||||
auth.action()
|
||||
}
|
||||
}
|
||||
|
||||
@ -94,6 +100,7 @@ private fun String.requestBuilder() = Request.Builder()
|
||||
fun Request.Builder.call() = client.newCall(build())!!
|
||||
|
||||
fun String.getAuth(): RequestAuth {
|
||||
L.v { "Getting auth for ${hashCode()}" }
|
||||
var auth = RequestAuth(cookie = this)
|
||||
val id = FB_USER_MATCHER.find(this)[1]?.toLong() ?: return auth
|
||||
auth = auth.copy(userId = id)
|
||||
|
@ -1,9 +1,19 @@
|
||||
package com.pitchedapps.frost.facebook.requests
|
||||
|
||||
import com.bumptech.glide.Priority
|
||||
import com.bumptech.glide.RequestBuilder
|
||||
import com.bumptech.glide.load.DataSource
|
||||
import com.bumptech.glide.load.Options
|
||||
import com.bumptech.glide.load.data.DataFetcher
|
||||
import com.bumptech.glide.load.model.ModelLoader
|
||||
import com.bumptech.glide.load.model.ModelLoaderFactory
|
||||
import com.bumptech.glide.load.model.MultiModelLoaderFactory
|
||||
import com.bumptech.glide.request.RequestOptions
|
||||
import com.bumptech.glide.request.target.Target
|
||||
import com.bumptech.glide.signature.ObjectKey
|
||||
import com.pitchedapps.frost.facebook.FB_IMAGE_ID_MATCHER
|
||||
import com.pitchedapps.frost.facebook.FB_URL_BASE
|
||||
import com.pitchedapps.frost.facebook.get
|
||||
import okhttp3.Call
|
||||
import okhttp3.Request
|
||||
import java.io.IOException
|
||||
@ -17,8 +27,54 @@ fun RequestAuth.getFullSizedImage(fbid: Long) = frostRequest(::getJsonUrl) {
|
||||
get()
|
||||
}
|
||||
|
||||
class ImageFbidFetcher(private val fbid: Long,
|
||||
private val cookie: String) : DataFetcher<InputStream> {
|
||||
/**
|
||||
* Request loader for a potentially hd version of a url
|
||||
* In this case, each url may potentially return an id,
|
||||
* which may potentially be used to fetch a higher res image url
|
||||
* The following aims to allow such loading while adhering to Glide's lifecycle
|
||||
*/
|
||||
data class HdImageMaybe(val url: String, val cookie: String) {
|
||||
|
||||
val id: Long by lazy { FB_IMAGE_ID_MATCHER.find(url)[1]?.toLongOrNull() ?: -1 }
|
||||
|
||||
val isValid: Boolean by lazy {
|
||||
id != -1L && cookie.isNotBlank()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/*
|
||||
* The following was a test to see if hd image loading would work
|
||||
*
|
||||
* It's working and tested, though the improvements aren't really worth the extra data use
|
||||
* and reload
|
||||
*/
|
||||
|
||||
class HdImageLoadingFactory : ModelLoaderFactory<HdImageMaybe, InputStream> {
|
||||
|
||||
override fun build(multiFactory: MultiModelLoaderFactory) = HdImageLoading()
|
||||
|
||||
override fun teardown() = Unit
|
||||
}
|
||||
|
||||
fun <T> RequestBuilder<T>.loadWithPotentialHd(model: HdImageMaybe) =
|
||||
thumbnail(clone().load(model.url))
|
||||
.load(model)
|
||||
.apply(RequestOptions().override(Target.SIZE_ORIGINAL))
|
||||
|
||||
class HdImageLoading : ModelLoader<HdImageMaybe, InputStream> {
|
||||
|
||||
override fun buildLoadData(model: HdImageMaybe,
|
||||
width: Int,
|
||||
height: Int,
|
||||
options: Options?): ModelLoader.LoadData<InputStream>? =
|
||||
if (!model.isValid) null
|
||||
else ModelLoader.LoadData(ObjectKey(model), HdImageFetcher(model))
|
||||
|
||||
override fun handles(model: HdImageMaybe) = model.isValid
|
||||
}
|
||||
|
||||
class HdImageFetcher(private val model: HdImageMaybe) : DataFetcher<InputStream> {
|
||||
|
||||
@Volatile private var cancelled: Boolean = false
|
||||
private var urlCall: Call? = null
|
||||
@ -33,10 +89,12 @@ class ImageFbidFetcher(private val fbid: Long,
|
||||
override fun getDataSource(): DataSource = DataSource.REMOTE
|
||||
|
||||
override fun loadData(priority: Priority, callback: DataFetcher.DataCallback<in InputStream>) {
|
||||
cookie.fbRequest(fail = { callback.fail("Invalid auth") }) {
|
||||
if (!model.isValid) return callback.fail("Model is invalid")
|
||||
model.cookie.fbRequest(fail = { callback.fail("Invalid auth") }) {
|
||||
if (cancelled) return@fbRequest callback.fail("Cancelled")
|
||||
val url = getFullSizedImage(fbid).invoke() ?: return@fbRequest callback.fail("Null url")
|
||||
val url = getFullSizedImage(model.id).invoke() ?: return@fbRequest callback.fail("Null url")
|
||||
if (cancelled) return@fbRequest callback.fail("Cancelled")
|
||||
if (!url.contains("png") && !url.contains("jpg")) return@fbRequest callback.fail("Invalid format")
|
||||
urlCall = Request.Builder().url(url).get().call()
|
||||
|
||||
inputStream = try {
|
||||
@ -44,7 +102,6 @@ class ImageFbidFetcher(private val fbid: Long,
|
||||
} catch (e: IOException) {
|
||||
null
|
||||
}
|
||||
|
||||
callback.onDataReady(inputStream)
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,32 @@
|
||||
package com.pitchedapps.frost.facebook.requests
|
||||
|
||||
import com.pitchedapps.frost.facebook.FB_URL_BASE
|
||||
import okhttp3.Call
|
||||
|
||||
/**
|
||||
* Created by Allan Wang on 07/01/18.
|
||||
*/
|
||||
fun RequestAuth.sendMessage(group: String, content: String): FrostRequest<Boolean> {
|
||||
|
||||
// todo test more; only tested against tids=cid...
|
||||
val body = listOf(
|
||||
"tids" to group,
|
||||
"body" to content,
|
||||
"fb_dtsg" to fb_dtsg,
|
||||
"__user" to userId
|
||||
).withEmptyData("m_sess", "__dyn", "__req", "__ajax__")
|
||||
|
||||
return frostRequest(::validateMessage) {
|
||||
url("${FB_URL_BASE}messages/send")
|
||||
post(body.toForm())
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Messages are a bit weird with their responses
|
||||
*/
|
||||
private fun validateMessage(call: Call): Boolean {
|
||||
val body = call.execute().body() ?: return false
|
||||
// todo
|
||||
return true
|
||||
}
|
@ -1,9 +1,14 @@
|
||||
package com.pitchedapps.frost.glide
|
||||
|
||||
import android.content.Context
|
||||
import com.bumptech.glide.Glide
|
||||
import com.bumptech.glide.Registry
|
||||
import com.bumptech.glide.RequestBuilder
|
||||
import com.bumptech.glide.annotation.GlideModule
|
||||
import com.bumptech.glide.load.MultiTransformation
|
||||
import com.bumptech.glide.load.resource.bitmap.BitmapTransformation
|
||||
import com.bumptech.glide.load.resource.bitmap.CircleCrop
|
||||
import com.bumptech.glide.module.AppGlideModule
|
||||
import com.bumptech.glide.request.RequestOptions
|
||||
|
||||
/**
|
||||
@ -25,3 +30,11 @@ fun <T> RequestBuilder<T>.transform(vararg transformation: BitmapTransformation)
|
||||
1 -> apply(RequestOptions.bitmapTransform(transformation[0]))
|
||||
else -> apply(RequestOptions.bitmapTransform(MultiTransformation(*transformation)))
|
||||
}
|
||||
|
||||
@GlideModule
|
||||
class FrostGlideModule : AppGlideModule() {
|
||||
|
||||
override fun registerComponents(context: Context, glide: Glide, registry: Registry) {
|
||||
// registry.prepend(HdImageMaybe::class.java, InputStream::class.java, HdImageLoadingFactory())
|
||||
}
|
||||
}
|
@ -9,14 +9,13 @@ import ca.allanwang.kau.ui.createSimpleRippleDrawable
|
||||
import ca.allanwang.kau.utils.bindView
|
||||
import ca.allanwang.kau.utils.gone
|
||||
import ca.allanwang.kau.utils.visible
|
||||
import com.bumptech.glide.Glide
|
||||
import com.mikepenz.fastadapter.FastAdapter
|
||||
import com.pitchedapps.frost.R
|
||||
import com.pitchedapps.frost.facebook.requests.MenuFooterItem
|
||||
import com.pitchedapps.frost.facebook.requests.MenuHeader
|
||||
import com.pitchedapps.frost.facebook.requests.MenuItem
|
||||
import com.pitchedapps.frost.glide.FrostGlide
|
||||
import com.pitchedapps.frost.glide.transform
|
||||
import com.pitchedapps.frost.glide.GlideApp
|
||||
import com.pitchedapps.frost.utils.Prefs
|
||||
|
||||
/**
|
||||
@ -42,7 +41,8 @@ class MenuContentIItem(val data: MenuItem)
|
||||
badge.setTextColor(Prefs.textColor)
|
||||
val iconUrl = item.data.pic
|
||||
if (iconUrl != null)
|
||||
Glide.with(itemView).load(iconUrl)
|
||||
GlideApp.with(itemView)
|
||||
.load(iconUrl)
|
||||
.transform(FrostGlide.roundCorner)
|
||||
.into(icon.visible())
|
||||
else
|
||||
|
@ -10,12 +10,11 @@ import ca.allanwang.kau.utils.bindView
|
||||
import ca.allanwang.kau.utils.gone
|
||||
import ca.allanwang.kau.utils.visible
|
||||
import ca.allanwang.kau.utils.withAlpha
|
||||
import com.bumptech.glide.Glide
|
||||
import com.mikepenz.fastadapter.FastAdapter
|
||||
import com.mikepenz.fastadapter.adapters.ItemAdapter
|
||||
import com.pitchedapps.frost.R
|
||||
import com.pitchedapps.frost.glide.FrostGlide
|
||||
import com.pitchedapps.frost.glide.transform
|
||||
import com.pitchedapps.frost.glide.GlideApp
|
||||
import com.pitchedapps.frost.parsers.FrostNotif
|
||||
import com.pitchedapps.frost.services.FrostRunnable
|
||||
import com.pitchedapps.frost.utils.Prefs
|
||||
@ -52,7 +51,7 @@ class NotificationIItem(val notification: FrostNotif, val cookie: String) : KauI
|
||||
val thumbnail: ImageView by bindView(R.id.item_thumbnail)
|
||||
|
||||
private val glide
|
||||
get() = Glide.with(itemView)
|
||||
get() = GlideApp.with(itemView)
|
||||
|
||||
override fun bindView(item: NotificationIItem, payloads: MutableList<Any>) {
|
||||
val notif = item.notification
|
||||
|
@ -24,6 +24,12 @@ import org.jsoup.select.Elements
|
||||
*/
|
||||
interface FrostParser<out T : Any> {
|
||||
|
||||
/**
|
||||
* Name associated to parser
|
||||
* Purely for display
|
||||
*/
|
||||
var nameRes: Int
|
||||
|
||||
/**
|
||||
* Url to request from
|
||||
*/
|
||||
|
@ -69,6 +69,8 @@ data class FrostThread(val id: Long,
|
||||
|
||||
private class MessageParserImpl : FrostParserBase<FrostMessages>(true) {
|
||||
|
||||
override var nameRes = FbItem.MESSAGES.titleId
|
||||
|
||||
override val url = FbItem.MESSAGES.url
|
||||
|
||||
override fun textToDoc(text: String): Document? {
|
||||
|
@ -60,6 +60,8 @@ data class FrostNotif(val id: Long,
|
||||
|
||||
private class NotifParserImpl : FrostParserBase<FrostNotifs>(false) {
|
||||
|
||||
override var nameRes = FbItem.NOTIFICATIONS.titleId
|
||||
|
||||
override val url = FbItem.NOTIFICATIONS.url
|
||||
|
||||
override fun parseImpl(doc: Document): FrostNotifs? {
|
||||
|
@ -55,6 +55,8 @@ data class FrostSearch(val href: String, val title: String, val description: Str
|
||||
|
||||
private class SearchParserImpl : FrostParserBase<FrostSearches>(false) {
|
||||
|
||||
override var nameRes = FbItem._SEARCH.titleId
|
||||
|
||||
override val url = "${FbItem._SEARCH.url}?q=a"
|
||||
|
||||
override fun parseImpl(doc: Document): FrostSearches? {
|
||||
|
86
app/src/main/kotlin/com/pitchedapps/frost/rx/RxFlyweight.kt
Normal file
86
app/src/main/kotlin/com/pitchedapps/frost/rx/RxFlyweight.kt
Normal file
@ -0,0 +1,86 @@
|
||||
package com.pitchedapps.frost.rx
|
||||
|
||||
import io.reactivex.Single
|
||||
import io.reactivex.schedulers.Schedulers
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* Created by Allan Wang on 07/01/18.
|
||||
*
|
||||
* Reactive flyweight to help deal with prolonged executions
|
||||
* Each call will output a [Single], which may be new if none exist or the old one is invalidated,
|
||||
* or reused if an old one is still valid
|
||||
*
|
||||
* Types:
|
||||
* T input argument for caller
|
||||
* C condition condition to check against for validity
|
||||
* R response response within reactive output
|
||||
*/
|
||||
abstract class RxFlyweight<in T : Any, C : Any, R : Any> {
|
||||
|
||||
/**
|
||||
* Given an input emit the desired response
|
||||
* This will be executed in a separate thread
|
||||
*/
|
||||
protected abstract fun call(input: T): R
|
||||
|
||||
/**
|
||||
* Given an input and condition, check if
|
||||
* we may used cache data or if we need to make a new request
|
||||
* Return [true] to use cache, [false] otherwise
|
||||
*/
|
||||
protected abstract fun validate(input: T, cond: C): Boolean
|
||||
|
||||
/**
|
||||
* Given an input, create a new condition to be used
|
||||
* for future requests
|
||||
*/
|
||||
protected abstract fun cache(input: T): C
|
||||
|
||||
private val conditionals = mutableMapOf<T, C>()
|
||||
private val sources = mutableMapOf<T, Single<R>>()
|
||||
|
||||
private val lock = Any()
|
||||
|
||||
/**
|
||||
* Entry point to give an input a receive a [Single]
|
||||
* Note that the observer is not bound to any particular thread,
|
||||
* as it is dependent on [createNewSource]
|
||||
*/
|
||||
operator fun invoke(input: T): Single<R> {
|
||||
synchronized(lock) {
|
||||
val source = sources[input]
|
||||
|
||||
// update condition and retrieve old one
|
||||
val condition = conditionals.put(input, cache(input))
|
||||
|
||||
// check to reuse observable
|
||||
if (source != null && condition != null && validate(input, condition))
|
||||
return source
|
||||
|
||||
val newSource = createNewSource(input).cache().doOnError { sources.remove(input) }
|
||||
|
||||
sources.put(input, newSource)
|
||||
return newSource
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open source creator
|
||||
* Result will then be created with [Single.cache]
|
||||
* If you don't have a need for cache,
|
||||
* you likely won't have a need for flyweights
|
||||
*/
|
||||
open protected fun createNewSource(input: T): Single<R> =
|
||||
Single.fromCallable { call(input) }
|
||||
.timeout(20, TimeUnit.SECONDS)
|
||||
.subscribeOn(Schedulers.io())
|
||||
|
||||
fun reset() {
|
||||
synchronized(lock) {
|
||||
sources.clear()
|
||||
conditionals.clear()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
@ -19,7 +19,6 @@ import android.support.v4.app.NotificationManagerCompat
|
||||
import ca.allanwang.kau.utils.color
|
||||
import ca.allanwang.kau.utils.dpToPx
|
||||
import ca.allanwang.kau.utils.string
|
||||
import com.bumptech.glide.Glide
|
||||
import com.bumptech.glide.request.target.SimpleTarget
|
||||
import com.bumptech.glide.request.transition.Transition
|
||||
import com.pitchedapps.frost.BuildConfig
|
||||
@ -31,7 +30,7 @@ import com.pitchedapps.frost.dbflow.lastNotificationTime
|
||||
import com.pitchedapps.frost.enums.OverlayContext
|
||||
import com.pitchedapps.frost.facebook.FbItem
|
||||
import com.pitchedapps.frost.glide.FrostGlide
|
||||
import com.pitchedapps.frost.glide.transform
|
||||
import com.pitchedapps.frost.glide.GlideApp
|
||||
import com.pitchedapps.frost.parsers.FrostParser
|
||||
import com.pitchedapps.frost.parsers.MessageParser
|
||||
import com.pitchedapps.frost.parsers.NotifParser
|
||||
@ -146,7 +145,10 @@ enum class NotificationType(
|
||||
fun fetch(context: Context, data: CookieModel) {
|
||||
val response = parser.parse(data.cookie)
|
||||
?: return L.v { "$name notification data not found" }
|
||||
val notifs = response.data.getUnreadNotifications(data)
|
||||
val notifs = response.data.getUnreadNotifications(data).filter {
|
||||
val text = it.text
|
||||
Prefs.notificationKeywords.any { text.contains(it, true) }
|
||||
}
|
||||
if (notifs.isEmpty()) return
|
||||
var notifCount = 0
|
||||
val userId = data.id
|
||||
@ -201,7 +203,7 @@ enum class NotificationType(
|
||||
if (profileUrl != null) {
|
||||
context.runOnUiThread {
|
||||
//todo verify if context is valid?
|
||||
Glide.with(context)
|
||||
GlideApp.with(context)
|
||||
.asBitmap()
|
||||
.load(profileUrl)
|
||||
.transform(FrostGlide.circleCrop)
|
||||
|
@ -61,7 +61,8 @@ class NotificationService : JobService() {
|
||||
val cookies = loadFbCookiesSync()
|
||||
cookies.forEach {
|
||||
val current = it.id == currentId
|
||||
if (current || Prefs.notificationAllAccounts)
|
||||
if (Prefs.notificationsGeneral
|
||||
&& (current || Prefs.notificationAllAccounts))
|
||||
NotificationType.GENERAL.fetch(context, it)
|
||||
if (Prefs.notificationsInstantMessages
|
||||
&& (current || Prefs.notificationsImAllAccounts))
|
||||
|
@ -3,6 +3,7 @@ package com.pitchedapps.frost.settings
|
||||
import ca.allanwang.kau.kpref.activity.KPrefAdapterBuilder
|
||||
import ca.allanwang.kau.utils.materialDialog
|
||||
import ca.allanwang.kau.utils.startActivityForResult
|
||||
import ca.allanwang.kau.utils.string
|
||||
import com.pitchedapps.frost.R
|
||||
import com.pitchedapps.frost.activities.DebugActivity
|
||||
import com.pitchedapps.frost.activities.SettingsActivity
|
||||
@ -10,6 +11,9 @@ import com.pitchedapps.frost.activities.SettingsActivity.Companion.ACTIVITY_REQU
|
||||
import com.pitchedapps.frost.debugger.OfflineWebsite
|
||||
import com.pitchedapps.frost.facebook.FbCookie
|
||||
import com.pitchedapps.frost.facebook.FbItem
|
||||
import com.pitchedapps.frost.parsers.MessageParser
|
||||
import com.pitchedapps.frost.parsers.NotifParser
|
||||
import com.pitchedapps.frost.parsers.SearchParser
|
||||
import com.pitchedapps.frost.utils.L
|
||||
import com.pitchedapps.frost.utils.frostUriFromFile
|
||||
import com.pitchedapps.frost.utils.sendFrostEmail
|
||||
@ -34,6 +38,23 @@ fun SettingsActivity.getDebugPrefs(): KPrefAdapterBuilder.() -> Unit = {
|
||||
descRes = R.string.debug_web_desc
|
||||
onClick = { this@getDebugPrefs.startActivityForResult<DebugActivity>(ACTIVITY_REQUEST_DEBUG) }
|
||||
}
|
||||
|
||||
plainText(R.string.debug_parsers) {
|
||||
descRes = R.string.debug_parsers_desc
|
||||
onClick = {
|
||||
|
||||
val parsers = arrayOf(NotifParser, MessageParser, SearchParser)
|
||||
|
||||
materialDialog {
|
||||
items(parsers.map { string(it.nameRes) })
|
||||
itemsCallback { dialog, _, position, _ ->
|
||||
dialog.dismiss()
|
||||
// todo add debugging
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private const val ZIP_NAME = "debug"
|
||||
|
@ -23,7 +23,7 @@ import com.pitchedapps.frost.views.Keywords
|
||||
fun SettingsActivity.getNotificationPrefs(): KPrefAdapterBuilder.() -> Unit = {
|
||||
|
||||
text(R.string.notification_frequency, Prefs::notificationFreq, { Prefs.notificationFreq = it }) {
|
||||
val options = longArrayOf(-1, 15, 30, 60, 120, 180, 300, 1440, 2880)
|
||||
val options = longArrayOf(15, 30, 60, 120, 180, 300, 1440, 2880)
|
||||
val texts = options.map { if (it <= 0) string(R.string.no_notifications) else minuteToText(it) }
|
||||
onClick = {
|
||||
materialDialogThemed {
|
||||
@ -36,6 +36,12 @@ fun SettingsActivity.getNotificationPrefs(): KPrefAdapterBuilder.() -> Unit = {
|
||||
})
|
||||
}
|
||||
}
|
||||
enabler = {
|
||||
val enabled = Prefs.notificationsGeneral || Prefs.notificationsInstantMessages
|
||||
if (!enabled)
|
||||
scheduleNotifications(-1)
|
||||
enabled
|
||||
}
|
||||
textGetter = { minuteToText(it) }
|
||||
}
|
||||
|
||||
@ -52,15 +58,34 @@ fun SettingsActivity.getNotificationPrefs(): KPrefAdapterBuilder.() -> Unit = {
|
||||
}
|
||||
}
|
||||
|
||||
checkbox(R.string.notification_all_accounts, Prefs::notificationAllAccounts, { Prefs.notificationAllAccounts = it }) {
|
||||
descRes = R.string.notification_all_accounts_desc
|
||||
checkbox(R.string.notification_general, Prefs::notificationsGeneral,
|
||||
{
|
||||
Prefs.notificationsGeneral = it
|
||||
reloadByTitle(R.string.notification_general_all_accounts)
|
||||
if (!Prefs.notificationsInstantMessages)
|
||||
reloadByTitle(R.string.notification_frequency)
|
||||
}) {
|
||||
descRes = R.string.notification_general_desc
|
||||
}
|
||||
|
||||
checkbox(R.string.notification_messages, Prefs::notificationsInstantMessages, { Prefs.notificationsInstantMessages = it; reloadByTitle(R.string.notification_messages_all_accounts) }) {
|
||||
checkbox(R.string.notification_general_all_accounts, Prefs::notificationAllAccounts,
|
||||
{ Prefs.notificationAllAccounts = it }) {
|
||||
descRes = R.string.notification_general_all_accounts_desc
|
||||
enabler = Prefs::notificationsGeneral
|
||||
}
|
||||
|
||||
checkbox(R.string.notification_messages, Prefs::notificationsInstantMessages,
|
||||
{
|
||||
Prefs.notificationsInstantMessages = it
|
||||
reloadByTitle(R.string.notification_messages_all_accounts)
|
||||
if (!Prefs.notificationsGeneral)
|
||||
reloadByTitle(R.string.notification_frequency)
|
||||
}) {
|
||||
descRes = R.string.notification_messages_desc
|
||||
}
|
||||
|
||||
checkbox(R.string.notification_messages_all_accounts, Prefs::notificationsImAllAccounts, { Prefs.notificationsImAllAccounts = it }) {
|
||||
checkbox(R.string.notification_messages_all_accounts, Prefs::notificationsImAllAccounts,
|
||||
{ Prefs.notificationsImAllAccounts = it }) {
|
||||
descRes = R.string.notification_messages_all_accounts_desc
|
||||
enabler = Prefs::notificationsInstantMessages
|
||||
}
|
||||
@ -91,22 +116,28 @@ fun SettingsActivity.getNotificationPrefs(): KPrefAdapterBuilder.() -> Unit = {
|
||||
}
|
||||
}
|
||||
|
||||
text(R.string.notification_ringtone, Prefs::notificationRingtone, { Prefs.notificationRingtone = it }) {
|
||||
text(R.string.notification_ringtone, Prefs::notificationRingtone,
|
||||
{ Prefs.notificationRingtone = it }) {
|
||||
ringtone(SettingsActivity.REQUEST_NOTIFICATION_RINGTONE)
|
||||
}
|
||||
|
||||
text(R.string.message_ringtone, Prefs::messageRingtone, { Prefs.messageRingtone = it }) {
|
||||
text(R.string.message_ringtone, Prefs::messageRingtone,
|
||||
{ Prefs.messageRingtone = it }) {
|
||||
ringtone(SettingsActivity.REQUEST_MESSAGE_RINGTONE)
|
||||
}
|
||||
|
||||
checkbox(R.string.notification_vibrate, Prefs::notificationVibrate, { Prefs.notificationVibrate = it })
|
||||
checkbox(R.string.notification_vibrate, Prefs::notificationVibrate,
|
||||
{ Prefs.notificationVibrate = it })
|
||||
|
||||
checkbox(R.string.notification_lights, Prefs::notificationLights, { Prefs.notificationLights = it })
|
||||
checkbox(R.string.notification_lights, Prefs::notificationLights,
|
||||
{ Prefs.notificationLights = it })
|
||||
|
||||
plainText(R.string.notification_fetch_now) {
|
||||
descRes = R.string.notification_fetch_now_desc
|
||||
onClick = {
|
||||
val text = if (fetchNotifications()) R.string.notification_fetch_success else R.string.notification_fetch_fail
|
||||
val text =
|
||||
if (fetchNotifications()) R.string.notification_fetch_success
|
||||
else R.string.notification_fetch_fail
|
||||
frostSnackbar(text)
|
||||
}
|
||||
}
|
||||
|
@ -113,6 +113,8 @@ object Prefs : KPref() {
|
||||
|
||||
var notificationKeywords: StringSet by kpref("notification_keywords", mutableSetOf())
|
||||
|
||||
var notificationsGeneral: Boolean by kpref("notification_general", true)
|
||||
|
||||
var notificationAllAccounts: Boolean by kpref("notification_all_accounts", true)
|
||||
|
||||
var notificationsInstantMessages: Boolean by kpref("notification_im", true)
|
||||
|
@ -250,8 +250,15 @@ inline val String?.isFacebookUrl
|
||||
/**
|
||||
* [true] if url is a video and can be accepted by VideoViewer
|
||||
*/
|
||||
inline val String?.isVideoUrl
|
||||
get() = this != null && (startsWith(VIDEO_REDIRECT) || startsWith("https://video-"))
|
||||
inline val String.isVideoUrl
|
||||
get() = startsWith(VIDEO_REDIRECT) || startsWith("https://video-")
|
||||
|
||||
/**
|
||||
* [true] if url is or redirects to an explicit facebook image
|
||||
*/
|
||||
inline val String.isImageUrl
|
||||
get() = (contains("fbcdn.net") && (contains(".png") || contains(".jpg")))
|
||||
|| contains("/photo/view_full_size")
|
||||
|
||||
/**
|
||||
* [true] if url can be displayed in a different webview
|
||||
@ -308,6 +315,7 @@ fun EmailBuilder.addFrostDetails() {
|
||||
addItem("Prev version", Prefs.prevVersionCode.toString())
|
||||
val proTag = if (IS_FROST_PRO) "TY" else "FP"
|
||||
addItem("Random Frost ID", "${Prefs.frostId}-$proTag")
|
||||
addItem("Locale", Locale.getDefault().displayName)
|
||||
}
|
||||
|
||||
fun frostJsoup(url: String)
|
||||
|
@ -7,7 +7,6 @@ import android.view.View
|
||||
import android.widget.ImageView
|
||||
import ca.allanwang.kau.iitems.KauIItem
|
||||
import ca.allanwang.kau.utils.*
|
||||
import com.bumptech.glide.Glide
|
||||
import com.bumptech.glide.load.DataSource
|
||||
import com.bumptech.glide.load.engine.GlideException
|
||||
import com.bumptech.glide.request.RequestListener
|
||||
@ -17,7 +16,7 @@ import com.pitchedapps.frost.R
|
||||
import com.pitchedapps.frost.dbflow.CookieModel
|
||||
import com.pitchedapps.frost.facebook.PROFILE_PICTURE_URL
|
||||
import com.pitchedapps.frost.glide.FrostGlide
|
||||
import com.pitchedapps.frost.glide.transform
|
||||
import com.pitchedapps.frost.glide.GlideApp
|
||||
import com.pitchedapps.frost.utils.Prefs
|
||||
|
||||
/**
|
||||
@ -33,7 +32,7 @@ class AccountItem(val cookie: CookieModel?) : KauIItem<AccountItem, AccountItem.
|
||||
text.setTextColor(Prefs.textColor)
|
||||
if (cookie != null) {
|
||||
text.text = cookie.name
|
||||
Glide.with(itemView).load(PROFILE_PICTURE_URL(cookie.id))
|
||||
GlideApp.with(itemView).load(PROFILE_PICTURE_URL(cookie.id))
|
||||
.transform(FrostGlide.roundCorner).listener(object : RequestListener<Drawable> {
|
||||
override fun onResourceReady(resource: Drawable?, model: Any?, target: Target<Drawable>?, dataSource: DataSource?, isFirstResource: Boolean): Boolean {
|
||||
text.fadeIn()
|
||||
|
@ -17,35 +17,13 @@ import java.io.ByteArrayInputStream
|
||||
*/
|
||||
private val blankResource: WebResourceResponse by lazy { WebResourceResponse("text/plain", "utf-8", ByteArrayInputStream("".toByteArray())) }
|
||||
|
||||
//these hosts will redirect to a blank resource
|
||||
private val blacklistHost: Set<String> =
|
||||
setOf(
|
||||
// "edge-chat.facebook.com" //todo make more specific? This is required for message responses
|
||||
)
|
||||
|
||||
//these hosts will return null and skip logging
|
||||
private val whitelistHost: Set<String> =
|
||||
setOf(
|
||||
"static.xx.fbcdn.net",
|
||||
"m.facebook.com",
|
||||
"touch.facebook.com"
|
||||
)
|
||||
|
||||
//these hosts will skip ad inspection
|
||||
//this list does not have to include anything from the two above
|
||||
private val adWhitelistHost: Set<String> =
|
||||
setOf(
|
||||
"scontent-sea1-1.xx.fbcdn.net"
|
||||
)
|
||||
|
||||
fun WebView.shouldFrostInterceptRequest(request: WebResourceRequest): WebResourceResponse? {
|
||||
request.url ?: return null
|
||||
val httpUrl = HttpUrl.parse(request.url.toString()) ?: return null
|
||||
val requestUrl = request.url?.toString() ?: return null
|
||||
val httpUrl = HttpUrl.parse(requestUrl) ?: return null
|
||||
val host = httpUrl.host()
|
||||
val url = httpUrl.toString()
|
||||
// if (blacklistHost.contains(host)) return blankResource
|
||||
if (whitelistHost.contains(host)) return null
|
||||
if (!adWhitelistHost.contains(host) && FrostPglAdBlock.isAdHost(host)) return blankResource
|
||||
if (host.contains("facebook") || host.contains("fbcdn")) return null
|
||||
if (FrostPglAdBlock.isAdHost(host)) return blankResource
|
||||
// if (!shouldLoadImages && !Prefs.loadMediaOnMeteredNetwork && request.isMedia) return blankResource
|
||||
L.v { "Intercept Request: $host $url" }
|
||||
return null
|
||||
|
@ -3,7 +3,6 @@ package com.pitchedapps.frost.web
|
||||
import com.pitchedapps.frost.activities.WebOverlayActivity
|
||||
import com.pitchedapps.frost.activities.WebOverlayActivityBase
|
||||
import com.pitchedapps.frost.activities.WebOverlayBasicActivity
|
||||
|
||||
import com.pitchedapps.frost.contracts.VideoViewHolder
|
||||
import com.pitchedapps.frost.facebook.FbItem
|
||||
import com.pitchedapps.frost.facebook.USER_AGENT_BASIC
|
||||
@ -36,6 +35,11 @@ fun FrostWebView.requestWebOverlay(url: String): Boolean {
|
||||
context.runOnUiThread { context.showVideo(url) }
|
||||
return true
|
||||
}
|
||||
if (url.isImageUrl) {
|
||||
L.d { "Found fb image" }
|
||||
context.launchImageActivity(url.formattedFbUrl, null)
|
||||
return true
|
||||
}
|
||||
if (!url.isIndependent) {
|
||||
L.d { "Forbid overlay switch" }
|
||||
return false
|
||||
@ -43,13 +47,14 @@ fun FrostWebView.requestWebOverlay(url: String): Boolean {
|
||||
if (!Prefs.overlayEnabled) return false
|
||||
if (context is WebOverlayActivityBase) {
|
||||
L.v { "Check web request from overlay" }
|
||||
val shouldUseBasic = url.formattedFbUrl.shouldUseBasicAgent
|
||||
//already overlay; manage user agent
|
||||
if (userAgentString != USER_AGENT_BASIC && url.formattedFbUrl.shouldUseBasicAgent) {
|
||||
if (userAgentString != USER_AGENT_BASIC && shouldUseBasic) {
|
||||
L.i { "Switch to basic agent overlay" }
|
||||
context.launchWebOverlayBasic(url)
|
||||
return true
|
||||
}
|
||||
if (context is WebOverlayBasicActivity && !url.formattedFbUrl.shouldUseBasicAgent) {
|
||||
if (context is WebOverlayBasicActivity && !shouldUseBasic) {
|
||||
L.i { "Switch from basic agent" }
|
||||
context.launchWebOverlay(url)
|
||||
return true
|
||||
|
@ -1,15 +1,11 @@
|
||||
package com.pitchedapps.frost.web
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Color
|
||||
import android.webkit.WebResourceRequest
|
||||
import android.webkit.WebResourceResponse
|
||||
import android.webkit.WebView
|
||||
import android.webkit.WebViewClient
|
||||
import com.pitchedapps.frost.activities.LoginActivity
|
||||
import com.pitchedapps.frost.activities.MainActivity
|
||||
import com.pitchedapps.frost.activities.SelectorActivity
|
||||
import com.pitchedapps.frost.facebook.FB_URL_BASE
|
||||
import com.pitchedapps.frost.facebook.FbItem
|
||||
import com.pitchedapps.frost.injectors.*
|
||||
|
@ -8,8 +8,8 @@
|
||||
<string name="add_keyword">Schlagwort hinzufügen</string>
|
||||
<string name="hint_keyword">Gebe das Schlagwort ein und drücke +</string>
|
||||
<string name="empty_keyword">Leeres Schlagwort</string>
|
||||
<string name="notification_all_accounts">Benachrichtigungen von allen Accounts</string>
|
||||
<string name="notification_all_accounts_desc">Bekomme Benachrichtigungen von allen Accounts die eingeloggt sind. Deaktiviere dies, um nur noch Benachrichtigungen von dem aktuellen Account zu bekommen.</string>
|
||||
<string name="notification_general_all_accounts">Benachrichtigungen von allen Accounts</string>
|
||||
<string name="notification_general_all_accounts_desc">Bekomme Benachrichtigungen von allen Accounts die eingeloggt sind. Deaktiviere dies, um nur noch Benachrichtigungen von dem aktuellen Account zu bekommen.</string>
|
||||
<string name="notification_messages">Aktiviere Nachrichten Benachrichtigungen</string>
|
||||
<string name="notification_messages_desc">Bekomme sofortige Benachrichtigungen für deine Nachrichten für den aktuellen Account.</string>
|
||||
<string name="notification_messages_all_accounts">Benachrichtigungen für Nachrichten von allen Accounts</string>
|
||||
|
@ -8,8 +8,8 @@
|
||||
<string name="add_keyword">Añadir palabra clave</string>
|
||||
<string name="hint_keyword">Escriba la palabra clave y pulse +</string>
|
||||
<string name="empty_keyword">Palabra clave vacía</string>
|
||||
<string name="notification_all_accounts">Notificar de todas las cuentas</string>
|
||||
<string name="notification_all_accounts_desc">Obten notificaciones de todas las cuentas en las que estes logueado. Desactivar esto hará que se muestren solo las notificaciones de la cuenta en la que estés.</string>
|
||||
<string name="notification_general_all_accounts">Notificar de todas las cuentas</string>
|
||||
<string name="notification_general_all_accounts_desc">Obten notificaciones de todas las cuentas en las que estes logueado. Desactivar esto hará que se muestren solo las notificaciones de la cuenta en la que estés.</string>
|
||||
<string name="notification_messages">Activar notificaciones de mensajes</string>
|
||||
<string name="notification_messages_desc">Obten notificaciones instantáneas de mensajes para tu cuenta actual.</string>
|
||||
<string name="notification_messages_all_accounts">Notificar mensajes de todas las cuentas</string>
|
||||
|
@ -8,8 +8,8 @@
|
||||
<string name="add_keyword">Ajouter un mot-clé</string>
|
||||
<string name="hint_keyword">Tapez le mot-clé et appuyez sur +</string>
|
||||
<string name="empty_keyword">Mot-clé vide</string>
|
||||
<string name="notification_all_accounts">Notifier de tous les comptes</string>
|
||||
<string name="notification_all_accounts_desc">Recevoir des notifications pour chaque compte connecté. Désactiver cette option fera une récupération uniquement pour le compte sélectionné.</string>
|
||||
<string name="notification_general_all_accounts">Notifier de tous les comptes</string>
|
||||
<string name="notification_general_all_accounts_desc">Recevoir des notifications pour chaque compte connecté. Désactiver cette option fera une récupération uniquement pour le compte sélectionné.</string>
|
||||
<string name="notification_messages">Activer les notifications de message</string>
|
||||
<string name="notification_messages_desc">Recevoir des notifications de messagerie instantanée pour le compte actuel.</string>
|
||||
<string name="notification_messages_all_accounts">Notifier les messages de tous les comptes</string>
|
||||
|
@ -8,8 +8,8 @@
|
||||
<string name="add_keyword">Engadir palabra chave</string>
|
||||
<string name="hint_keyword">Escribe a palabra e toca en +</string>
|
||||
<string name="empty_keyword">Palabra chave baleira</string>
|
||||
<string name="notification_all_accounts">Notificar de todas as contas</string>
|
||||
<string name="notification_all_accounts_desc">Obtén notificacións de todas as contas con que iniciaras sesión. Ao desactivar isto, só obterás notificacións da conta actualmente seleccionada.</string>
|
||||
<string name="notification_general_all_accounts">Notificar de todas as contas</string>
|
||||
<string name="notification_general_all_accounts_desc">Obtén notificacións de todas as contas con que iniciaras sesión. Ao desactivar isto, só obterás notificacións da conta actualmente seleccionada.</string>
|
||||
<string name="notification_messages">Activar notificacións de mensaxes</string>
|
||||
<string name="notification_messages_desc">Obtén notificacións instantáneas de mensaxes para a túa conta actual.</string>
|
||||
<string name="notification_messages_all_accounts">Notificar mensaxes de todas as contas</string>
|
||||
|
@ -8,8 +8,8 @@
|
||||
<string name="add_keyword">Aggiungi parole chiave</string>
|
||||
<string name="hint_keyword">Scrivi la parola e premi +</string>
|
||||
<string name="empty_keyword">Parola Chiave Vuota</string>
|
||||
<string name="notification_all_accounts">Notifica da tutti gli accounts</string>
|
||||
<string name="notification_all_accounts_desc">Ricevi notifiche per ogni account registrato. Disattivando questa opzione riceverai notifiche solo dall\'account selezionato.</string>
|
||||
<string name="notification_general_all_accounts">Notifica da tutti gli accounts</string>
|
||||
<string name="notification_general_all_accounts_desc">Ricevi notifiche per ogni account registrato. Disattivando questa opzione riceverai notifiche solo dall\'account selezionato.</string>
|
||||
<string name="notification_messages">Attiva notifiche messaggi</string>
|
||||
<string name="notification_messages_desc">Ricevi notifiche istantanee di messaggi per il tuo account.</string>
|
||||
<string name="notification_messages_all_accounts">Notifica messaggi da tutti gli account</string>
|
||||
|
@ -8,8 +8,8 @@
|
||||
<string name="add_keyword">키워드 추가</string>
|
||||
<string name="hint_keyword">키워드를 작성하고 + 를 누르세요.</string>
|
||||
<string name="empty_keyword">키워드 비어있음</string>
|
||||
<string name="notification_all_accounts">모든 계정에서 알림</string>
|
||||
<string name="notification_all_accounts_desc">로그인 되어 있는 모든 계정에서 알림을 받습니다. 이 옺션을 해제하면 현재 로그인된 계정의 알림만 받게 됩니다.</string>
|
||||
<string name="notification_general_all_accounts">모든 계정에서 알림</string>
|
||||
<string name="notification_general_all_accounts_desc">로그인 되어 있는 모든 계정에서 알림을 받습니다. 이 옺션을 해제하면 현재 로그인된 계정의 알림만 받게 됩니다.</string>
|
||||
<string name="notification_messages">메시지 알림 활성화</string>
|
||||
<string name="notification_messages_desc">현재 계정의 메시지 알림을 받습니다.</string>
|
||||
<string name="notification_messages_all_accounts">모든 계정의 메시지 받기</string>
|
||||
|
@ -8,8 +8,8 @@
|
||||
<string name="add_keyword">Dodaj słowo kluczowe</string>
|
||||
<string name="hint_keyword">Wpisz słowo kluczowe i naciśnij +</string>
|
||||
<string name="empty_keyword">Puste słowo kluczowe</string>
|
||||
<string name="notification_all_accounts">Powiadamiaj ze wszystkich kont</string>
|
||||
<string name="notification_all_accounts_desc">Otrzymuj powiadomienia dla każdego zalogowanego konta. Wyłączenie spowoduje sprawdzanie powiadomień, tylko z obecnie wybranego konta.</string>
|
||||
<string name="notification_general_all_accounts">Powiadamiaj ze wszystkich kont</string>
|
||||
<string name="notification_general_all_accounts_desc">Otrzymuj powiadomienia dla każdego zalogowanego konta. Wyłączenie spowoduje sprawdzanie powiadomień, tylko z obecnie wybranego konta.</string>
|
||||
<string name="notification_messages">Włącz powiadomienia o wiadomościach</string>
|
||||
<string name="notification_messages_desc">Otrzymuj natychmiastowe powiadomienia dla obecnego konta.</string>
|
||||
<string name="notification_messages_all_accounts">Powiadamiaj ze wszystkich kont</string>
|
||||
|
@ -8,8 +8,8 @@
|
||||
<string name="add_keyword">Adicionar Palavra-Chave</string>
|
||||
<string name="hint_keyword">Escreva a palavra-chave e pressione +</string>
|
||||
<string name="empty_keyword">Palavra-chave Vazia</string>
|
||||
<string name="notification_all_accounts">Notificações de todas as contas</string>
|
||||
<string name="notification_all_accounts_desc">Receber notificações para qualquer conta que esteja logada. Desativar esta opção vai apenas buscar as notificações da conta atualmente selecionada.</string>
|
||||
<string name="notification_general_all_accounts">Notificações de todas as contas</string>
|
||||
<string name="notification_general_all_accounts_desc">Receber notificações para qualquer conta que esteja logada. Desativar esta opção vai apenas buscar as notificações da conta atualmente selecionada.</string>
|
||||
<string name="notification_messages">Ativar as notificações de mensagem</string>
|
||||
<string name="notification_messages_desc">Receber notificações de conversas de sua conta atual.</string>
|
||||
<string name="notification_messages_all_accounts">Notificar mensagens de todas as contas</string>
|
||||
|
@ -8,8 +8,8 @@
|
||||
<string name="add_keyword">Thêm từ khoá</string>
|
||||
<string name="hint_keyword">Nhập từ khoá rồi bấm +</string>
|
||||
<string name="empty_keyword">Từ khoá trống</string>
|
||||
<string name="notification_all_accounts">Thông báo từ tất cả các tài khoản</string>
|
||||
<string name="notification_all_accounts_desc">Hiện thông báo từ tất cả các tài khoản đăng đăng nhập. Khi tắt, sẽ chỉ hiện thông báo từ tài khoản hiện tại.</string>
|
||||
<string name="notification_general_all_accounts">Thông báo từ tất cả các tài khoản</string>
|
||||
<string name="notification_general_all_accounts_desc">Hiện thông báo từ tất cả các tài khoản đăng đăng nhập. Khi tắt, sẽ chỉ hiện thông báo từ tài khoản hiện tại.</string>
|
||||
<string name="notification_messages">Bật thông báo tin nhắn</string>
|
||||
<string name="notification_messages_desc">Nhận thông báo tin nhắn cho tài khoản hiện tại.</string>
|
||||
<string name="notification_messages_all_accounts">Thông báo tin nhắn từ tất cả các tài khoản</string>
|
||||
|
@ -8,8 +8,8 @@
|
||||
<string name="add_keyword">添加关键字</string>
|
||||
<string name="hint_keyword">输入关键字并按 +</string>
|
||||
<string name="empty_keyword">空关键字</string>
|
||||
<string name="notification_all_accounts">所有帐户通知</string>
|
||||
<string name="notification_all_accounts_desc">从每个登录帐户中获取通知。如果禁用则只从当前选择帐户中获取通知。</string>
|
||||
<string name="notification_general_all_accounts">所有帐户通知</string>
|
||||
<string name="notification_general_all_accounts_desc">从每个登录帐户中获取通知。如果禁用则只从当前选择帐户中获取通知。</string>
|
||||
<string name="notification_messages">启用通知</string>
|
||||
<string name="notification_messages_desc">获得当前帐户即时消息通知。</string>
|
||||
<string name="notification_messages_all_accounts">所有帐户消息通知</string>
|
||||
|
@ -16,4 +16,7 @@
|
||||
<string name="debug_web_desc">Navigate to the page with an issue and send the resources for debugging.</string>
|
||||
|
||||
<string name="parsing_data">Parsing Data</string>
|
||||
|
||||
<string name="debug_parsers">Debug Parsers</string>
|
||||
<string name="debug_parsers_desc">Launch one of the available parsers to debug its response data</string>
|
||||
</resources>
|
@ -8,14 +8,16 @@
|
||||
<string name="add_keyword">Add Keyword</string>
|
||||
<string name="hint_keyword">Type keyword and press +</string>
|
||||
<string name="empty_keyword">Empty Keyword</string>
|
||||
<string name="notification_all_accounts">Notify from all accounts</string>
|
||||
<string name="notification_all_accounts_desc">Get notifications for every account that is logged in. Disabling this will only fetch notifications form the currently selected account.</string>
|
||||
<string name="notification_general">Enable general notifications</string>
|
||||
<string name="notification_general_desc">Get general notifications for your current account.</string>
|
||||
<string name="notification_general_all_accounts">Notify from all accounts</string>
|
||||
<string name="notification_general_all_accounts_desc">Get general notifications for every account that is logged in.</string>
|
||||
<string name="notification_messages">Enable message notifications</string>
|
||||
<string name="notification_messages_desc">Get instant message notifications for your current account.</string>
|
||||
<string name="notification_messages_all_accounts">Notify messages from all accounts</string>
|
||||
<string name="notification_messages_all_accounts_desc">Get instant message notifications from all accounts</string>
|
||||
<string name="notification_fetch_now">Fetch Notifications Now</string>
|
||||
<string name="notification_fetch_now_desc">Trigger the notification fetcher once. Note that fetching instant messages takes time.</string>
|
||||
<string name="notification_fetch_now_desc">Trigger the notification fetcher once.</string>
|
||||
<string name="notification_fetch_success">Fetching Notifications…</string>
|
||||
<string name="notification_fetch_fail">Couldn\'t fetch notifications</string>
|
||||
<string name="notification_sound">Notification sound</string>
|
||||
|
@ -9,8 +9,8 @@
|
||||
<version title="v1.7.7" />
|
||||
<item text="Fix overlay loading" />
|
||||
<item text="Improve image loading" />
|
||||
<item text="" />
|
||||
<item text="" />
|
||||
<item text="Launch image viewer when opening full sized image" />
|
||||
<item text="Improve filtering system" />
|
||||
<item text="" />
|
||||
<item text="" />
|
||||
<item text="" />
|
||||
|
@ -49,4 +49,12 @@ class FbRegexTest {
|
||||
val data = "\"uri\":\"$url\"}"
|
||||
assertEquals(url, FB_JSON_URL_MATCHER.find(data)[1])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun imageIdRegex() {
|
||||
val id = 123456L
|
||||
val img = "https://scontent-yyz1-1.xx.fbcdn.net/v/t31.0-8/fr/cp0/e15/q65/89056_${id}_98239_o.jpg"
|
||||
assertEquals(id, FB_IMAGE_ID_MATCHER.find(img)[1]?.toLongOrNull())
|
||||
}
|
||||
|
||||
}
|
@ -1,7 +1,10 @@
|
||||
package com.pitchedapps.frost.facebook
|
||||
|
||||
import com.pitchedapps.frost.utils.isImageUrl
|
||||
import org.junit.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
|
||||
/**
|
||||
@ -57,4 +60,28 @@ class FbUrlTest {
|
||||
assertFbFormat(expected, url)
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun imageRegex() {
|
||||
arrayOf(
|
||||
"https://scontent-yyz1-1.xx.fbcdn.net/v/t1.0-9/fr/cp0/e15/q65/229_546131_836546862_n.jpg?efg=e343J9&oh=d4245b1&oe=5453",
|
||||
"/photo/view_full_size/?fbid=1523&ref_component=mbasic_photo_permalink&ref_page=%2Fwap%2Fphoto.php&refid=153&_ft_=...",
|
||||
"#!/photo/view_full_size/?fbid=1523&ref_component=mbasic_photo_permalink&ref_page=%2Fwap%2Fphoto.php&refid=153&_ft_=..."
|
||||
).forEach {
|
||||
assertTrue(it.isImageUrl, "Failed to match image for $it")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun antiImageRegex() {
|
||||
arrayOf(
|
||||
"http...fbcdn.net...mp4",
|
||||
"/photo/...png",
|
||||
"https://www.google.ca"
|
||||
).forEach {
|
||||
assertFalse(it.isImageUrl, "Should not have matched image for $it")
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,61 @@
|
||||
package com.pitchedapps.frost.rx
|
||||
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotEquals
|
||||
|
||||
/**
|
||||
* Created by Allan Wang on 07/01/18.
|
||||
*/
|
||||
private inline val threadId
|
||||
get() = Thread.currentThread().id
|
||||
|
||||
class ResettableFlyweightTest {
|
||||
|
||||
class IntFlyweight : RxFlyweight<Int, Long, Long>() {
|
||||
override fun call(input: Int): Long {
|
||||
println("Call for $input on thread $threadId")
|
||||
Thread.sleep(20)
|
||||
return System.currentTimeMillis()
|
||||
}
|
||||
|
||||
override fun validate(input: Int, cond: Long) = System.currentTimeMillis() - cond < 500
|
||||
|
||||
override fun cache(input: Int): Long = System.currentTimeMillis()
|
||||
}
|
||||
|
||||
private lateinit var flyweight: IntFlyweight
|
||||
private lateinit var latch: CountDownLatch
|
||||
|
||||
@Before
|
||||
fun init() {
|
||||
flyweight = IntFlyweight()
|
||||
latch = CountDownLatch(1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testCache() {
|
||||
flyweight(1).subscribe { i ->
|
||||
flyweight(1).subscribe { j ->
|
||||
assertEquals(i, j, "Did not use cache during calls")
|
||||
latch.countDown()
|
||||
}
|
||||
}
|
||||
latch.await()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testNoCache() {
|
||||
flyweight(1).subscribe { i ->
|
||||
flyweight(2).subscribe { j ->
|
||||
assertNotEquals(i, j, "Should not use cache for calls with different keys")
|
||||
latch.countDown()
|
||||
}
|
||||
}
|
||||
latch.await()
|
||||
}
|
||||
|
||||
|
||||
}
|
@ -3,6 +3,8 @@
|
||||
## v1.7.7
|
||||
* Fix overlay loading
|
||||
* Improve image loading
|
||||
* Launch image viewer when opening full sized image
|
||||
* Improve filtering system
|
||||
|
||||
## v1.7.5
|
||||
* Mark notifications as read when clicked!
|
||||
|
Loading…
Reference in New Issue
Block a user