From 32e6b5be0e662bbac22806bcc87259fd1a2e2ed0 Mon Sep 17 00:00:00 2001 From: Allan Wang Date: Fri, 29 Dec 2017 19:39:04 -0500 Subject: Feature/native notifs (#579) * Improve parser and add zip test * Remove ActivityOptionsCompat, resolves #555 * Create native notifs * Add animations * Add image rounder * Improve glide transformations * Add request service * Fix parser * Fix parser * Add thumbnail and fix notification text * Update parsers and regex * Auto mark as read * Add request implementation in pending intent * Remove unnecessary return data * Simplify command retrieval * Use name keys instead * Revamp all bundle calls * Fix up thumbnail layout --- app/src/main/AndroidManifest.xml | 5 + .../frost/activities/BaseMainActivity.kt | 4 +- .../pitchedapps/frost/activities/ImageActivity.kt | 4 +- .../pitchedapps/frost/activities/LoginActivity.kt | 5 +- .../frost/activities/WebOverlayActivity.kt | 17 +- .../frost/contracts/ActivityContract.kt | 1 - .../com/pitchedapps/frost/enums/OverlayContext.kt | 11 +- .../com/pitchedapps/frost/facebook/FbItem.kt | 17 +- .../com/pitchedapps/frost/facebook/FbRegex.kt | 6 +- .../com/pitchedapps/frost/facebook/FbRequest.kt | 85 +++++++--- .../pitchedapps/frost/fragments/FragmentBase.kt | 119 ++++++++------ .../frost/fragments/FragmentContract.kt | 7 - .../frost/fragments/RecyclerFragments.kt | 27 +++ .../pitchedapps/frost/fragments/WebFragments.kt | 26 +++ .../com/pitchedapps/frost/glide/GlideUtils.kt | 27 +++ .../frost/glide/RoundCornerTransformation.kt | 41 +++++ .../pitchedapps/frost/iitems/NotificationIItem.kt | 83 ++++++++++ .../com/pitchedapps/frost/parsers/FrostParser.kt | 18 +- .../com/pitchedapps/frost/parsers/MessageParser.kt | 23 ++- .../com/pitchedapps/frost/parsers/NotifParser.kt | 23 ++- .../com/pitchedapps/frost/parsers/SearchParser.kt | 7 +- .../frost/services/FrostNotifications.kt | 60 +++++-- .../frost/services/FrostRequestService.kt | 182 +++++++++++++++++++++ .../frost/services/NotificationService.kt | 2 +- .../com/pitchedapps/frost/utils/EnumUtils.kt | 57 +++++++ .../kotlin/com/pitchedapps/frost/utils/Prefs.kt | 2 +- .../kotlin/com/pitchedapps/frost/utils/Utils.kt | 14 +- .../com/pitchedapps/frost/views/AccountItem.kt | 6 +- .../pitchedapps/frost/views/FrostRecyclerView.kt | 37 +++-- .../com/pitchedapps/frost/views/FrostWebView.kt | 8 + .../com/pitchedapps/frost/web/LoginWebView.kt | 1 - app/src/main/res/layout/iitem_notification.xml | 60 +++++++ .../main/res/layout/view_content_base_recycler.xml | 1 - app/src/main/res/values/dimens.xml | 1 + app/src/main/res/values/strings.xml | 1 + .../test/kotlin/com/pitchedapps/frost/MiscTest.kt | 20 ++- .../com/pitchedapps/frost/facebook/FbParseTest.kt | 26 ++- .../com/pitchedapps/frost/facebook/FbRegexTest.kt | 9 +- .../pitchedapps/frost/facebook/FbRequestTest.kt | 8 +- .../com/pitchedapps/frost/internal/Internal.kt | 2 +- .../pitchedapps/frost/parsers/MessageParserTest.kt | 29 ---- .../pitchedapps/frost/parsers/ParserTestHelper.kt | 22 --- .../pitchedapps/frost/parsers/SearchParserTest.kt | 18 -- gradle.properties | 2 +- 44 files changed, 859 insertions(+), 265 deletions(-) create mode 100644 app/src/main/kotlin/com/pitchedapps/frost/fragments/RecyclerFragments.kt create mode 100644 app/src/main/kotlin/com/pitchedapps/frost/fragments/WebFragments.kt create mode 100644 app/src/main/kotlin/com/pitchedapps/frost/glide/GlideUtils.kt create mode 100644 app/src/main/kotlin/com/pitchedapps/frost/glide/RoundCornerTransformation.kt create mode 100644 app/src/main/kotlin/com/pitchedapps/frost/iitems/NotificationIItem.kt create mode 100644 app/src/main/kotlin/com/pitchedapps/frost/services/FrostRequestService.kt create mode 100644 app/src/main/kotlin/com/pitchedapps/frost/utils/EnumUtils.kt create mode 100644 app/src/main/res/layout/iitem_notification.xml delete mode 100644 app/src/test/kotlin/com/pitchedapps/frost/parsers/MessageParserTest.kt delete mode 100644 app/src/test/kotlin/com/pitchedapps/frost/parsers/ParserTestHelper.kt delete mode 100644 app/src/test/kotlin/com/pitchedapps/frost/parsers/SearchParserTest.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 1839a122..c6414c51 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -148,6 +148,11 @@ android:enabled="true" android:label="@string/frost_notifications" android:permission="android.permission.BIND_JOB_SERVICE" /> + { val intent = Intent(this, SettingsActivity::class.java) intent.putParcelableArrayListExtra(EXTRA_COOKIES, cookies()) - val bundle = ActivityOptionsCompat.makeCustomAnimation(this, R.anim.kau_slide_in_right, R.anim.kau_fade_out).toBundle() + val bundle = ActivityOptions.makeCustomAnimation(this, R.anim.kau_slide_in_right, R.anim.kau_fade_out).toBundle() startActivityForResult(intent, ACTIVITY_SETTINGS, bundle) } else -> return super.onOptionsItemSelected(item) diff --git a/app/src/main/kotlin/com/pitchedapps/frost/activities/ImageActivity.kt b/app/src/main/kotlin/com/pitchedapps/frost/activities/ImageActivity.kt index 2fe6b8d8..cd01a718 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/activities/ImageActivity.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/activities/ImageActivity.kt @@ -70,9 +70,9 @@ class ImageActivity : KauBaseActivity() { value.update(fab) } - val imageUrl: String by lazy { intent.extras.getString(ARG_IMAGE_URL).trim('"') } + val imageUrl: String by lazy { intent.getStringExtra(ARG_IMAGE_URL).trim('"') } - val text: String? by lazy { intent.extras.getString(ARG_TEXT) } + val text: String? by lazy { intent.getStringExtra(ARG_TEXT) } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) diff --git a/app/src/main/kotlin/com/pitchedapps/frost/activities/LoginActivity.kt b/app/src/main/kotlin/com/pitchedapps/frost/activities/LoginActivity.kt index e2f7a3d2..f98f9eaf 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/activities/LoginActivity.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/activities/LoginActivity.kt @@ -23,6 +23,8 @@ import com.pitchedapps.frost.dbflow.fetchUsername 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.transform import com.pitchedapps.frost.utils.* import com.pitchedapps.frost.web.LoginWebView import io.reactivex.Single @@ -108,7 +110,8 @@ class LoginActivity : BaseActivity() { private fun loadProfile(id: Long) { - profileLoader.load(PROFILE_PICTURE_URL(id)).withRoundIcon().listener(object : RequestListener { + profileLoader.load(PROFILE_PICTURE_URL(id)) + .transform(FrostGlide.roundCorner).listener(object : RequestListener { override fun onResourceReady(resource: Drawable?, model: Any?, target: Target?, dataSource: DataSource?, isFirstResource: Boolean): Boolean { profileSubject.onSuccess(true) return false diff --git a/app/src/main/kotlin/com/pitchedapps/frost/activities/WebOverlayActivity.kt b/app/src/main/kotlin/com/pitchedapps/frost/activities/WebOverlayActivity.kt index 5b565d96..c750c88b 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/activities/WebOverlayActivity.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/activities/WebOverlayActivity.kt @@ -22,6 +22,7 @@ import com.pitchedapps.frost.R import com.pitchedapps.frost.contracts.* import com.pitchedapps.frost.enums.OverlayContext import com.pitchedapps.frost.facebook.* +import com.pitchedapps.frost.services.FrostRunnable import com.pitchedapps.frost.utils.* import com.pitchedapps.frost.views.FrostContentWeb import com.pitchedapps.frost.views.FrostVideoViewer @@ -111,18 +112,18 @@ open class WebOverlayActivityBase(private val forceBasicAgent: Boolean) : BaseAc val coordinator: CoordinatorLayout by bindView(R.id.overlay_main_content) private inline val urlTest: String? - get() = intent.extras?.getString(ARG_URL) ?: intent.dataString + get() = intent.getStringExtra(ARG_URL) ?: intent.dataString override val baseUrl: String - get() = (intent.extras?.getString(ARG_URL) ?: intent.dataString).formattedFbUrl + get() = (intent.getStringExtra(ARG_URL) ?: intent.dataString).formattedFbUrl override val baseEnum: FbItem? = null private inline val userId: Long - get() = intent.extras?.getLong(ARG_USER_ID, Prefs.userId) ?: Prefs.userId + get() = intent.getLongExtra(ARG_USER_ID, Prefs.userId) - private inline val overlayContext: OverlayContext? - get() = intent.extras?.getSerializable(ARG_OVERLAY_CONTEXT) as OverlayContext? + private val overlayContext: OverlayContext? + get() = OverlayContext[intent.extras] override fun setTitle(title: String) { toolbar.title = title @@ -136,6 +137,7 @@ open class WebOverlayActivityBase(private val forceBasicAgent: Boolean) : BaseAc finish() return } + setFrameContentView(R.layout.activity_web_overlay) setSupportActionBar(toolbar) supportActionBar?.setDisplayShowHomeEnabled(true) @@ -167,6 +169,9 @@ open class WebOverlayActivityBase(private val forceBasicAgent: Boolean) : BaseAc } } + FrostRunnable.propagate(this, intent) + L.e("Done propagation") + kauSwipeOnCreate { if (!Prefs.overlayFullScreenSwipe) edgeSize = 20.dpToPx transitionSystemBars = false @@ -180,7 +185,7 @@ open class WebOverlayActivityBase(private val forceBasicAgent: Boolean) : BaseAc */ override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) - val newUrl = (intent.extras?.getString(ARG_URL) ?: intent.dataString ?: return).formattedFbUrl + val newUrl = (intent.getStringExtra(ARG_URL) ?: intent.dataString ?: return).formattedFbUrl L.d("New intent") if (baseUrl != newUrl) { this.intent = intent diff --git a/app/src/main/kotlin/com/pitchedapps/frost/contracts/ActivityContract.kt b/app/src/main/kotlin/com/pitchedapps/frost/contracts/ActivityContract.kt index 14d7ae09..e46a4bfb 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/contracts/ActivityContract.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/contracts/ActivityContract.kt @@ -1,6 +1,5 @@ package com.pitchedapps.frost.contracts -import com.pitchedapps.frost.dbflow.CookieModel import io.reactivex.subjects.PublishSubject /** diff --git a/app/src/main/kotlin/com/pitchedapps/frost/enums/OverlayContext.kt b/app/src/main/kotlin/com/pitchedapps/frost/enums/OverlayContext.kt index 8f26e152..4f37c6c7 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/enums/OverlayContext.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/enums/OverlayContext.kt @@ -6,6 +6,9 @@ import android.view.MenuItem import ca.allanwang.kau.utils.toDrawable import com.pitchedapps.frost.R import com.pitchedapps.frost.facebook.FbItem +import com.pitchedapps.frost.utils.EnumBundle +import com.pitchedapps.frost.utils.EnumBundleCompanion +import com.pitchedapps.frost.utils.EnumCompanion import com.pitchedapps.frost.views.FrostWebView /** @@ -16,7 +19,7 @@ import com.pitchedapps.frost.views.FrostWebView * * For now, this is able to add new menu options upon first load */ -enum class OverlayContext(private val menuItem: FrostMenuItem?) { +enum class OverlayContext(private val menuItem: FrostMenuItem?) : EnumBundle { NOTIFICATION(FrostMenuItem(R.id.action_notification, FbItem.NOTIFICATIONS)), MESSAGE(FrostMenuItem(R.id.action_messages, FbItem.MESSAGES)); @@ -28,9 +31,11 @@ enum class OverlayContext(private val menuItem: FrostMenuItem?) { menuItem?.addToMenu(context, menu, 0) } - companion object { + override val bundleContract: EnumBundleCompanion + get() = Companion + + companion object : EnumCompanion("frost_arg_overlay_context", values()) { - val values = OverlayContext.values() //save one instance /** * Execute selection call for an item by id * Returns [true] if selection was consumed, [false] otherwise diff --git a/app/src/main/kotlin/com/pitchedapps/frost/facebook/FbItem.kt b/app/src/main/kotlin/com/pitchedapps/frost/facebook/FbItem.kt index cc2ca556..e2e9d9e5 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/facebook/FbItem.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/facebook/FbItem.kt @@ -7,15 +7,19 @@ import com.mikepenz.iconics.typeface.IIcon import com.mikepenz.material_design_iconic_typeface_library.MaterialDesignIconic import com.pitchedapps.frost.R import com.pitchedapps.frost.fragments.BaseFragment +import com.pitchedapps.frost.fragments.NotificationFragment import com.pitchedapps.frost.fragments.WebFragment import com.pitchedapps.frost.fragments.WebFragmentMenu +import com.pitchedapps.frost.utils.EnumBundle +import com.pitchedapps.frost.utils.EnumBundleCompanion +import com.pitchedapps.frost.utils.EnumCompanion enum class FbItem( @StringRes val titleId: Int, val icon: IIcon, relativeUrl: String, val fragmentCreator: () -> BaseFragment = ::WebFragment -) { +) : EnumBundle { ACTIVITY_LOG(R.string.activity_log, GoogleMaterial.Icon.gmd_list, "me/allactivity"), BIRTHDAYS(R.string.birthdays, GoogleMaterial.Icon.gmd_cake, "events/birthdays"), CHAT(R.string.chat, GoogleMaterial.Icon.gmd_chat, "buddylist"), @@ -28,7 +32,7 @@ enum class FbItem( MENU(R.string.menu, GoogleMaterial.Icon.gmd_menu, "settings", ::WebFragmentMenu), MESSAGES(R.string.messages, MaterialDesignIconic.Icon.gmi_comments, "messages"), NOTES(R.string.notes, CommunityMaterial.Icon.cmd_note, "notes"), - NOTIFICATIONS(R.string.notifications, MaterialDesignIconic.Icon.gmi_globe, "notifications"), + NOTIFICATIONS(R.string.notifications, MaterialDesignIconic.Icon.gmi_globe, "notifications", ::NotificationFragment), ON_THIS_DAY(R.string.on_this_day, GoogleMaterial.Icon.gmd_today, "onthisday"), PAGES(R.string.pages, GoogleMaterial.Icon.gmd_flag, "pages"), PHOTOS(R.string.photos, GoogleMaterial.Icon.gmd_photo, "me/photos"), @@ -39,12 +43,11 @@ enum class FbItem( ; val url = "$FB_URL_BASE$relativeUrl" -} -inline val fbSearch - get() = fbSearch() + override val bundleContract: EnumBundleCompanion + get() = Companion -fun fbSearch(query: String = "a") = "$FB_SEARCH$query" + companion object : EnumCompanion("frost_arg_fb_item", values()) +} -private const val FB_SEARCH = "${FB_URL_BASE}search/top/?q=" fun defaultTabs(): List = listOf(FbItem.FEED, FbItem.MESSAGES, FbItem.NOTIFICATIONS, FbItem.MENU) diff --git a/app/src/main/kotlin/com/pitchedapps/frost/facebook/FbRegex.kt b/app/src/main/kotlin/com/pitchedapps/frost/facebook/FbRegex.kt index 8d625582..24f685be 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/facebook/FbRegex.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/facebook/FbRegex.kt @@ -13,7 +13,7 @@ package com.pitchedapps.frost.facebook * Matches the fb_dtsg component of a page containing it as a hidden value */ val FB_DTSG_MATCHER: Regex by lazy { Regex("name=\"fb_dtsg\" value=\"(.*?)\"") } -val FB_REV_MATCHER: Regex by lazy{Regex("\"app_version\":\"(.*?)\"")} +val FB_REV_MATCHER: Regex by lazy { Regex("\"app_version\":\"(.*?)\"") } /** * Matches user id from cookie @@ -21,9 +21,9 @@ val FB_REV_MATCHER: Regex by lazy{Regex("\"app_version\":\"(.*?)\"")} val FB_USER_MATCHER: Regex by lazy { Regex("c_user=([0-9]*);") } val FB_EPOCH_MATCHER: Regex by lazy { Regex(":([0-9]+)") } -val FB_NOTIF_ID_MATCHER: Regex by lazy { Regex("notif_id\":([0-9]+)") } +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_CSS_URL_MATCHER: Regex by lazy { Regex("url\\([\"|']?(.*?)[\"|']?\\)") } operator fun MatchResult?.get(groupIndex: Int) = this?.groupValues?.get(groupIndex) diff --git a/app/src/main/kotlin/com/pitchedapps/frost/facebook/FbRequest.kt b/app/src/main/kotlin/com/pitchedapps/frost/facebook/FbRequest.kt index 2fa20917..51e14097 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/facebook/FbRequest.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/facebook/FbRequest.kt @@ -11,6 +11,24 @@ import org.apache.commons.text.StringEscapeUtils /** * Created by Allan Wang on 21/12/17. */ +private val authMap: MutableMap = mutableMapOf() + +fun String.fbRequest(action: RequestAuth.() -> Unit) { + val savedAuth = authMap[this] + if (savedAuth != null) { + savedAuth.action() + } else { + val auth = getAuth() + if (!auth.isValid) { + L.e("Attempted fbrequest with invalid auth") + return + } + authMap.put(this, auth) + L.i(null, "Found auth $auth") + auth.action() + } +} + data class RequestAuth(val userId: Long = -1, val cookie: String = "", val fb_dtsg: String = "", @@ -19,6 +37,22 @@ data class RequestAuth(val userId: Long = -1, get() = userId > 0 && cookie.isNotEmpty() && fb_dtsg.isNotEmpty() && rev.isNotEmpty() } +/** + * Request container with the execution call + */ +class FrostRequest(val call: Call, private val invoke: (Call) -> T) { + fun invoke() = invoke(call) +} + +private inline fun RequestAuth.frostRequest( + noinline invoke: (Call) -> T, + builder: Request.Builder.() -> Request.Builder // to ensure we don't do anything extra at the end +): FrostRequest { + val request = cookie.requestBuilder() + request.builder() + return FrostRequest(request.call(), invoke) +} + private val client: OkHttpClient by lazy { val builder = OkHttpClient.Builder() if (BuildConfig.DEBUG) @@ -49,12 +83,12 @@ private fun String.requestBuilder() = Request.Builder() private fun Request.Builder.call() = client.newCall(build()) - -fun Pair.getAuth(): RequestAuth { - val (userId, cookie) = this - var auth = RequestAuth(userId, cookie) - val call = cookie.requestBuilder() - .url("https://touch.facebook.com") +fun String.getAuth(): RequestAuth { + var auth = RequestAuth(cookie = this) + val id = FB_USER_MATCHER.find(this)[1]?.toLong() ?: return auth + auth = auth.copy(userId = id) + val call = this.requestBuilder() + .url(FB_URL_BASE) .get() .call() call.execute().body()?.charStream()?.useLines { @@ -62,14 +96,14 @@ fun Pair.getAuth(): RequestAuth { val text = StringEscapeUtils.unescapeEcmaScript(it) val fb_dtsg = FB_DTSG_MATCHER.find(text)[1] if (fb_dtsg != null) { - L.d(null, "fb_dtsg for $userId: $fb_dtsg") + L.d(null, "fb_dtsg for ${auth.userId}: $fb_dtsg") auth = auth.copy(fb_dtsg = fb_dtsg) if (auth.isValid) return auth } val rev = FB_REV_MATCHER.find(text)[1] if (rev != null) { - L.d(null, "rev for $userId: $rev") + L.d(null, "rev for ${auth.userId}: $rev") auth = auth.copy(rev = rev) if (auth.isValid) return auth } @@ -79,7 +113,7 @@ fun Pair.getAuth(): RequestAuth { return auth } -fun RequestAuth.markNotificationRead(notifId: Long): Call { +fun RequestAuth.markNotificationRead(notifId: Long): FrostRequest { val body = listOf( "click_type" to "notification_click", @@ -89,40 +123,37 @@ fun RequestAuth.markNotificationRead(notifId: Long): Call { "__user" to userId ).withEmptyData("m_sess", "__dyn", "__req", "__ajax__") - return cookie.requestBuilder() - .url("${FB_URL_BASE}a/jewel_notifications_log.php") - .post(body.toForm()) - .call() + return frostRequest(::executeForNoError) { + url("${FB_URL_BASE}a/jewel_notifications_log.php") + post(body.toForm()) + } } -private inline fun zip(data: Array, - crossinline mapper: (List) -> O, - crossinline caller: (T) -> R): Single { - val singles = data.map { Single.fromCallable { caller(it) }.subscribeOn(Schedulers.io()) } +inline fun Array.zip(crossinline mapper: (List) -> O, + crossinline caller: (T) -> R): Single { + val singles = map { Single.fromCallable { caller(it) }.subscribeOn(Schedulers.io()) } return Single.zip(singles) { val results = it.mapNotNull { it as? R } mapper(results) } } -fun RequestAuth.markNotificationsRead(vararg notifId: Long) = zip(notifId.toTypedArray(), - { it.count { it } }) { - val response = markNotificationRead(it).execute() - val buffer = CharArray(20) - response.body()?.charStream()?.read(buffer) ?: return@zip false - !buffer.toString().contains("error") -} +fun RequestAuth.markNotificationsRead(vararg notifId: Long) = + notifId.toTypedArray().zip( + { it.all { it } }, + { markNotificationRead(it).invoke() }) /** * Execute the call and attempt to check validity + * Valid = not blank & no "error" instance */ -fun Call.executeAndCheck(): Boolean { - val body = execute().body() ?: return false +fun executeForNoError(call: Call): Boolean { + val body = call.execute().body() ?: return false var empty = true body.charStream().useLines { it.forEach { + if (it.contains("error")) return false if (empty && it.isNotEmpty()) empty = false - if (it.contains("error")) return true } } return !empty diff --git a/app/src/main/kotlin/com/pitchedapps/frost/fragments/FragmentBase.kt b/app/src/main/kotlin/com/pitchedapps/frost/fragments/FragmentBase.kt index 58d9ebd4..963d00bb 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/fragments/FragmentBase.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/fragments/FragmentBase.kt @@ -6,27 +6,28 @@ import android.support.v4.app.Fragment import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import ca.allanwang.kau.adapters.fastAdapter import ca.allanwang.kau.utils.withArguments +import com.mikepenz.fastadapter.FastAdapter import com.mikepenz.fastadapter.IItem -import com.mikepenz.fastadapter.commons.adapters.FastItemAdapter +import com.mikepenz.fastadapter.adapters.ItemAdapter +import com.mikepenz.fastadapter_extensions.items.ProgressItem import com.pitchedapps.frost.R import com.pitchedapps.frost.contracts.DynamicUiContract import com.pitchedapps.frost.contracts.FrostContentParent import com.pitchedapps.frost.contracts.MainActivityContract -import com.pitchedapps.frost.dbflow.CookieModel import com.pitchedapps.frost.enums.FeedSort import com.pitchedapps.frost.facebook.FbCookie import com.pitchedapps.frost.facebook.FbItem import com.pitchedapps.frost.parsers.FrostParser +import com.pitchedapps.frost.parsers.ParseResponse import com.pitchedapps.frost.utils.* import com.pitchedapps.frost.views.FrostRecyclerView -import com.pitchedapps.frost.views.FrostWebView -import com.pitchedapps.frost.web.FrostWebViewClient -import com.pitchedapps.frost.web.FrostWebViewClientMenu import io.reactivex.android.schedulers.AndroidSchedulers import io.reactivex.disposables.Disposable import org.jetbrains.anko.doAsync import org.jetbrains.anko.toast +import org.jetbrains.anko.uiThread /** * Created by Allan Wang on 2017-11-07. @@ -37,7 +38,6 @@ import org.jetbrains.anko.toast abstract class BaseFragment : Fragment(), FragmentContract, DynamicUiContract { companion object { - private const val ARG_URL_ENUM = "arg_url_enum" private const val ARG_POSITION = "arg_position" internal operator fun invoke(base: () -> BaseFragment, data: FbItem, position: Int): BaseFragment { @@ -45,15 +45,15 @@ abstract class BaseFragment : Fragment(), FragmentContract, DynamicUiContract { val d = if (data == FbItem.FEED) FeedSort(Prefs.feedSort).item else data fragment.withArguments( ARG_URL to d.url, - ARG_POSITION to position, - ARG_URL_ENUM to d + ARG_POSITION to position ) + d.put(fragment.arguments!!) return fragment } } override val baseUrl: String by lazy { arguments!!.getString(ARG_URL) } - override val baseEnum: FbItem by lazy { arguments!!.getSerializable(ARG_URL_ENUM) as FbItem } + override val baseEnum: FbItem by lazy { FbItem[arguments]!! } override val position: Int by lazy { arguments!!.getInt(ARG_POSITION) } override var firstLoad: Boolean = true @@ -66,6 +66,7 @@ abstract class BaseFragment : Fragment(), FragmentContract, DynamicUiContract { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + firstLoad = true if (context !is MainActivityContract) throw IllegalArgumentException("${this::class.java.simpleName} is not attached to a context implementing MainActivityContract") } @@ -92,8 +93,9 @@ abstract class BaseFragment : Fragment(), FragmentContract, DynamicUiContract { } override fun firstLoadRequest() { + val core = core ?: return if (userVisibleHint && isVisible && firstLoad) { - core?.reloadBase(true) + core.reloadBase(true) firstLoad = false } } @@ -182,64 +184,83 @@ abstract class RecyclerFragment> : BaseFragment(), R */ abstract val parser: FrostParser - abstract val adapter: FastItemAdapter + open fun getDoc(cookie: String?) = frostJsoup(cookie, parser.url) - abstract fun toItems(data: T): List + val adapter: ItemAdapter = ItemAdapter() - override fun bind(recyclerView: FrostRecyclerView) { - recyclerView.adapter = this.adapter + abstract fun toItems(response: ParseResponse): List + + override final fun bind(recyclerView: FrostRecyclerView) { + recyclerView.adapter = getAdapter() + recyclerView.onReloadClear = { adapter.clear() } + bindImpl(recyclerView) } - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - val tail = tailMapper(baseEnum) - if (tail.isNotEmpty()) { - val baseUrl = baseEnum.url - L.d("Adding $tail to $baseUrl for RecyclerFragment") - arguments!!.putString(ARG_URL, "$baseUrl$tail") + override fun firstLoadRequest() { + val core = core ?: return + if (firstLoad) { + core.reloadBase(true) + firstLoad = false } } - private fun tailMapper(item: FbItem) = when (item) { - FbItem.NOTIFICATIONS, FbItem.MESSAGES -> "/?more" - else -> "" - } + /** + * Anything to call for one time bindings + * At this stage, all adapters will have FastAdapter references + */ + open fun bindImpl(recyclerView: FrostRecyclerView) = Unit + + /** + * Create the fast adapter to bind to the recyclerview + */ + open fun getAdapter(): FastAdapter> = fastAdapter(this.adapter) override fun reload(progress: (Int) -> Unit, callback: (Boolean) -> Unit) { doAsync { progress(10) - val doc = frostJsoup(baseUrl) + val cookie = FbCookie.webCookie + val doc = getDoc(cookie) progress(60) - val data = parser.parse(FbCookie.webCookie, doc) - if (data == null) { - context?.toast(R.string.error_generic) + val response = parser.parse(cookie, doc) + if (response == null) { + uiThread { context?.toast(R.string.error_generic) } L.eThrow("RecyclerFragment failed for ${baseEnum.name}") Prefs.nativeViews = false return@doAsync callback(false) } progress(80) - val items = toItems(data.data) + val items = toItems(response) progress(97) - adapter.setNewList(items) + uiThread { adapter.setNewList(items) } + callback(true) } } } -open class WebFragment : BaseFragment(), FragmentContract { - - override val layoutRes: Int = R.layout.view_content_web - - /** - * Given a webview, output a client - */ - open fun client(web: FrostWebView) = FrostWebViewClient(web) - - override fun innerView(context: Context) = FrostWebView(context) - -} - -class WebFragmentMenu : WebFragment() { - - override fun client(web: FrostWebView) = FrostWebViewClientMenu(web) - -} \ No newline at end of file +//abstract class PagedRecyclerFragment> : RecyclerFragment() { +// +// var allowPagedLoading = true +// +// val footerAdapter = ItemAdapter() +// +// val footerScrollListener = object : EndlessRecyclerOnScrollListener(footerAdapter) { +// override fun onLoadMore(currentPage: Int) { +// TODO("not implemented") +// +// } +// +// } +// +// override fun getAdapter() = fastAdapter(adapter, footerAdapter) +// +// override fun bindImpl(recyclerView: FrostRecyclerView) { +// recyclerView.addOnScrollListener(footerScrollListener) +// } +// +// override fun reload(progress: (Int) -> Unit, callback: (Boolean) -> Unit) { +// footerScrollListener. +// super.reload(progress, callback) +// } +//} + +class FrostProgress : ProgressItem() \ No newline at end of file diff --git a/app/src/main/kotlin/com/pitchedapps/frost/fragments/FragmentContract.kt b/app/src/main/kotlin/com/pitchedapps/frost/fragments/FragmentContract.kt index 00429730..a78eb0d0 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/fragments/FragmentContract.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/fragments/FragmentContract.kt @@ -1,11 +1,9 @@ package com.pitchedapps.frost.fragments -import android.content.Context import com.pitchedapps.frost.contracts.FrostContentContainer import com.pitchedapps.frost.contracts.FrostContentCore import com.pitchedapps.frost.contracts.FrostContentParent import com.pitchedapps.frost.contracts.MainActivityContract -import com.pitchedapps.frost.dbflow.CookieModel import com.pitchedapps.frost.views.FrostRecyclerView import io.reactivex.disposables.Disposable @@ -55,11 +53,6 @@ interface FragmentContract : FrostContentContainer { */ fun attachMainObservable(contract: MainActivityContract): Disposable - /** - * Load custom layout to container - */ - fun innerView(context: Context): FrostContentCore - /** * Call when fragment is detached so that any existing * observable is disposed diff --git a/app/src/main/kotlin/com/pitchedapps/frost/fragments/RecyclerFragments.kt b/app/src/main/kotlin/com/pitchedapps/frost/fragments/RecyclerFragments.kt new file mode 100644 index 00000000..4d4a6f8b --- /dev/null +++ b/app/src/main/kotlin/com/pitchedapps/frost/fragments/RecyclerFragments.kt @@ -0,0 +1,27 @@ +package com.pitchedapps.frost.fragments + +import com.pitchedapps.frost.facebook.FbItem +import com.pitchedapps.frost.iitems.NotificationIItem +import com.pitchedapps.frost.parsers.FrostNotifs +import com.pitchedapps.frost.parsers.NotifParser +import com.pitchedapps.frost.parsers.ParseResponse +import com.pitchedapps.frost.utils.frostJsoup +import com.pitchedapps.frost.views.FrostRecyclerView + +/** + * Created by Allan Wang on 27/12/17. + */ +class NotificationFragment : RecyclerFragment() { + + override val parser = NotifParser + + override fun getDoc(cookie: String?) = frostJsoup(cookie, "${FbItem.NOTIFICATIONS.url}?more") + + override fun toItems(response: ParseResponse): List = + response.data.notifs.map { NotificationIItem(it, response.cookie) } + + override fun bindImpl(recyclerView: FrostRecyclerView) { + NotificationIItem.bindEvents(adapter) + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/pitchedapps/frost/fragments/WebFragments.kt b/app/src/main/kotlin/com/pitchedapps/frost/fragments/WebFragments.kt new file mode 100644 index 00000000..2740a36f --- /dev/null +++ b/app/src/main/kotlin/com/pitchedapps/frost/fragments/WebFragments.kt @@ -0,0 +1,26 @@ +package com.pitchedapps.frost.fragments + +import com.pitchedapps.frost.R +import com.pitchedapps.frost.views.FrostWebView +import com.pitchedapps.frost.web.FrostWebViewClient +import com.pitchedapps.frost.web.FrostWebViewClientMenu + +/** + * Created by Allan Wang on 27/12/17. + */ +open class WebFragment : BaseFragment() { + + override val layoutRes: Int = R.layout.view_content_web + + /** + * Given a webview, output a client + */ + open fun client(web: FrostWebView) = FrostWebViewClient(web) + +} + +class WebFragmentMenu : WebFragment() { + + override fun client(web: FrostWebView) = FrostWebViewClientMenu(web) + +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/pitchedapps/frost/glide/GlideUtils.kt b/app/src/main/kotlin/com/pitchedapps/frost/glide/GlideUtils.kt new file mode 100644 index 00000000..6d2b3cda --- /dev/null +++ b/app/src/main/kotlin/com/pitchedapps/frost/glide/GlideUtils.kt @@ -0,0 +1,27 @@ +package com.pitchedapps.frost.glide + +import com.bumptech.glide.RequestBuilder +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.request.RequestOptions + +/** + * Created by Allan Wang on 28/12/17. + * + * Collection of transformations + * Each caller will generate a new one upon request + */ +object FrostGlide { + val roundCorner + get() = RoundCornerTransformation() + val circleCrop + get() = CircleCrop() +} + +fun RequestBuilder.transform(vararg transformation: BitmapTransformation): RequestBuilder = + when (transformation.size) { + 0 -> this + 1 -> apply(RequestOptions.bitmapTransform(transformation[0])) + else -> apply(RequestOptions.bitmapTransform(MultiTransformation(*transformation))) + } diff --git a/app/src/main/kotlin/com/pitchedapps/frost/glide/RoundCornerTransformation.kt b/app/src/main/kotlin/com/pitchedapps/frost/glide/RoundCornerTransformation.kt new file mode 100644 index 00000000..5eded159 --- /dev/null +++ b/app/src/main/kotlin/com/pitchedapps/frost/glide/RoundCornerTransformation.kt @@ -0,0 +1,41 @@ +package com.pitchedapps.frost.glide + +import android.graphics.* +import com.bumptech.glide.load.engine.bitmap_recycle.BitmapPool +import com.bumptech.glide.load.resource.bitmap.BitmapTransformation +import com.pitchedapps.frost.utils.Prefs +import java.security.MessageDigest + + +/** + * Created by Allan Wang on 27/12/17. + */ +class RoundCornerTransformation : BitmapTransformation() { + + override fun updateDiskCacheKey(messageDigest: MessageDigest) { + messageDigest.update("FrostRoundCornerTransform-${Prefs.showRoundedIcons}".toByteArray()) + } + + override fun transform(pool: BitmapPool, toTransform: Bitmap, outWidth: Int, outHeight: Int): Bitmap { + + val width = toTransform.width + val height = toTransform.height + + val bitmap = pool.get(width, height, Bitmap.Config.ARGB_8888) + bitmap.setHasAlpha(true) + + val radius = Math.min(width, height).toFloat() / + (if (Prefs.showRoundedIcons) 2f else 10f) + + val canvas = Canvas(bitmap) + val paint = Paint() + paint.isAntiAlias = true + paint.shader = BitmapShader(toTransform, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP) + canvas.drawRoundRect(RectF(0f, 0f, width.toFloat(), height.toFloat()), + radius, radius, paint) + + return bitmap + } + + +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/pitchedapps/frost/iitems/NotificationIItem.kt b/app/src/main/kotlin/com/pitchedapps/frost/iitems/NotificationIItem.kt new file mode 100644 index 00000000..c7f61351 --- /dev/null +++ b/app/src/main/kotlin/com/pitchedapps/frost/iitems/NotificationIItem.kt @@ -0,0 +1,83 @@ +package com.pitchedapps.frost.iitems + +import android.view.View +import android.view.ViewGroup +import android.widget.ImageView +import android.widget.TextView +import ca.allanwang.kau.iitems.KauIItem +import ca.allanwang.kau.ui.createSimpleRippleDrawable +import ca.allanwang.kau.utils.* +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.parsers.FrostNotif +import com.pitchedapps.frost.services.FrostRunnable +import com.pitchedapps.frost.utils.Prefs +import com.pitchedapps.frost.utils.launchWebOverlay + +/** + * Created by Allan Wang on 27/12/17. + */ +class NotificationIItem(val notification: FrostNotif, val cookie: String) : KauIItem( + R.layout.iitem_notification, ::ViewHolder +) { + + companion object { + fun bindEvents(adapter: ItemAdapter) { + adapter.fastAdapter.withSelectable(false) + .withOnClickListener { v, _, item, position -> + val notif = item.notification + if (notif.unread) { + FrostRunnable.markNotificationRead(v.context, notif.id, item.cookie) + adapter.set(position, NotificationIItem(notif.copy(unread = false), item.cookie)) + } + v.context.launchWebOverlay(notif.url) + true + } + } + } + + class ViewHolder(itemView: View) : FastAdapter.ViewHolder(itemView) { + + val frame: ViewGroup by bindView(R.id.item_frame) + val avatar: ImageView by bindView(R.id.item_avatar) + val content: TextView by bindView(R.id.item_content) + val date: TextView by bindView(R.id.item_date) + val thumbnail: ImageView by bindView(R.id.item_thumbnail) + + private val glide + get() = Glide.with(itemView) + + override fun bindView(item: NotificationIItem, payloads: MutableList) { + val notif = item.notification + frame.background = createSimpleRippleDrawable(Prefs.textColor, + Prefs.bgColor.colorToForeground(if (notif.unread) 0.7f else 0.0f) + .withAlpha(30)) + content.setTextColor(Prefs.textColor) + date.setTextColor(Prefs.textColor.withAlpha(150)) + + val glide = glide + glide.load(notif.img) + .transform(FrostGlide.roundCorner) + .into(avatar) + if (notif.thumbnailUrl != null) + glide.load(notif.thumbnailUrl).into(thumbnail.visible()) + + content.text = notif.content + date.text = notif.timeString + } + + override fun unbindView(item: NotificationIItem) { + frame.background = null + val glide = glide + glide.clear(avatar) + glide.clear(thumbnail) + thumbnail.gone() + content.text = null + date.text = null + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/pitchedapps/frost/parsers/FrostParser.kt b/app/src/main/kotlin/com/pitchedapps/frost/parsers/FrostParser.kt index 016f33e8..f0938eca 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/parsers/FrostParser.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/parsers/FrostParser.kt @@ -9,6 +9,7 @@ import com.pitchedapps.frost.utils.frostJsoup import org.jsoup.Jsoup import org.jsoup.nodes.Document import org.jsoup.nodes.Element +import org.jsoup.select.Elements /** * Created by Allan Wang on 2017-10-06. @@ -38,6 +39,11 @@ interface FrostParser { */ fun parse(cookie: String?, document: Document): ParseResponse? + /** + * Call parsing using jsoup to fetch from given url + */ + fun parseFromUrl(cookie: String?, url: String): ParseResponse? + /** * Call parsing with given data */ @@ -45,6 +51,8 @@ interface FrostParser { } +const val FALLBACK_TIME_MOD = 1000000 + data class FrostLink(val text: String, val href: String) data class ParseResponse(val cookie: String, val data: T) { @@ -68,7 +76,7 @@ internal fun List.toJsonString(tag: String, indent: Int) = StringBuilder( */ internal abstract class FrostParserBase(private val redirectToText: Boolean) : FrostParser { - override final fun parse(cookie: String?) = parse(cookie, frostJsoup(cookie, url)) + override final fun parse(cookie: String?) = parseFromUrl(cookie, url) override final fun parseFromData(cookie: String?, text: String): ParseResponse? { cookie ?: return null @@ -77,6 +85,9 @@ internal abstract class FrostParserBase(private val redirectToText: return ParseResponse(cookie, data) } + override final fun parseFromUrl(cookie: String?, url: String): ParseResponse? = + parse(cookie, frostJsoup(cookie, url)) + override fun parse(cookie: String?, document: Document): ParseResponse? { cookie ?: return null if (redirectToText) @@ -94,7 +105,10 @@ internal abstract class FrostParserBase(private val redirectToText: * Returns the formatted url, or an empty string if nothing was found */ protected fun Element.getInnerImgStyle() = - FB_CSS_URL_MATCHER.find(select("i.img[style*=url]").attr("style"))[1]?.formattedFbUrl ?: "" + select("i.img[style*=url]").getStyleUrl() + + protected fun Elements.getStyleUrl() = + FB_CSS_URL_MATCHER.find(attr("style"))[1]?.formattedFbUrl protected open fun textToDoc(text: String) = if (!redirectToText) Jsoup.parse(text) diff --git a/app/src/main/kotlin/com/pitchedapps/frost/parsers/MessageParser.kt b/app/src/main/kotlin/com/pitchedapps/frost/parsers/MessageParser.kt index 9d4a2193..02c6f189 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/parsers/MessageParser.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/parsers/MessageParser.kt @@ -16,7 +16,11 @@ import org.jsoup.nodes.Element * We can parse out the content we want directly and load it ourselves * */ -object MessageParser : FrostParser by MessageParserImpl() +object MessageParser : FrostParser by MessageParserImpl() { + + fun queryUser(cookie: String?, name: String) = parseFromUrl(cookie, "${FbItem.MESSAGES.url}/?q=$name") + +} data class FrostMessages(val threads: List, val seeMore: FrostLink?, @@ -35,7 +39,7 @@ data class FrostMessages(val threads: List, with(it) { NotificationContent( data = data, - notifId = Math.abs(id.toInt()), + id = id, href = url, title = title, text = content ?: "", @@ -55,12 +59,13 @@ data class FrostMessages(val threads: List, * [content] optional string for thread */ data class FrostThread(val id: Long, - val img: String, + val img: String?, val title: String, val time: Long, val url: String, val unread: Boolean, - val content: String?) + val content: String?, + val contentImgUrl: String?) private class MessageParserImpl : FrostParserBase(true) { @@ -99,10 +104,11 @@ private class MessageParserImpl : FrostParserBase(true) { val epoch = FB_EPOCH_MATCHER.find(abbr.attr("data-store"))[1]?.toLongOrNull() ?: -1L //fetch id val id = FB_MESSAGE_NOTIF_ID_MATCHER.find(element.id())[1]?.toLongOrNull() - ?: System.currentTimeMillis() - val content = element.select("span.snippet").firstOrNull()?.text()?.trim() + ?: System.currentTimeMillis() % FALLBACK_TIME_MOD + val snippet = element.select("span.snippet").firstOrNull() + val content = snippet?.text()?.trim() + val contentImg = snippet?.select("i[style*=url]")?.getStyleUrl() val img = element.getInnerImgStyle() - L.v("url", a.attr("href")) return FrostThread( id = id, img = img, @@ -110,7 +116,8 @@ private class MessageParserImpl : FrostParserBase(true) { time = epoch, url = a.attr("href").formattedFbUrl, unread = !element.hasClass("acw"), - content = content + content = content, + contentImgUrl = contentImg ) } diff --git a/app/src/main/kotlin/com/pitchedapps/frost/parsers/NotifParser.kt b/app/src/main/kotlin/com/pitchedapps/frost/parsers/NotifParser.kt index f743a43a..451eb774 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/parsers/NotifParser.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/parsers/NotifParser.kt @@ -3,7 +3,6 @@ package com.pitchedapps.frost.parsers import com.pitchedapps.frost.dbflow.CookieModel import com.pitchedapps.frost.facebook.* import com.pitchedapps.frost.services.NotificationContent -import com.pitchedapps.frost.utils.L import org.jsoup.nodes.Document import org.jsoup.nodes.Element @@ -29,10 +28,10 @@ data class FrostNotifs( with(it) { NotificationContent( data = data, - notifId = Math.abs(id.toInt()), + id = id, href = url, title = null, - text = content ?: "", + text = content, timestamp = time, profileUrl = img ) @@ -47,13 +46,17 @@ data class FrostNotifs( * [url] link to thread * [unread] true if image is unread, false otherwise * [content] optional string for thread + * [timeString] text version of time from Facebook + * [thumbnailUrl] optional thumbnail url if existent */ data class FrostNotif(val id: Long, - val img: String, + val img: String?, val time: Long, val url: String, val unread: Boolean, - val content: String?) + val content: String, + val timeString: String, + val thumbnailUrl: String?) private class NotifParserImpl : FrostParserBase(false) { @@ -62,7 +65,7 @@ private class NotifParserImpl : FrostParserBase(false) { override fun parseImpl(doc: Document): FrostNotifs? { val notificationList = doc.getElementById("notifications_list") ?: return null val notifications = notificationList.getElementsByAttributeValueContaining("id", "list_notif_") - .mapNotNull { parseNotif(it) } + .mapNotNull(this::parseNotif) val seeMore = parseLink(doc.getElementsByAttributeValue("href", "/notifications.php?more").first()) return FrostNotifs(notifications, seeMore) } @@ -73,18 +76,20 @@ private class NotifParserImpl : FrostParserBase(false) { val epoch = FB_EPOCH_MATCHER.find(abbr.attr("data-store"))[1]?.toLongOrNull() ?: -1L //fetch id val id = FB_NOTIF_ID_MATCHER.find(element.id())[1]?.toLongOrNull() - ?: System.currentTimeMillis() + ?: System.currentTimeMillis() % FALLBACK_TIME_MOD val img = element.getInnerImgStyle() val timeString = abbr.text() val content = a.text().replace("\u00a0", " ").removeSuffix(timeString).trim() //remove   - L.v("url", a.attr("href")) + val thumbnail = element.selectFirst("img.thumbnail")?.attr("src") return FrostNotif( id = id, img = img, time = epoch, url = a.attr("href").formattedFbUrl, unread = !element.hasClass("acw"), - content = content + content = content, + timeString = timeString, + thumbnailUrl = if (thumbnail?.isNotEmpty() == true) thumbnail else null ) } diff --git a/app/src/main/kotlin/com/pitchedapps/frost/parsers/SearchParser.kt b/app/src/main/kotlin/com/pitchedapps/frost/parsers/SearchParser.kt index bc09d4db..557e80b8 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/parsers/SearchParser.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/parsers/SearchParser.kt @@ -1,13 +1,10 @@ package com.pitchedapps.frost.parsers import ca.allanwang.kau.searchview.SearchItem -import com.pitchedapps.frost.dbflow.CookieModel import com.pitchedapps.frost.facebook.FbItem import com.pitchedapps.frost.facebook.formattedFbUrl import com.pitchedapps.frost.parsers.FrostSearch.Companion.create import com.pitchedapps.frost.utils.L -import com.pitchedapps.frost.utils.frostJsoup -import org.jsoup.Jsoup import org.jsoup.nodes.Document import org.jsoup.nodes.Element @@ -18,7 +15,7 @@ object SearchParser : FrostParser by SearchParserImpl() { fun query(cookie: String?, input: String): ParseResponse? { val url = "${FbItem._SEARCH.url}?q=${if (input.isNotBlank()) input else "a"}" L.i(null, "Search Query $url") - return parse(cookie, frostJsoup(url)) + return parseFromUrl(cookie, url) } } @@ -27,7 +24,7 @@ enum class SearchKeys(val key: String) { EVENTS("keywords_events") } -data class FrostSearches(val results: List) { +data class FrostSearches(val results: List) { override fun toString() = StringBuilder().apply { append("FrostSearches {\n") diff --git a/app/src/main/kotlin/com/pitchedapps/frost/services/FrostNotifications.kt b/app/src/main/kotlin/com/pitchedapps/frost/services/FrostNotifications.kt index afa30a91..2d9e0803 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/services/FrostNotifications.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/services/FrostNotifications.kt @@ -11,7 +11,9 @@ import android.content.Context import android.content.Intent import android.graphics.Bitmap import android.net.Uri +import android.os.BaseBundle import android.os.Build +import android.os.Bundle import android.support.v4.app.NotificationCompat import android.support.v4.app.NotificationManagerCompat import ca.allanwang.kau.utils.color @@ -28,11 +30,16 @@ import com.pitchedapps.frost.dbflow.NotificationModel 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.parsers.FrostParser import com.pitchedapps.frost.parsers.MessageParser import com.pitchedapps.frost.parsers.NotifParser import com.pitchedapps.frost.parsers.ParseNotification -import com.pitchedapps.frost.utils.* +import com.pitchedapps.frost.utils.ARG_USER_ID +import com.pitchedapps.frost.utils.L +import com.pitchedapps.frost.utils.Prefs +import com.pitchedapps.frost.utils.frostAnswersCustom import org.jetbrains.anko.runOnUiThread import java.util.* @@ -54,6 +61,7 @@ inline val Context.frostNotification: NotificationCompat.Builder get() = NotificationCompat.Builder(this, BuildConfig.APPLICATION_ID).apply { setSmallIcon(R.drawable.frost_f_24) setAutoCancel(true) + setStyle(NotificationCompat.BigTextStyle()) color = color(R.color.frost_notification_accent) } @@ -68,9 +76,6 @@ fun NotificationCompat.Builder.withDefaults(ringtone: String = Prefs.notificatio setDefaults(defaults) } -inline val NotificationCompat.Builder.withBigText: NotificationCompat.BigTextStyle - get() = NotificationCompat.BigTextStyle(this) - /** * Created by Allan Wang on 2017-07-08. * @@ -85,7 +90,7 @@ class FrostNotificationTarget(val context: Context, override fun onResourceReady(resource: Bitmap, transition: Transition) { builder.setLargeIcon(resource) - NotificationManagerCompat.from(context).notify(notifTag, notifId, builder.withBigText.build()) + NotificationManagerCompat.from(context).notify(notifTag, notifId, builder.build()) } } @@ -99,12 +104,18 @@ enum class NotificationType( private val getTime: (notif: NotificationModel) -> Long, private val putTime: (notif: NotificationModel, time: Long) -> NotificationModel, private val ringtone: () -> String) { + GENERAL(OverlayContext.NOTIFICATION, FbItem.NOTIFICATIONS, NotifParser, NotificationModel::epoch, { notif, time -> notif.copy(epoch = time) }, - Prefs::notificationRingtone), + Prefs::notificationRingtone) { + + override fun bindRequest(content: NotificationContent, cookie: String) = + FrostRunnable.prepareMarkNotificationRead(content.id, cookie) + }, + MESSAGE(OverlayContext.MESSAGE, FbItem.MESSAGES, MessageParser, @@ -114,6 +125,19 @@ enum class NotificationType( private val groupPrefix = "frost_${name.toLowerCase(Locale.CANADA)}" + /** + * Optional binder to return the request bundle builder + */ + internal open fun bindRequest(content: NotificationContent, cookie: String): (BaseBundle.() -> Unit)? = null + + private fun bindRequest(intent: Intent, content: NotificationContent, cookie: String?) { + cookie ?: return + val binder = bindRequest(content, cookie) ?: return + val bundle = Bundle() + bundle.binder() + intent.putExtras(bundle) + } + /** * Get unread data from designated parser * Display notifications for those after old epoch @@ -138,7 +162,7 @@ enum class NotificationType( newLatestEpoch = notif.timestamp notifCount++ } - if (newLatestEpoch != prevLatestEpoch) + if (newLatestEpoch > prevLatestEpoch) putTime(prevNotifTime, newLatestEpoch).save() L.d("Notif $name new epoch ${getTime(lastNotificationTime(userId))}") summaryNotification(context, userId, notifCount) @@ -154,9 +178,11 @@ enum class NotificationType( val intent = Intent(context, FrostWebActivity::class.java) intent.data = Uri.parse(href) intent.putExtra(ARG_USER_ID, data.id) - intent.putExtra(ARG_OVERLAY_CONTEXT, overlayContext) + overlayContext.put(intent) + bindRequest(intent, content, data.cookie) + val group = "${groupPrefix}_${data.id}" - val pendingIntent = PendingIntent.getActivity(context, 0, intent, 0) + val pendingIntent = PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT) val notifBuilder = context.frostNotification .setContentTitle(title ?: context.string(R.string.frost_name)) .setContentText(text) @@ -170,15 +196,15 @@ enum class NotificationType( if (timestamp != -1L) notifBuilder.setWhen(timestamp * 1000) L.v("Notif load", context.toString()) - NotificationManagerCompat.from(context).notify(group, notifId, notifBuilder.withBigText.build()) + NotificationManagerCompat.from(context).notify(group, notifId, notifBuilder.build()) - if (profileUrl.isNotBlank()) { + if (profileUrl != null) { context.runOnUiThread { //todo verify if context is valid? Glide.with(context) .asBitmap() .load(profileUrl) - .withRoundIcon() + .transform(FrostGlide.circleCrop) .into(FrostNotificationTarget(context, notifId, group, notifBuilder)) } } @@ -196,7 +222,7 @@ enum class NotificationType( val intent = Intent(context, FrostWebActivity::class.java) intent.data = Uri.parse(fbItem.url) intent.putExtra(ARG_USER_ID, userId) - val pendingIntent = PendingIntent.getActivity(context, 0, intent, 0) + val pendingIntent = PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT) val notifBuilder = context.frostNotification.withDefaults(ringtone()) .setContentTitle(context.string(R.string.frost_name)) .setContentText("$count ${context.string(fbItem.titleId)}") @@ -213,12 +239,16 @@ enum class NotificationType( * Notification data holder */ data class NotificationContent(val data: CookieModel, - val notifId: Int, + val id: Long, val href: String, val title: String? = null, // defaults to frost title val text: String, val timestamp: Long, - val profileUrl: String) + val profileUrl: String?) { + + val notifId = Math.abs(id.toInt()) + +} const val NOTIFICATION_PERIODIC_JOB = 7 diff --git a/app/src/main/kotlin/com/pitchedapps/frost/services/FrostRequestService.kt b/app/src/main/kotlin/com/pitchedapps/frost/services/FrostRequestService.kt new file mode 100644 index 00000000..74a8b98d --- /dev/null +++ b/app/src/main/kotlin/com/pitchedapps/frost/services/FrostRequestService.kt @@ -0,0 +1,182 @@ +package com.pitchedapps.frost.services + +import android.app.job.JobInfo +import android.app.job.JobParameters +import android.app.job.JobScheduler +import android.app.job.JobService +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.os.BaseBundle +import android.os.PersistableBundle +import com.pitchedapps.frost.facebook.RequestAuth +import com.pitchedapps.frost.facebook.fbRequest +import com.pitchedapps.frost.facebook.markNotificationRead +import com.pitchedapps.frost.utils.EnumBundle +import com.pitchedapps.frost.utils.EnumBundleCompanion +import com.pitchedapps.frost.utils.EnumCompanion +import com.pitchedapps.frost.utils.L +import org.jetbrains.anko.doAsync +import java.util.concurrent.Future + +/** + * Created by Allan Wang on 28/12/17. + */ + +/** + * Private helper data + */ +private enum class FrostRequestCommands : EnumBundle { + + NOTIF_READ { + + override fun invoke(auth: RequestAuth, bundle: PersistableBundle) { + val id = bundle.getLong(ARG_0, -1L) + val success = auth.markNotificationRead(id).invoke() + L.d("Marked notif $id as read: $success") + } + + override fun propagate(bundle: BaseBundle) = + FrostRunnable.prepareMarkNotificationRead( + bundle.getLong(ARG_0), + bundle.getCookie()) + + }; + + override val bundleContract: EnumBundleCompanion + get() = Companion + + /** + * Call request with arguments inside bundle + */ + abstract fun invoke(auth: RequestAuth, bundle: PersistableBundle) + + /** + * Return bundle builder given arguments in the old bundle + * Must not write to old bundle! + */ + abstract fun propagate(bundle: BaseBundle): BaseBundle.() -> Unit + + companion object : EnumCompanion("frost_arg_commands", values()) + +} + +private const val ARG_COMMAND = "frost_request_command" +private const val ARG_COOKIE = "frost_request_cookie" +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) + +/** + * Singleton handler for running requests in [FrostRequestService] + * Requests are typically completely decoupled from the UI, + * and are optional enhancers. + * + * Nothing guarantees the completion time, or whether it even executes at all + * + * Design: + * prepare function - creates a bundle binder + * actor function - calls the service with the given arguments + * + * Global: + * propagator - given a bundle with a command, extracts and executes the requests + */ +object FrostRunnable { + + fun prepareMarkNotificationRead(id: Long, cookie: String): BaseBundle.() -> Unit = { + FrostRequestCommands.NOTIF_READ.put(this) + putLong(ARG_0, id) + putCookie(cookie) + } + + fun markNotificationRead(context: Context, id: Long, cookie: String): Boolean { + if (id <= 0) { + L.d("Invalid notification id $id for marking as read") + return false + } + return schedule(context, FrostRequestCommands.NOTIF_READ, + prepareMarkNotificationRead(id, cookie)) + } + + fun propagate(context: Context, intent: Intent?) { + intent?.extras ?: return + val command = FrostRequestCommands[intent] ?: return + intent.removeExtra(ARG_COMMAND) // reset + L.d("Propagating command ${command.name}") + val builder = command.propagate(intent.extras) + schedule(context, command, builder) + } + + private fun schedule(context: Context, + command: FrostRequestCommands, + bundleBuilder: PersistableBundle.() -> Unit): Boolean { + val scheduler = context.getSystemService(Context.JOB_SCHEDULER_SERVICE) as JobScheduler + val serviceComponent = ComponentName(context, FrostRequestService::class.java) + val bundle = PersistableBundle() + bundle.bundleBuilder() + bundle.putString(ARG_COMMAND, command.name) + + if (bundle.getCookie().isNullOrBlank()) { + L.e("Scheduled frost request with empty cookie)") + return false + } + + val builder = JobInfo.Builder(JOB_REQUEST_BASE + command.ordinal, serviceComponent) + .setMinimumLatency(0L) + .setExtras(bundle) + .setOverrideDeadline(2000L) + .setRequiredNetworkType(JobInfo.NETWORK_TYPE_ANY) + val result = scheduler.schedule(builder.build()) + if (result <= 0) { + L.eThrow("FrostRequestService scheduler failed for ${command.name}") + return false + } + L.d("Scheduled ${command.name}") + return true + } + +} + +class FrostRequestService : JobService() { + + var future: Future? = null + + override fun onStopJob(params: JobParameters?): Boolean { + future?.cancel(true) + future = null + return false + } + + override fun onStartJob(params: JobParameters?): Boolean { + val bundle = params?.extras + if (bundle == null) { + L.eThrow("Launched ${this::class.java.simpleName} without param data") + return false + } + val cookie = bundle.getCookie() + if (cookie.isNullOrBlank()) { + L.eThrow("Launched ${this::class.java.simpleName} without cookie") + return false + } + val command = FrostRequestCommands[bundle] + if (command == null) { + L.eThrow("Launched ${this::class.java.simpleName} without command") + return false + } + val now = System.currentTimeMillis() + future = doAsync { + cookie.fbRequest { + L.d("Requesting frost service for ${command.name}") + command.invoke(this, bundle) + } + L.d("Finished frost service for ${command.name} in ${System.currentTimeMillis() - now} ms") + jobFinished(params, false) + } + return true + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/pitchedapps/frost/services/NotificationService.kt b/app/src/main/kotlin/com/pitchedapps/frost/services/NotificationService.kt index adeefec6..c1a4ace1 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/services/NotificationService.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/services/NotificationService.kt @@ -77,7 +77,7 @@ class NotificationService : JobService() { return null } - private fun Context.debugNotification(text: String) { + private fun Context.debugNotification(text: String = string(R.string.kau_lorem_ipsum)) { if (!BuildConfig.DEBUG) return val notifBuilder = frostNotification.withDefaults() .setContentTitle(string(R.string.frost_name)) diff --git a/app/src/main/kotlin/com/pitchedapps/frost/utils/EnumUtils.kt b/app/src/main/kotlin/com/pitchedapps/frost/utils/EnumUtils.kt new file mode 100644 index 00000000..d20d1573 --- /dev/null +++ b/app/src/main/kotlin/com/pitchedapps/frost/utils/EnumUtils.kt @@ -0,0 +1,57 @@ +package com.pitchedapps.frost.utils + +import android.content.Intent +import android.os.BaseBundle + +/** + * Created by Allan Wang on 29/12/17. + * + * Helper to set enum using its name rather than the serialized version + * Name is used in case the enum is involved in persistent data, where updates may shift indices + */ +interface EnumBundle> { + + val bundleContract: EnumBundleCompanion + + val name: String + + val ordinal: Int + + fun put(intent: Intent) { + intent.putExtra(bundleContract.argTag, name) + } + + fun put(bundle: BaseBundle?) { + bundle?.putString(bundleContract.argTag, name) + } +} + +interface EnumBundleCompanion> { + + val argTag: String + + val values: Array + + val valueMap: Map + + operator fun get(name: String?) = if (name == null) null else valueMap[name] + + operator fun get(bundle: BaseBundle?) = get(bundle?.getString(argTag)) + + operator fun get(intent: Intent?) = get(intent?.getStringExtra(argTag)) + +} + +open class EnumCompanion>( + override final val argTag: String, + override final val values: Array) : EnumBundleCompanion { + + override final val valueMap: Map = values.map { it.name to it }.toMap() + + override final fun get(name: String?) = super.get(name) + + override final fun get(bundle: BaseBundle?) = super.get(bundle) + + override final fun get(intent: Intent?) = super.get(intent) + +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/pitchedapps/frost/utils/Prefs.kt b/app/src/main/kotlin/com/pitchedapps/frost/utils/Prefs.kt index cc5ee733..f14039b7 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/utils/Prefs.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/utils/Prefs.kt @@ -102,7 +102,7 @@ object Prefs : KPref() { var animate: Boolean by kpref("fancy_animations", true) - var notificationKeywords: StringSet by kpref("notification_keywords", mutableSetOf()) + var notificationKeywords: StringSet by kpref("notification_keywords", mutableSetOf()) var notificationAllAccounts: Boolean by kpref("notification_all_accounts", true) diff --git a/app/src/main/kotlin/com/pitchedapps/frost/utils/Utils.kt b/app/src/main/kotlin/com/pitchedapps/frost/utils/Utils.kt index 0ca068b5..592dd4fc 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/utils/Utils.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/utils/Utils.kt @@ -2,6 +2,7 @@ package com.pitchedapps.frost.utils import android.annotation.SuppressLint import android.app.Activity +import android.app.ActivityOptions import android.content.Context import android.content.Intent import android.graphics.Color @@ -10,7 +11,6 @@ import android.net.Uri import android.support.annotation.StringRes import android.support.design.internal.SnackbarContentLayout import android.support.design.widget.Snackbar -import android.support.v4.app.ActivityOptionsCompat import android.support.v7.widget.Toolbar import android.view.View import android.widget.FrameLayout @@ -22,9 +22,6 @@ import ca.allanwang.kau.mediapicker.createPrivateMediaFile import ca.allanwang.kau.utils.* import ca.allanwang.kau.xml.showChangelog import com.afollestad.materialdialogs.MaterialDialog -import com.bumptech.glide.RequestBuilder -import com.bumptech.glide.load.resource.bitmap.CircleCrop -import com.bumptech.glide.request.RequestOptions import com.crashlytics.android.answers.Answers import com.crashlytics.android.answers.CustomEvent import com.pitchedapps.frost.BuildConfig @@ -47,7 +44,6 @@ const val ARG_URL = "arg_url" const val ARG_USER_ID = "arg_user_id" const val ARG_IMAGE_URL = "arg_image_url" const val ARG_TEXT = "arg_text" -const val ARG_OVERLAY_CONTEXT = "arg_overlay_context" fun Context.launchNewTask(clazz: Class, cookieList: ArrayList = arrayListOf(), clearStack: Boolean = false) { startActivity(clazz, clearStack, intentBuilder = { @@ -61,7 +57,7 @@ fun Context.launchLogin(cookieList: ArrayList, clearStack: Boolean } fun Activity.cookies(): ArrayList { - return intent?.extras?.getParcelableArrayList(EXTRA_COOKIES) ?: arrayListOf() + return intent?.getParcelableArrayListExtra(EXTRA_COOKIES) ?: arrayListOf() } /** @@ -81,7 +77,7 @@ fun Context.launchWebOverlay(url: String, clazz: Class) = launchNewTask(IntroActivity::class.java, cookieList, true) fun WebOverlayActivity.url(): String { - return intent.extras?.getString(ARG_URL) ?: FbItem.FEED.url + return intent.getStringExtra(ARG_URL) ?: FbItem.FEED.url } fun Context.materialDialogThemed(action: MaterialDialog.Builder.() -> Unit): MaterialDialog { @@ -192,8 +188,6 @@ fun Activity.frostNavigationBar() { navigationBarColor = if (Prefs.tintNavBar) Prefs.headerColor else Color.BLACK } -fun RequestBuilder.withRoundIcon() = apply(RequestOptions().transform(CircleCrop()))!! - @Throws(IOException::class) fun createMediaFile(extension: String) = createMediaFile("Frost", extension) diff --git a/app/src/main/kotlin/com/pitchedapps/frost/views/AccountItem.kt b/app/src/main/kotlin/com/pitchedapps/frost/views/AccountItem.kt index 2ab1d572..64cf34a1 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/views/AccountItem.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/views/AccountItem.kt @@ -16,8 +16,9 @@ import com.mikepenz.google_material_typeface_library.GoogleMaterial 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.utils.Prefs -import com.pitchedapps.frost.utils.withRoundIcon /** * Created by Allan Wang on 2017-06-05. @@ -32,7 +33,8 @@ class AccountItem(val cookie: CookieModel?) : KauIItem { + Glide.with(itemView).load(PROFILE_PICTURE_URL(cookie.id)) + .transform(FrostGlide.roundCorner).listener(object : RequestListener { override fun onResourceReady(resource: Drawable?, model: Any?, target: Target?, dataSource: DataSource?, isFirstResource: Boolean): Boolean { text.fadeIn() return false diff --git a/app/src/main/kotlin/com/pitchedapps/frost/views/FrostRecyclerView.kt b/app/src/main/kotlin/com/pitchedapps/frost/views/FrostRecyclerView.kt index 436f8b00..38b09657 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/views/FrostRecyclerView.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/views/FrostRecyclerView.kt @@ -1,15 +1,17 @@ package com.pitchedapps.frost.views import android.content.Context +import android.support.v7.widget.LinearLayoutManager import android.support.v7.widget.RecyclerView import android.util.AttributeSet import android.view.View +import ca.allanwang.kau.utils.circularReveal +import ca.allanwang.kau.utils.fadeOut import com.pitchedapps.frost.contracts.FrostContentContainer import com.pitchedapps.frost.contracts.FrostContentCore import com.pitchedapps.frost.contracts.FrostContentParent import com.pitchedapps.frost.fragments.RecyclerContentContract -import com.pitchedapps.frost.utils.L -import java.lang.ref.WeakReference +import com.pitchedapps.frost.utils.Prefs /** * Created by Allan Wang on 2017-05-29. @@ -27,12 +29,16 @@ class FrostRecyclerView @JvmOverloads constructor( override val currentUrl: String get() = parent.baseUrl - lateinit var recyclerContract: WeakReference + lateinit var recyclerContract: RecyclerContentContract + + init { + layoutManager = LinearLayoutManager(context) + } override fun bind(container: FrostContentContainer): View { if (container !is RecyclerContentContract) throw IllegalStateException("FrostRecyclerView must bind to a container that is a RecyclerContentContract") - this.recyclerContract = WeakReference(container) + this.recyclerContract = container container.bind(this) return this } @@ -41,15 +47,15 @@ class FrostRecyclerView @JvmOverloads constructor( isNestedScrollingEnabled = true } + var onReloadClear: () -> Unit = {} + override fun reloadBase(animate: Boolean) { - val contract = recyclerContract.get() - if (contract == null) { - L.eThrow("Attempted to reload with invalid contract") - return - } - contract.reload({ parent.progressObservable.onNext(it) }) { + if (Prefs.animate) fadeOut(onFinish = onReloadClear) + parent.refreshObservable.onNext(true) + recyclerContract.reload({ parent.progressObservable.onNext(it) }) { parent.progressObservable.onNext(100) parent.refreshObservable.onNext(false) + if (Prefs.animate) post { circularReveal() } } } @@ -64,11 +70,12 @@ class FrostRecyclerView @JvmOverloads constructor( override fun onBackPressed() = false /** - * If webview is already at the top, refresh + * If recycler is already at the top, refresh * Otherwise scroll to top */ override fun onTabClicked() { - if (scrollY < 5) reloadBase(true) + val firstPosition = (layoutManager as LinearLayoutManager).findFirstCompletelyVisibleItemPosition() + if (firstPosition == 0) reloadBase(true) else scrollToTop() } @@ -77,12 +84,8 @@ class FrostRecyclerView @JvmOverloads constructor( smoothScrollToPosition(0) } + // nothing running in background; no need to listen override var active: Boolean = true - set(value) { - if (field == value) return - field = value - // todo - } override fun reloadTheme() { reloadThemeSelf() diff --git a/app/src/main/kotlin/com/pitchedapps/frost/views/FrostWebView.kt b/app/src/main/kotlin/com/pitchedapps/frost/views/FrostWebView.kt index 0d04fcd9..c35f1bb8 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/views/FrostWebView.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/views/FrostWebView.kt @@ -6,6 +6,7 @@ import android.content.Context import android.graphics.Color import android.util.AttributeSet import android.view.View +import android.view.ViewGroup import ca.allanwang.kau.utils.AnimHolder import com.pitchedapps.frost.contracts.FrostContentContainer import com.pitchedapps.frost.contracts.FrostContentCore @@ -147,4 +148,11 @@ class FrostWebView @JvmOverloads constructor( settings.textZoom = Prefs.webTextScaling } + override fun destroy() { + val parent = getParent() as? ViewGroup + if (parent != null) { + parent.removeView(this) + super.destroy() + } + } } \ No newline at end of file diff --git a/app/src/main/kotlin/com/pitchedapps/frost/web/LoginWebView.kt b/app/src/main/kotlin/com/pitchedapps/frost/web/LoginWebView.kt index 9251e607..9855040d 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/web/LoginWebView.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/web/LoginWebView.kt @@ -2,7 +2,6 @@ package com.pitchedapps.frost.web import android.annotation.SuppressLint import android.content.Context -import android.graphics.Bitmap import android.graphics.Color import android.util.AttributeSet import android.view.View diff --git a/app/src/main/res/layout/iitem_notification.xml b/app/src/main/res/layout/iitem_notification.xml new file mode 100644 index 00000000..266b9dc4 --- /dev/null +++ b/app/src/main/res/layout/iitem_notification.xml @@ -0,0 +1,60 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/view_content_base_recycler.xml b/app/src/main/res/layout/view_content_base_recycler.xml index efe69913..e1591efe 100644 --- a/app/src/main/res/layout/view_content_base_recycler.xml +++ b/app/src/main/res/layout/view_content_base_recycler.xml @@ -17,7 +17,6 @@ android:layout_height="match_parent" android:focusable="true" android:focusableInTouchMode="true" - app:layoutManager="android.support.v7.widget.LinearLayoutManager" app:layout_behavior="@string/appbar_scrolling_view_behavior" /> diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml index 3d03888e..713bd1b4 100644 --- a/app/src/main/res/values/dimens.xml +++ b/app/src/main/res/values/dimens.xml @@ -2,6 +2,7 @@ 16dp 1dip 100dp + 48dp 60dp diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 540ee0da..e39e91ed 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -25,6 +25,7 @@ Select Facebook Account Current account is not in the database + Frost Requests Frost Notifications Requires custom theme diff --git a/app/src/test/kotlin/com/pitchedapps/frost/MiscTest.kt b/app/src/test/kotlin/com/pitchedapps/frost/MiscTest.kt index 91e2149c..54792086 100644 --- a/app/src/test/kotlin/com/pitchedapps/frost/MiscTest.kt +++ b/app/src/test/kotlin/com/pitchedapps/frost/MiscTest.kt @@ -1,8 +1,9 @@ package com.pitchedapps.frost +import com.pitchedapps.frost.facebook.zip import com.pitchedapps.frost.injectors.CssHider import org.junit.Test -import kotlin.test.assertEquals +import kotlin.test.assertTrue /** * Created by Allan Wang on 2017-06-14. @@ -14,8 +15,21 @@ class MiscTest { print(CssHider.HEADER.injector.function) } + /** + * Spin off 15 threads + * Pause each for 1 - 2s + * Ensure that total zipped process does not take over 5s + */ @Test - fun nullPair() { - assertEquals(Pair(null, 2), Pair(null, 2)) + fun zip() { + val now = System.currentTimeMillis() + val base = 1 + val data = (0..15).map { Math.random() + base }.toTypedArray().zip( + List::toLongArray, + { Thread.sleep((it * 1000).toLong()); System.currentTimeMillis() - now } + ).blockingGet() + println(data.contentToString()) + assertTrue(data.all { it >= base * 1000 && it < base * 1000 * 5 }, + "zip did not seem to work on different threads") } } \ No newline at end of file diff --git a/app/src/test/kotlin/com/pitchedapps/frost/facebook/FbParseTest.kt b/app/src/test/kotlin/com/pitchedapps/frost/facebook/FbParseTest.kt index 65777f97..8c568279 100644 --- a/app/src/test/kotlin/com/pitchedapps/frost/facebook/FbParseTest.kt +++ b/app/src/test/kotlin/com/pitchedapps/frost/facebook/FbParseTest.kt @@ -7,6 +7,8 @@ import com.pitchedapps.frost.internal.authDependent import com.pitchedapps.frost.parsers.* import org.junit.BeforeClass import org.junit.Test +import kotlin.test.assertNotNull +import kotlin.test.assertTrue import kotlin.test.fail /** @@ -22,25 +24,39 @@ class FbParseTest { } } - private inline fun FrostParser.test(action: T.() -> Unit = {}) { - val response = parse(COOKIE) - ?: fail("${this::class.java.simpleName} returned null for $url") + private inline fun FrostParser.test(action: T.() -> Unit = {}) = + parse(COOKIE).test(url, action) + + private inline fun ParseResponse?.test(url: String, action: T.() -> Unit = {}) { + val response = this + ?: fail("${T::class.simpleName} parser returned null for $url") println(response) response.data.action() } @Test fun message() = MessageParser.test { - threads.forEach(FrostThread::assertComponentsNotEmpty) + threads.forEach { + it.assertComponentsNotEmpty() + assertTrue(it.id > FALLBACK_TIME_MOD, "id may not be properly matched") + assertNotNull(it.img, "img may not be properly matched") + } threads.map(FrostThread::time).assertDescending("thread time values") } + @Test + fun messageUser() = MessageParser.queryUser(COOKIE, "allan").test("allan query") + @Test fun search() = SearchParser.test() @Test fun notif() = NotifParser.test { - notifs.forEach(FrostNotif::assertComponentsNotEmpty) + notifs.forEach { + it.assertComponentsNotEmpty() + assertTrue(it.id > FALLBACK_TIME_MOD, "id may not be properly matched") + assertNotNull(it.img, "img may not be properly matched") + } notifs.map(FrostNotif::time).assertDescending("notif time values") } } \ No newline at end of file diff --git a/app/src/test/kotlin/com/pitchedapps/frost/facebook/FbRegexTest.kt b/app/src/test/kotlin/com/pitchedapps/frost/facebook/FbRegexTest.kt index a21bcb13..da815b34 100644 --- a/app/src/test/kotlin/com/pitchedapps/frost/facebook/FbRegexTest.kt +++ b/app/src/test/kotlin/com/pitchedapps/frost/facebook/FbRegexTest.kt @@ -25,8 +25,13 @@ class FbRegexTest { @Test fun ppRegex() { val img = "https\\3a //scontent-yyz1-1.xx.fbcdn.net/v/asdf1234.jpg?efg\\3d 333\\26 oh\\3d 77\\26 oe\\3d 444" - val ppStyle = "background:#d8dce6 url('$img') no-repeat center;background-size:100% 100%;-webkit-background-size:100% 100%;width:58px;height:58px;" - assertEquals(StringEscapeUtils.unescapeCsv(img), StringEscapeUtils.unescapeCsv(FB_CSS_URL_MATCHER.find(ppStyle)[1])) + val imgUnescaped = StringEscapeUtils.unescapeCsv(img) + val ppStyleSingleQuote = "background:#d8dce6 url('$img') no-repeat center;" + val ppStyleDoubleQuote = "background:#d8dce6 url(\"$img\") no-repeat center;" + val ppStyleNoQuote = "background:#d8dce6 url($img) no-repeat center;" + listOf(ppStyleSingleQuote, ppStyleDoubleQuote, ppStyleNoQuote).forEach { + assertEquals(imgUnescaped, StringEscapeUtils.unescapeCsv(FB_CSS_URL_MATCHER.find(it)[1])) + } } @Test diff --git a/app/src/test/kotlin/com/pitchedapps/frost/facebook/FbRequestTest.kt b/app/src/test/kotlin/com/pitchedapps/frost/facebook/FbRequestTest.kt index 16894b16..c3b19727 100644 --- a/app/src/test/kotlin/com/pitchedapps/frost/facebook/FbRequestTest.kt +++ b/app/src/test/kotlin/com/pitchedapps/frost/facebook/FbRequestTest.kt @@ -23,7 +23,7 @@ class FbRequestTest { } /** - * Used to emulate [executeAndCheck] + * Used to emulate [executeForNoError] * Must be consistent with that method */ private fun Call.assertNoError() { @@ -35,7 +35,7 @@ class FbRequestTest { @Test fun auth() { - val auth = (USER_ID to COOKIE).getAuth() + val auth = COOKIE.getAuth() assertNotNull(auth) assertEquals(USER_ID, auth.userId) assertEquals(COOKIE, auth.cookie) @@ -44,8 +44,8 @@ class FbRequestTest { @Test fun markNotification() { - val notifId = 1513544657695779 - AUTH.markNotificationRead(notifId).assertNoError() + val notifId = 1514443903880 + AUTH.markNotificationRead(notifId).call.assertNoError() } } \ No newline at end of file diff --git a/app/src/test/kotlin/com/pitchedapps/frost/internal/Internal.kt b/app/src/test/kotlin/com/pitchedapps/frost/internal/Internal.kt index deaed333..ed88453a 100644 --- a/app/src/test/kotlin/com/pitchedapps/frost/internal/Internal.kt +++ b/app/src/test/kotlin/com/pitchedapps/frost/internal/Internal.kt @@ -32,7 +32,7 @@ val PROPS: Properties by lazy { val COOKIE: String by lazy { PROPS.getProperty("COOKIE") ?: "" } val USER_ID: Long by lazy { FB_USER_MATCHER.find(COOKIE)[1]?.toLong() ?: -1 } val AUTH: RequestAuth by lazy { - (USER_ID to COOKIE).getAuth().apply { + COOKIE.getAuth().apply { println("Auth:\nuser:$userId\nfb_dtsg: $fb_dtsg\nrev: $rev\nvalid: $isValid") } } diff --git a/app/src/test/kotlin/com/pitchedapps/frost/parsers/MessageParserTest.kt b/app/src/test/kotlin/com/pitchedapps/frost/parsers/MessageParserTest.kt deleted file mode 100644 index ecebed04..00000000 --- a/app/src/test/kotlin/com/pitchedapps/frost/parsers/MessageParserTest.kt +++ /dev/null @@ -1,29 +0,0 @@ -package com.pitchedapps.frost.parsers - -import com.pitchedapps.frost.facebook.FB_EPOCH_MATCHER -import com.pitchedapps.frost.facebook.formattedFbUrl -import com.pitchedapps.frost.facebook.get -import org.junit.Test -import kotlin.test.assertEquals - -/** - * Created by Allan Wang on 2017-10-06. - */ -class MessageParserTest { - - @Test - fun basic() = debug("messages", MessageParser) - - @Test - fun parseEpoch() { - val input = "{\"time\":1507301642,\"short\":true,\"forceseconds\":false}" - assertEquals(1507301642, FB_EPOCH_MATCHER.find(input)[1]!!.toLong()) - } - - @Test - fun parseImage() { - var input = "https\\3a //scontent.fyhu1-1.fna.fbcdn.net/v/t1.0-1/cp0/e15/q65/p100x100/12994387_243040309382307_4586627375882013710_n.jpg?efg\\3d eyJpIjoidCJ9\\26 oh\\3d b9ae0d7a1298989fe24873e2ee4054b6\\26 oe\\3d 5A3A7FE1" - input = input.formattedFbUrl - println(input) - } -} \ No newline at end of file diff --git a/app/src/test/kotlin/com/pitchedapps/frost/parsers/ParserTestHelper.kt b/app/src/test/kotlin/com/pitchedapps/frost/parsers/ParserTestHelper.kt deleted file mode 100644 index 53495ecb..00000000 --- a/app/src/test/kotlin/com/pitchedapps/frost/parsers/ParserTestHelper.kt +++ /dev/null @@ -1,22 +0,0 @@ -package com.pitchedapps.frost.parsers - -import java.net.URL -import java.nio.file.Paths - -/** - * Created by Allan Wang on 2017-10-06. - */ -fun T.getResource(path: String): String? { - Paths.get("src/test/resources/${path.trimStart('/')}") - val resource: URL? = this::class.java.classLoader.getResource(path) - if (resource == null) { - println("Resource at $path could not be found") - return null - } - return resource.readText() -} - -fun T.debug(path: String, parser: FrostParser

) { - val content = getResource("priv/$path.html") ?: return -// println(parser.debug(content)) -} \ No newline at end of file diff --git a/app/src/test/kotlin/com/pitchedapps/frost/parsers/SearchParserTest.kt b/app/src/test/kotlin/com/pitchedapps/frost/parsers/SearchParserTest.kt deleted file mode 100644 index 6a7b60ae..00000000 --- a/app/src/test/kotlin/com/pitchedapps/frost/parsers/SearchParserTest.kt +++ /dev/null @@ -1,18 +0,0 @@ -package com.pitchedapps.frost.parsers - -import org.junit.Test - -/** - * Created by Allan Wang on 2017-10-06. - */ -class SearchParserTest { - - @Test - fun debug() = debug("search", SearchParser) - - @Test - fun debug2() = debug("search2", SearchParser) - - @Test - fun debug3() = debug("search3", SearchParser) -} \ No newline at end of file diff --git a/gradle.properties b/gradle.properties index 3ac2ad96..adbd6c10 100644 --- a/gradle.properties +++ b/gradle.properties @@ -17,7 +17,7 @@ MIN_SDK=21 TARGET_SDK=27 BUILD_TOOLS=27.0.2 -KAU=196a29b +KAU=e7480f2 KOTLIN=1.2.10 ANDROID_SUPPORT_LIBS=27.0.2 -- cgit v1.2.3