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

Merge pull request #1365 from AllanWang/room

Room
This commit is contained in:
Allan Wang 2019-04-25 15:14:10 -07:00 committed by GitHub
commit 4f3e44b7b9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
55 changed files with 2043 additions and 410 deletions

View File

@ -1,7 +1,7 @@
# Frost-for-Facebook
[![Releaes Version](https://img.shields.io/github/release/AllanWang/Frost-for-Facebook.svg)](https://github.com/AllanWang/Frost-for-Facebook/releases)
[![Build Status](https://travis-ci.org/AllanWang/Frost-for-Facebook.svg?branch=dev)](https://travis-ci.org/AllanWang/Frost-for-Facebook)
[![Build Status](https://travis-ci.com/AllanWang/Frost-for-Facebook.svg?branch=dev)](https://travis-ci.com/AllanWang/Frost-for-Facebook)
[![Crowdin](https://d322cqt584bo4o.cloudfront.net/frost-for-facebook/localized.svg)](https://crowdin.com/project/frost-for-facebook)
[![ZenHub](https://img.shields.io/badge/Shipping%20faster%20with-ZenHub-45529A.svg)](https://app.zenhub.com/workspace/o/allanwang/frost-for-facebook/boards)
[![BugSnag](https://img.shields.io/badge/Bug%20tracking%20with-BugSnag-37C2D9.svg)](https://www.bugsnag.com/)

View File

@ -27,6 +27,11 @@ android {
versionName androidGitVersion.name()
multiDexEnabled true
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
javaCompileOptions {
annotationProcessorOptions {
arguments = ["room.schemaLocation": "$projectDir/src/schemas".toString()]
}
}
}
applicationVariants.all { variant ->
@ -174,6 +179,7 @@ dependencies {
androidTestImplementation kauDependency.espresso
androidTestImplementation kauDependency.testRules
androidTestImplementation kauDependency.testRunner
androidTestImplementation "org.jetbrains.kotlin:kotlin-reflect:${KOTLIN}"
testImplementation kauDependency.kotlinTest
testImplementation "org.jetbrains.kotlin:kotlin-reflect:${KOTLIN}"
@ -200,9 +206,9 @@ dependencies {
implementation "androidx.core:core-ktx:${KTX}"
// implementation "org.koin:koin-android:${KOIN}"
// testImplementation "org.koin:koin-test:${KOIN}"
// androidTestImplementation "org.koin:koin-test:${KOIN}"
implementation "org.koin:koin-android:${KOIN}"
testImplementation "org.koin:koin-test:${KOIN}"
androidTestImplementation "org.koin:koin-test:${KOIN}"
// androidTestImplementation "io.mockk:mockk:${MOCKK}"
@ -253,6 +259,11 @@ dependencies {
implementation "com.sothree.slidinguppanel:library:${SLIDING_PANEL}"
implementation "androidx.room:room-coroutines:${ROOM}"
implementation "androidx.room:room-runtime:${ROOM}"
kapt "androidx.room:room-compiler:${ROOM}"
testImplementation "androidx.room:room-testing:${ROOM}"
}
// Validates code and generates apk

View File

@ -0,0 +1,48 @@
/*
* Copyright 2019 Allan Wang
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package com.pitchedapps.frost.db
import android.content.Context
import androidx.room.Room
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.runner.RunWith
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
@RunWith(AndroidJUnit4::class)
abstract class BaseDbTest {
protected lateinit var db: FrostDatabase
@BeforeTest
fun before() {
val context = ApplicationProvider.getApplicationContext<Context>()
val privateDb = Room.inMemoryDatabaseBuilder(
context, FrostPrivateDatabase::class.java
).build()
val publicDb = Room.inMemoryDatabaseBuilder(
context, FrostPublicDatabase::class.java
).build()
db = FrostDatabase(privateDb, publicDb)
}
@AfterTest
fun after() {
db.close()
}
}

View File

@ -0,0 +1,48 @@
/*
* Copyright 2019 Allan Wang
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package com.pitchedapps.frost.db
import kotlinx.coroutines.runBlocking
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
import kotlin.test.fail
class CacheDbTest : BaseDbTest() {
private val dao get() = db.cacheDao()
private val cookieDao get() = db.cookieDao()
private fun cookie(id: Long) = CookieEntity(id, "name$id", "cookie$id")
@Test
fun save() {
val cookie = cookie(1L)
val type = "test"
val content = "long test".repeat(10000)
runBlocking {
cookieDao.save(cookie)
dao.save(cookie.id, type, content)
val cache = dao.select(cookie.id, type) ?: fail("Cache not found")
assertEquals(content, cache.contents, "Content mismatch")
assertTrue(
System.currentTimeMillis() - cache.lastUpdated < 500,
"Cache retrieval took over 500ms (${System.currentTimeMillis() - cache.lastUpdated})"
)
}
}
}

View File

@ -0,0 +1,85 @@
/*
* Copyright 2019 Allan Wang
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package com.pitchedapps.frost.db
import kotlinx.coroutines.runBlocking
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
class CookieDbTest : BaseDbTest() {
private val dao get() = db.cookieDao()
@Test
fun basicCookie() {
val cookie = CookieEntity(id = 1234L, name = "testName", cookie = "testCookie")
runBlocking {
dao.save(cookie)
val cookies = dao.selectAll()
assertEquals(listOf(cookie), cookies, "Cookie mismatch")
}
}
@Test
fun deleteCookie() {
val cookie = CookieEntity(id = 1234L, name = "testName", cookie = "testCookie")
runBlocking {
dao.save(cookie)
dao.deleteById(cookie.id + 1)
assertEquals(
listOf(cookie),
dao.selectAll(),
"Cookie list should be the same after inexistent deletion"
)
dao.deleteById(cookie.id)
assertEquals(emptyList(), dao.selectAll(), "Cookie list should be empty after deletion")
}
}
@Test
fun insertReplaceCookie() {
val cookie = CookieEntity(id = 1234L, name = "testName", cookie = "testCookie")
runBlocking {
dao.save(cookie)
assertEquals(listOf(cookie), dao.selectAll(), "Cookie insertion failed")
dao.save(cookie.copy(name = "testName2"))
assertEquals(
listOf(cookie.copy(name = "testName2")),
dao.selectAll(),
"Cookie replacement failed"
)
dao.save(cookie.copy(id = 123L))
assertEquals(
setOf(cookie.copy(id = 123L), cookie.copy(name = "testName2")),
dao.selectAll().toSet(),
"New cookie insertion failed"
)
}
}
@Test
fun selectCookie() {
val cookie = CookieEntity(id = 1234L, name = "testName", cookie = "testCookie")
runBlocking {
dao.save(cookie)
assertEquals(cookie, dao.selectById(cookie.id), "Cookie selection failed")
assertNull(dao.selectById(cookie.id + 1), "Inexistent cookie selection failed")
}
}
}

View File

@ -0,0 +1,54 @@
/*
* Copyright 2019 Allan Wang
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package com.pitchedapps.frost.db
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.runner.RunWith
import org.koin.error.NoBeanDefFoundException
import org.koin.standalone.get
import org.koin.test.KoinTest
import kotlin.reflect.KClass
import kotlin.reflect.full.functions
import kotlin.test.Test
import kotlin.test.assertTrue
@RunWith(AndroidJUnit4::class)
class DatabaseTest : KoinTest {
inline fun <reified T : Any> hasKoin() = hasKoin(T::class)
fun <T : Any> hasKoin(klazz: KClass<T>): Boolean =
try {
get<T>(clazz = klazz)
true
} catch (e: NoBeanDefFoundException) {
false
}
/**
* Database and all daos should be loaded as components
*/
@Test
fun testKoins() {
hasKoin<FrostDatabase>()
val members = FrostDatabase::class.java.kotlin.functions.filter { it.name.endsWith("Dao") }
.mapNotNull { it.returnType.classifier as? KClass<*> }
assertTrue(members.isNotEmpty(), "Failed to find dao interfaces")
val missingKoins = (members + FrostDatabase::class).filter { !hasKoin(it) }
assertTrue(missingKoins.isEmpty(), "Missing koins: $missingKoins")
}
}

View File

@ -0,0 +1,62 @@
/*
* Copyright 2019 Allan Wang
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package com.pitchedapps.frost.db
import com.pitchedapps.frost.facebook.FbItem
import com.pitchedapps.frost.facebook.defaultTabs
import kotlinx.coroutines.runBlocking
import kotlin.test.Test
import kotlin.test.assertEquals
class GenericDbTest : BaseDbTest() {
private val dao get() = db.genericDao()
/**
* Note that order is also preserved here
*/
@Test
fun save() {
val tabs = listOf(FbItem.ACTIVITY_LOG, FbItem.BIRTHDAYS, FbItem.EVENTS, FbItem.MARKETPLACE, FbItem.ACTIVITY_LOG)
runBlocking {
dao.saveTabs(tabs)
assertEquals(tabs, dao.getTabs(), "Tab saving failed")
val newTabs = listOf(FbItem.PAGES, FbItem.MENU)
dao.saveTabs(newTabs)
assertEquals(newTabs, dao.getTabs(), "Tab overwrite failed")
}
}
@Test
fun defaultRetrieve() {
runBlocking {
assertEquals(defaultTabs(), dao.getTabs(), "Default retrieval failed")
}
}
@Test
fun ignoreErrors() {
runBlocking {
dao.save(GenericEntity(GenericDao.TYPE_TABS, "${FbItem.ACTIVITY_LOG.name},unknown,${FbItem.EVENTS.name}"))
assertEquals(
listOf(FbItem.ACTIVITY_LOG, FbItem.EVENTS),
dao.getTabs(),
"Tab fetching does not ignore unknown names"
)
}
}
}

View File

@ -0,0 +1,145 @@
/*
* Copyright 2019 Allan Wang
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package com.pitchedapps.frost.db
import com.pitchedapps.frost.services.NOTIF_CHANNEL_GENERAL
import com.pitchedapps.frost.services.NOTIF_CHANNEL_MESSAGES
import com.pitchedapps.frost.services.NotificationContent
import kotlinx.coroutines.runBlocking
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class NotificationDbTest : BaseDbTest() {
private val dao get() = db.notifDao()
private fun cookie(id: Long) = CookieEntity(id, "name$id", "cookie$id")
private fun notifContent(id: Long, cookie: CookieEntity, time: Long = id) = NotificationContent(
data = cookie,
id = id,
href = "",
title = null,
text = "",
timestamp = time,
profileUrl = null,
unread = true
)
@Test
fun saveAndRetrieve() {
val cookie = cookie(12345L)
// Unique unsorted ids
val notifs = listOf(0L, 4L, 2L, 6L, 99L, 3L).map { notifContent(it, cookie) }
runBlocking {
db.cookieDao().save(cookie)
dao.saveNotifications(NOTIF_CHANNEL_GENERAL, notifs)
val dbNotifs = dao.selectNotifications(cookie.id, NOTIF_CHANNEL_GENERAL)
assertEquals(notifs.sortedByDescending { it.timestamp }, dbNotifs, "Incorrect notification list received")
}
}
@Test
fun selectConditions() {
runBlocking {
val cookie1 = cookie(12345L)
val cookie2 = cookie(12L)
val notifs1 = (0L..2L).map { notifContent(it, cookie1) }
val notifs2 = (5L..10L).map { notifContent(it, cookie2) }
db.cookieDao().save(cookie1)
db.cookieDao().save(cookie2)
dao.saveNotifications(NOTIF_CHANNEL_GENERAL, notifs1)
dao.saveNotifications(NOTIF_CHANNEL_MESSAGES, notifs2)
assertEquals(
emptyList(),
dao.selectNotifications(cookie1.id, NOTIF_CHANNEL_MESSAGES),
"Filtering by type did not work for cookie1"
)
assertEquals(
notifs1.sortedByDescending { it.timestamp },
dao.selectNotifications(cookie1.id, NOTIF_CHANNEL_GENERAL),
"Selection for cookie1 failed"
)
assertEquals(
emptyList(),
dao.selectNotifications(cookie2.id, NOTIF_CHANNEL_GENERAL),
"Filtering by type did not work for cookie2"
)
assertEquals(
notifs2.sortedByDescending { it.timestamp },
dao.selectNotifications(cookie2.id, NOTIF_CHANNEL_MESSAGES),
"Selection for cookie2 failed"
)
}
}
/**
* Primary key is both id and userId, in the event that the same notification to multiple users has the same id
*/
@Test
fun primaryKeyCheck() {
runBlocking {
val cookie1 = cookie(12345L)
val cookie2 = cookie(12L)
val notifs1 = (0L..2L).map { notifContent(it, cookie1) }
val notifs2 = notifs1.map { it.copy(data = cookie2) }
db.cookieDao().save(cookie1)
db.cookieDao().save(cookie2)
assertTrue(dao.saveNotifications(NOTIF_CHANNEL_GENERAL, notifs1), "Notif1 save failed")
assertTrue(dao.saveNotifications(NOTIF_CHANNEL_GENERAL, notifs2), "Notif2 save failed")
}
}
@Test
fun cascadeDeletion() {
val cookie = cookie(12345L)
// Unique unsorted ids
val notifs = listOf(0L, 4L, 2L, 6L, 99L, 3L).map { notifContent(it, cookie) }
runBlocking {
db.cookieDao().save(cookie)
dao.saveNotifications(NOTIF_CHANNEL_GENERAL, notifs)
db.cookieDao().deleteById(cookie.id)
val dbNotifs = dao.selectNotifications(cookie.id, NOTIF_CHANNEL_GENERAL)
assertTrue(dbNotifs.isEmpty(), "Cascade deletion failed")
}
}
@Test
fun latestEpoch() {
val cookie = cookie(12345L)
// Unique unsorted ids
val notifs = listOf(0L, 4L, 2L, 6L, 99L, 3L).map { notifContent(it, cookie) }
runBlocking {
assertEquals(-1L, dao.latestEpoch(cookie.id, NOTIF_CHANNEL_GENERAL), "Default epoch failed")
db.cookieDao().save(cookie)
dao.saveNotifications(NOTIF_CHANNEL_GENERAL, notifs)
assertEquals(99L, dao.latestEpoch(cookie.id, NOTIF_CHANNEL_GENERAL), "Latest epoch failed")
}
}
@Test
fun insertionWithInvalidCookies() {
runBlocking {
assertFalse(
dao.saveNotifications(NOTIF_CHANNEL_GENERAL, listOf(notifContent(1L, cookie(2L)))),
"Notif save should not have passed without relevant cookie entries"
)
}
}
}

View File

@ -173,6 +173,20 @@
</intent-filter>
</receiver>
<!--Widgets-->
<receiver android:name=".widgets.NotificationWidget">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/notification_widget_info" />
</receiver>
<service
android:name=".widgets.NotificationWidgetService"
android:enabled="true"
android:permission="android.permission.BIND_REMOTEVIEWS" />
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.provider"

View File

@ -29,9 +29,10 @@ import com.bumptech.glide.request.RequestOptions
import com.bumptech.glide.signature.ApplicationVersionSignature
import com.mikepenz.materialdrawer.util.AbstractDrawerImageLoader
import com.mikepenz.materialdrawer.util.DrawerImageLoader
import com.pitchedapps.frost.dbflow.CookiesDb
import com.pitchedapps.frost.dbflow.FbTabsDb
import com.pitchedapps.frost.dbflow.NotificationDb
import com.pitchedapps.frost.db.CookiesDb
import com.pitchedapps.frost.db.FbTabsDb
import com.pitchedapps.frost.db.FrostDatabase
import com.pitchedapps.frost.db.NotificationDb
import com.pitchedapps.frost.glide.GlideApp
import com.pitchedapps.frost.services.scheduleNotifications
import com.pitchedapps.frost.services.setupNotificationChannels
@ -44,6 +45,7 @@ import com.raizlabs.android.dbflow.config.DatabaseConfig
import com.raizlabs.android.dbflow.config.FlowConfig
import com.raizlabs.android.dbflow.config.FlowManager
import com.raizlabs.android.dbflow.runtime.ContentResolverNotifier
import org.koin.android.ext.android.startKoin
import java.util.Random
import kotlin.reflect.KClass
@ -132,6 +134,7 @@ class FrostApp : Application() {
L.d { "Activity ${activity.localClassName} created" }
}
})
startKoin(this, listOf(FrostDatabase.module(this)))
}
private fun initBugsnag() {

View File

@ -32,17 +32,27 @@ import com.mikepenz.google_material_typeface_library.GoogleMaterial
import com.pitchedapps.frost.activities.LoginActivity
import com.pitchedapps.frost.activities.MainActivity
import com.pitchedapps.frost.activities.SelectorActivity
import com.pitchedapps.frost.dbflow.CookieModel
import com.pitchedapps.frost.dbflow.loadFbCookiesSync
import com.pitchedapps.frost.db.CookieDao
import com.pitchedapps.frost.db.CookieEntity
import com.pitchedapps.frost.db.CookieModel
import com.pitchedapps.frost.db.FbTabModel
import com.pitchedapps.frost.db.GenericDao
import com.pitchedapps.frost.db.getTabs
import com.pitchedapps.frost.db.save
import com.pitchedapps.frost.db.saveTabs
import com.pitchedapps.frost.db.selectAll
import com.pitchedapps.frost.facebook.FbCookie
import com.pitchedapps.frost.utils.EXTRA_COOKIES
import com.pitchedapps.frost.utils.L
import com.pitchedapps.frost.utils.Prefs
import com.pitchedapps.frost.utils.launchNewTask
import com.pitchedapps.frost.utils.loadAssets
import com.raizlabs.android.dbflow.kotlinextensions.from
import com.raizlabs.android.dbflow.kotlinextensions.select
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.koin.android.ext.android.inject
import java.util.ArrayList
/**
@ -50,6 +60,9 @@ import java.util.ArrayList
*/
class StartActivity : KauBaseActivity() {
private val cookieDao: CookieDao by inject()
private val genericDao: GenericDao by inject()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@ -67,12 +80,11 @@ class StartActivity : KauBaseActivity() {
launch {
try {
migrate()
FbCookie.switchBackUser()
val cookies = ArrayList(withContext(Dispatchers.IO) {
loadFbCookiesSync()
})
val cookies = ArrayList(cookieDao.selectAll())
L.i { "Cookies loaded at time ${System.currentTimeMillis()}" }
L._d { "Cookies: ${cookies.joinToString("\t", transform = CookieModel::toSensitiveString)}" }
L._d { "Cookies: ${cookies.joinToString("\t", transform = CookieEntity::toSensitiveString)}" }
loadAssets()
when {
cookies.isEmpty() -> launchNewTask<LoginActivity>()
@ -85,11 +97,32 @@ class StartActivity : KauBaseActivity() {
})
}
} catch (e: Exception) {
L._e(e) { "Load start failed" }
showInvalidWebView()
}
}
}
/**
* Migrate from dbflow to room
* TODO delete dbflow data
*/
private suspend fun migrate() = withContext(Dispatchers.IO) {
if (cookieDao.selectAll().isNotEmpty()) return@withContext
val cookies = (select from CookieModel::class).queryList().map { CookieEntity(it.id, it.name, it.cookie) }
if (cookies.isNotEmpty()) {
cookieDao.save(cookies)
L._d { "Migrated cookies ${cookieDao.selectAll()}" }
}
val tabs = (select from FbTabModel::class).queryList().map(FbTabModel::tab)
if (tabs.isNotEmpty()) {
genericDao.saveTabs(tabs)
L._d { "Migrated tabs ${genericDao.getTabs()}" }
}
deleteDatabase("Cookies.db")
deleteDatabase("FrostTabs.db")
}
private fun showInvalidWebView() =
showInvalidView(R.string.error_webview)

View File

@ -69,9 +69,10 @@ import com.pitchedapps.frost.contracts.FileChooserContract
import com.pitchedapps.frost.contracts.FileChooserDelegate
import com.pitchedapps.frost.contracts.MainActivityContract
import com.pitchedapps.frost.contracts.VideoViewHolder
import com.pitchedapps.frost.dbflow.TAB_COUNT
import com.pitchedapps.frost.dbflow.loadFbCookie
import com.pitchedapps.frost.dbflow.loadFbTabs
import com.pitchedapps.frost.db.CookieDao
import com.pitchedapps.frost.db.GenericDao
import com.pitchedapps.frost.db.currentCookie
import com.pitchedapps.frost.db.getTabs
import com.pitchedapps.frost.enums.MainActivityLayout
import com.pitchedapps.frost.facebook.FbCookie
import com.pitchedapps.frost.facebook.FbItem
@ -103,12 +104,14 @@ import com.pitchedapps.frost.utils.setFrostColors
import com.pitchedapps.frost.views.BadgedIcon
import com.pitchedapps.frost.views.FrostVideoViewer
import com.pitchedapps.frost.views.FrostViewPager
import com.pitchedapps.frost.widgets.NotificationWidget
import kotlinx.android.synthetic.main.activity_frame_wrapper.*
import kotlinx.android.synthetic.main.view_main_fab.*
import kotlinx.android.synthetic.main.view_main_toolbar.*
import kotlinx.android.synthetic.main.view_main_viewpager.*
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.launch
import org.koin.android.ext.android.inject
/**
* Created by Allan Wang on 20/12/17.
@ -120,9 +123,14 @@ abstract class BaseMainActivity : BaseActivity(), MainActivityContract,
FileChooserContract by FileChooserDelegate(),
VideoViewHolder, SearchViewHolder {
protected lateinit var adapter: SectionsPagerAdapter
/**
* Note that tabs themselves are initialized through a coroutine during onCreate
*/
protected val adapter: SectionsPagerAdapter = SectionsPagerAdapter()
override val frameWrapper: FrameLayout get() = frame_wrapper
val viewPager: FrostViewPager get() = container
val cookieDao: CookieDao by inject()
val genericDao: GenericDao by inject()
/*
* Components with the same id in multiple layout files
@ -131,6 +139,8 @@ abstract class BaseMainActivity : BaseActivity(), MainActivityContract,
val appBar: AppBarLayout by bindView(R.id.appbar)
val coordinator: CoordinatorLayout by bindView(R.id.main_content)
protected var lastPosition = -1
override var videoViewer: FrostVideoViewer? = null
private lateinit var drawer: Drawer
private lateinit var drawerHeader: AccountHeader
@ -151,12 +161,13 @@ abstract class BaseMainActivity : BaseActivity(), MainActivityContract,
background(viewPager)
}
setSupportActionBar(toolbar)
adapter = SectionsPagerAdapter(loadFbTabs())
viewPager.adapter = adapter
viewPager.offscreenPageLimit = TAB_COUNT
tabs.setBackgroundColor(Prefs.mainActivityLayout.backgroundColor())
onNestedCreate(savedInstanceState)
L.i { "Main finished loading UI in ${System.currentTimeMillis() - start} ms" }
launch {
adapter.setPages(genericDao.getTabs())
}
controlWebview = WebView(this)
if (BuildConfig.VERSION_CODE > Prefs.versionCode) {
Prefs.prevVersionCode = Prefs.versionCode
@ -274,36 +285,37 @@ abstract class BaseMainActivity : BaseActivity(), MainActivityContract,
if (current) launchWebOverlay(FbItem.PROFILE.url)
else when (profile.identifier) {
-2L -> {
val currentCookie = loadFbCookie(Prefs.userId)
// TODO no backpressure support
this@BaseMainActivity.launch {
val currentCookie = cookieDao.currentCookie()
if (currentCookie == null) {
toast(R.string.account_not_found)
launch {
FbCookie.reset()
launchLogin(cookies(), true)
}
} else {
materialDialogThemed {
title(R.string.kau_logout)
content(
String.format(
string(R.string.kau_logout_confirm_as_x), currentCookie.name
?: Prefs.userId.toString()
string(R.string.kau_logout_confirm_as_x),
currentCookie.name ?: Prefs.userId.toString()
)
)
positiveText(R.string.kau_yes)
negativeText(R.string.kau_no)
onPositive { _, _ ->
launch {
this@BaseMainActivity.launch {
FbCookie.logout(this@BaseMainActivity)
}
}
}
}
}
}
-3L -> launchNewTask<LoginActivity>(clearStack = false)
-4L -> launchNewTask<SelectorActivity>(cookies(), false)
else -> {
launch {
this@BaseMainActivity.launch {
FbCookie.switchUser(profile.identifier)
tabsForEachView { _, view -> view.badgeText = null }
refreshAll()
@ -439,7 +451,11 @@ abstract class BaseMainActivity : BaseActivity(), MainActivityContract,
Runtime.getRuntime().exit(0)
return
}
if (resultCode and REQUEST_RESTART > 0) return restart()
if (resultCode and REQUEST_RESTART > 0) {
NotificationWidget.forceUpdate(this)
restart()
return
}
/*
* These results can be stacked
*/
@ -454,16 +470,12 @@ abstract class BaseMainActivity : BaseActivity(), MainActivityContract,
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
outState.putStringArrayList(STATE_FORCE_FALLBACK, ArrayList(adapter.forcedFallbacks))
adapter.saveInstanceState(outState)
}
override fun onRestoreInstanceState(savedInstanceState: Bundle) {
super.onRestoreInstanceState(savedInstanceState)
adapter.forcedFallbacks.clear()
adapter.forcedFallbacks.addAll(
savedInstanceState.getStringArrayList(STATE_FORCE_FALLBACK)
?: emptyList()
)
adapter.restoreInstanceState(savedInstanceState)
}
override fun onResume() {
@ -518,9 +530,48 @@ abstract class BaseMainActivity : BaseActivity(), MainActivityContract,
runOnUiThread { adapter.reloadFragment(fragment) }
}
inner class SectionsPagerAdapter(val pages: List<FbItem>) : FragmentPagerAdapter(supportFragmentManager) {
inner class SectionsPagerAdapter : FragmentPagerAdapter(supportFragmentManager) {
val forcedFallbacks = mutableSetOf<String>()
private val pages: MutableList<FbItem> = mutableListOf()
private val forcedFallbacks = mutableSetOf<String>()
/**
* Update page list and prompt reload
*/
fun setPages(pages: List<FbItem>) {
this.pages.clear()
this.pages.addAll(pages)
notifyDataSetChanged()
tabs.removeAllTabs()
this.pages.forEachIndexed { index, fbItem ->
tabs.addTab(
tabs.newTab()
.setCustomView(BadgedIcon(this@BaseMainActivity).apply { iicon = fbItem.icon }.also {
it.setAllAlpha(if (index == 0) SELECTED_TAB_ALPHA else UNSELECTED_TAB_ALPHA)
})
)
}
lastPosition = 0
viewPager.setCurrentItem(0, false)
viewPager.offscreenPageLimit = pages.size
viewPager.post {
if (!fragmentChannel.isClosedForSend)
fragmentChannel.offer(0)
} //trigger hook so title is set
}
fun saveInstanceState(outState: Bundle) {
outState.putStringArrayList(STATE_FORCE_FALLBACK, ArrayList(forcedFallbacks))
}
fun restoreInstanceState(savedInstanceState: Bundle) {
forcedFallbacks.clear()
forcedFallbacks.addAll(
savedInstanceState.getStringArrayList(STATE_FORCE_FALLBACK)
?: emptyList()
)
}
fun reloadFragment(fragment: BaseFragment) {
if (fragment is WebFragment) return
@ -559,4 +610,9 @@ abstract class BaseMainActivity : BaseActivity(), MainActivityContract,
PointF(0f, toolbar.height.toFloat())
else
PointF(0f, 0f)
companion object {
const val SELECTED_TAB_ALPHA = 255f
const val UNSELECTED_TAB_ALPHA = 128f
}
}

View File

@ -32,9 +32,10 @@ import com.bumptech.glide.load.engine.GlideException
import com.bumptech.glide.request.RequestListener
import com.bumptech.glide.request.target.Target
import com.pitchedapps.frost.R
import com.pitchedapps.frost.dbflow.CookieModel
import com.pitchedapps.frost.dbflow.loadFbCookiesSuspend
import com.pitchedapps.frost.dbflow.saveFbCookie
import com.pitchedapps.frost.db.CookieDao
import com.pitchedapps.frost.db.CookieEntity
import com.pitchedapps.frost.db.save
import com.pitchedapps.frost.db.selectAll
import com.pitchedapps.frost.facebook.FbCookie
import com.pitchedapps.frost.facebook.FbItem
import com.pitchedapps.frost.facebook.profilePictureUrl
@ -58,6 +59,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import org.koin.android.ext.android.inject
import java.net.UnknownHostException
import kotlin.coroutines.resume
@ -71,6 +73,7 @@ class LoginActivity : BaseActivity() {
private val swipeRefresh: SwipeRefreshLayout by bindView(R.id.swipe_refresh)
private val textview: AppCompatTextView by bindView(R.id.textview)
private val profile: ImageView by bindView(R.id.profile)
private val cookieDao: CookieDao by inject()
private lateinit var profileLoader: RequestManager
private val refreshChannel = Channel<Boolean>(10)
@ -109,13 +112,13 @@ class LoginActivity : BaseActivity() {
refreshChannel.offer(refreshing)
}
private suspend fun loadInfo(cookie: CookieModel): Unit = withMainContext {
private suspend fun loadInfo(cookie: CookieEntity): Unit = withMainContext {
refresh(true)
val imageDeferred = async { loadProfile(cookie.id) }
val nameDeferred = async { loadUsername(cookie) }
val name: String = nameDeferred.await()
val name: String? = nameDeferred.await()
val foundImage: Boolean = imageDeferred.await()
L._d { "Logged in and received data" }
@ -126,7 +129,7 @@ class LoginActivity : BaseActivity() {
L._i { cookie }
}
textview.text = String.format(getString(R.string.welcome), name)
textview.text = String.format(getString(R.string.welcome), name ?: "")
textview.fadeIn()
frostEvent("Login", "success" to true)
@ -134,7 +137,7 @@ class LoginActivity : BaseActivity() {
* The user may have logged into an account that is already in the database
* We will let the db handle duplicates and load it now after the new account has been saved
*/
val cookies = ArrayList(loadFbCookiesSuspend())
val cookies = ArrayList(cookieDao.selectAll())
delay(1000)
if (Showcase.intro)
launchNewTask<IntroActivity>(cookies, true)
@ -171,23 +174,23 @@ class LoginActivity : BaseActivity() {
}
}
private suspend fun loadUsername(cookie: CookieModel): String = withContext(Dispatchers.IO) {
val result: String = try {
private suspend fun loadUsername(cookie: CookieEntity): String? = withContext(Dispatchers.IO) {
val result: String? = try {
withTimeout(5000) {
frostJsoup(cookie.cookie, FbItem.PROFILE.url).title()
}
} catch (e: Exception) {
if (e !is UnknownHostException)
e.logFrostEvent("Fetch username failed")
""
null
}
if (cookie.name?.isNotBlank() == false && result != cookie.name) {
cookie.name = result
saveFbCookie(cookie)
if (result != null) {
cookieDao.save(cookie.copy(name = result))
return@withContext result
}
cookie.name ?: ""
return@withContext cookie.name
}
override fun backConsumer(): Boolean {

View File

@ -35,7 +35,6 @@ class MainActivity : BaseMainActivity() {
override val fragmentChannel = BroadcastChannel<Int>(10)
override val headerBadgeChannel = BroadcastChannel<String>(Channel.CONFLATED)
var lastPosition = -1
override fun onNestedCreate(savedInstanceState: Bundle?) {
setupTabs()
@ -54,23 +53,18 @@ class MainActivity : BaseMainActivity() {
override fun onPageScrolled(position: Int, positionOffset: Float, positionOffsetPixels: Int) {
super.onPageScrolled(position, positionOffset, positionOffsetPixels)
val delta = positionOffset * (255 - 128).toFloat()
val delta = positionOffset * (SELECTED_TAB_ALPHA - UNSELECTED_TAB_ALPHA)
tabsForEachView { tabPosition, view ->
view.setAllAlpha(
when (tabPosition) {
position -> 255.0f - delta
position + 1 -> 128.0f + delta
else -> 128f
position -> SELECTED_TAB_ALPHA - delta
position + 1 -> UNSELECTED_TAB_ALPHA + delta
else -> UNSELECTED_TAB_ALPHA
}
)
}
}
})
viewPager.post {
if (!fragmentChannel.isClosedForSend)
fragmentChannel.offer(0)
lastPosition = 0
} //trigger hook so title is set
}
private fun setupTabs() {
@ -86,8 +80,7 @@ class MainActivity : BaseMainActivity() {
(tab.customView as BadgedIcon).badgeText = null
}
})
headerBadgeChannel.subscribeDuringJob(this, Dispatchers.IO) {
html ->
headerBadgeChannel.subscribeDuringJob(this, Dispatchers.IO) { html ->
try {
val doc = Jsoup.parse(html)
if (doc.select("[data-sigil=count]").isEmpty())
@ -116,11 +109,5 @@ class MainActivity : BaseMainActivity() {
L.e(e) { "Header badge error" }
}
}
adapter.pages.forEach {
tabs.addTab(
tabs.newTab()
.setCustomView(BadgedIcon(this).apply { iicon = it.icon })
)
}
}
}

View File

@ -25,6 +25,7 @@ import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.RecyclerView
import ca.allanwang.kau.kotlin.lazyContext
import ca.allanwang.kau.utils.launchMain
import ca.allanwang.kau.utils.scaleXY
import ca.allanwang.kau.utils.setIcon
import ca.allanwang.kau.utils.withAlpha
@ -33,14 +34,19 @@ import com.mikepenz.fastadapter_extensions.drag.ItemTouchCallback
import com.mikepenz.fastadapter_extensions.drag.SimpleDragCallback
import com.mikepenz.google_material_typeface_library.GoogleMaterial
import com.pitchedapps.frost.R
import com.pitchedapps.frost.dbflow.TAB_COUNT
import com.pitchedapps.frost.dbflow.loadFbTabs
import com.pitchedapps.frost.dbflow.save
import com.pitchedapps.frost.db.GenericDao
import com.pitchedapps.frost.db.TAB_COUNT
import com.pitchedapps.frost.db.getTabs
import com.pitchedapps.frost.db.saveTabs
import com.pitchedapps.frost.facebook.FbItem
import com.pitchedapps.frost.iitems.TabIItem
import com.pitchedapps.frost.utils.L
import com.pitchedapps.frost.utils.Prefs
import com.pitchedapps.frost.utils.setFrostColors
import kotlinx.android.synthetic.main.activity_tab_customizer.*
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.launch
import org.koin.android.ext.android.inject
import java.util.Collections
/**
@ -48,6 +54,8 @@ import java.util.Collections
*/
class TabCustomizerActivity : BaseActivity() {
private val genericDao: GenericDao by inject()
private val adapter = FastItemAdapter<TabIItem>()
private val wobble = lazyContext { AnimationUtils.loadAnimation(it, R.anim.rotate_delta) }
@ -65,25 +73,31 @@ class TabCustomizerActivity : BaseActivity() {
divider.setBackgroundColor(Prefs.textColor.withAlpha(30))
instructions.setTextColor(Prefs.textColor)
val tabs = loadFbTabs().toMutableList()
launch {
val tabs = genericDao.getTabs().toMutableList()
L.d { "Tabs $tabs" }
val remaining = FbItem.values().filter { it.name[0] != '_' }.toMutableList()
remaining.removeAll(tabs)
tabs.addAll(remaining)
adapter.set(tabs.map(::TabIItem))
adapter.add(tabs.map(::TabIItem))
bindSwapper(adapter, tab_recycler)
adapter.withOnClickListener { view, _, _, _ -> view!!.wobble(); true }
}
setResult(Activity.RESULT_CANCELED)
fab_save.setIcon(GoogleMaterial.Icon.gmd_check, Prefs.iconColor)
fab_save.backgroundTintList = ColorStateList.valueOf(Prefs.accentColor)
fab_save.setOnClickListener {
adapter.adapterItems.subList(0, TAB_COUNT).map(TabIItem::item).save()
launchMain(NonCancellable) {
val tabs = adapter.adapterItems.subList(0, TAB_COUNT).map(TabIItem::item)
genericDao.saveTabs(tabs)
setResult(Activity.RESULT_OK)
finish()
}
}
fab_cancel.setIcon(GoogleMaterial.Icon.gmd_close, Prefs.iconColor)
fab_cancel.backgroundTintList = ColorStateList.valueOf(Prefs.accentColor)
fab_cancel.setOnClickListener { finish() }

View File

@ -0,0 +1,86 @@
/*
* Copyright 2018 Allan Wang
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package com.pitchedapps.frost.db
import android.os.Parcelable
import androidx.room.Dao
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import com.pitchedapps.frost.utils.L
import kotlinx.android.parcel.Parcelize
/**
* Created by Allan Wang on 2017-05-30.
*/
/**
* Generic cache to store serialized content
*/
@Entity(
tableName = "frost_cache",
primaryKeys = ["id", "type"],
foreignKeys = [ForeignKey(
entity = CookieEntity::class,
parentColumns = ["cookie_id"],
childColumns = ["id"],
onDelete = ForeignKey.CASCADE
)]
)
@Parcelize
data class CacheEntity(
val id: Long,
val type: String,
val lastUpdated: Long,
val contents: String
) : Parcelable
@Dao
interface CacheDao {
@Query("SELECT * FROM frost_cache WHERE id = :id AND type = :type")
fun _select(id: Long, type: String): CacheEntity?
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun _insertCache(cache: CacheEntity)
@Query("DELETE FROM frost_cache WHERE id = :id AND type = :type")
fun _delete(id: Long, type: String)
}
suspend fun CacheDao.select(id: Long, type: String) = dao {
_select(id, type)
}
suspend fun CacheDao.delete(id: Long, type: String) = dao {
_delete(id, type)
}
/**
* Returns true if successful, given that there are constraints to the insertion
*/
suspend fun CacheDao.save(id: Long, type: String, contents: String): Boolean = dao {
try {
_insertCache(CacheEntity(id, type, System.currentTimeMillis(), contents))
true
} catch (e: Exception) {
L.e(e) { "Cache save failed for $type" }
false
}
}

View File

@ -0,0 +1,90 @@
/*
* Copyright 2018 Allan Wang
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package com.pitchedapps.frost.db
import android.os.Parcelable
import androidx.room.ColumnInfo
import androidx.room.Dao
import androidx.room.Entity
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import com.pitchedapps.frost.utils.Prefs
import com.raizlabs.android.dbflow.annotation.ConflictAction
import com.raizlabs.android.dbflow.annotation.Database
import com.raizlabs.android.dbflow.annotation.PrimaryKey
import com.raizlabs.android.dbflow.annotation.Table
import com.raizlabs.android.dbflow.structure.BaseModel
import kotlinx.android.parcel.Parcelize
/**
* Created by Allan Wang on 2017-05-30.
*/
@Entity(tableName = "cookies")
@Parcelize
data class CookieEntity(
@androidx.room.PrimaryKey
@ColumnInfo(name = "cookie_id")
val id: Long,
val name: String?,
val cookie: String?
) : Parcelable {
override fun toString(): String = "CookieEntity(${hashCode()})"
fun toSensitiveString(): String = "CookieEntity(id=$id, name=$name, cookie=$cookie)"
}
@Dao
interface CookieDao {
@Query("SELECT * FROM cookies")
fun _selectAll(): List<CookieEntity>
@Query("SELECT * FROM cookies WHERE cookie_id = :id")
fun _selectById(id: Long): CookieEntity?
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun _save(cookie: CookieEntity)
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun _save(cookies: List<CookieEntity>)
@Query("DELETE FROM cookies WHERE cookie_id = :id")
fun _deleteById(id: Long)
}
suspend fun CookieDao.selectAll() = dao { _selectAll() }
suspend fun CookieDao.selectById(id: Long) = dao { _selectById(id) }
suspend fun CookieDao.save(cookie: CookieEntity) = dao { _save(cookie) }
suspend fun CookieDao.save(cookies: List<CookieEntity>) = dao { _save(cookies) }
suspend fun CookieDao.deleteById(id: Long) = dao { _deleteById(id) }
suspend fun CookieDao.currentCookie() = selectById(Prefs.userId)
@Database(version = CookiesDb.VERSION)
object CookiesDb {
const val NAME = "Cookies"
const val VERSION = 2
}
@Parcelize
@Table(database = CookiesDb::class, allFields = true, primaryKeyConflict = ConflictAction.REPLACE)
data class CookieModel(@PrimaryKey var id: Long = -1L, var name: String? = null, var cookie: String? = null) :
BaseModel(), Parcelable {
override fun toString(): String = "CookieModel(${hashCode()})"
}

View File

@ -0,0 +1,28 @@
/*
* Copyright 2019 Allan Wang
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package com.pitchedapps.frost.db
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
/**
* Wraps dao calls to work with coroutines
* Non transactional queries were supposed to be fixed in https://issuetracker.google.com/issues/69474692,
* but it still requires dispatch from a non ui thread.
* This avoids that constraint
*/
suspend inline fun <T> dao(crossinline block: () -> T) = withContext(Dispatchers.IO) { block() }

View File

@ -0,0 +1,106 @@
/*
* Copyright 2019 Allan Wang
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package com.pitchedapps.frost.db
import android.content.Context
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
import com.pitchedapps.frost.BuildConfig
import org.koin.dsl.module.module
import org.koin.standalone.StandAloneContext
interface FrostPrivateDao {
fun cookieDao(): CookieDao
fun notifDao(): NotificationDao
fun cacheDao(): CacheDao
}
@Database(
entities = [CookieEntity::class, NotificationEntity::class, CacheEntity::class],
version = 1,
exportSchema = true
)
abstract class FrostPrivateDatabase : RoomDatabase(), FrostPrivateDao {
companion object {
const val DATABASE_NAME = "frost-priv-db"
}
}
interface FrostPublicDao {
fun genericDao(): GenericDao
}
@Database(entities = [GenericEntity::class], version = 1, exportSchema = true)
abstract class FrostPublicDatabase : RoomDatabase(), FrostPublicDao {
companion object {
const val DATABASE_NAME = "frost-db"
}
}
interface FrostDao : FrostPrivateDao, FrostPublicDao {
fun close()
}
/**
* Composition of all database interfaces
*/
class FrostDatabase(private val privateDb: FrostPrivateDatabase, private val publicDb: FrostPublicDatabase) :
FrostDao,
FrostPrivateDao by privateDb,
FrostPublicDao by publicDb {
override fun close() {
privateDb.close()
publicDb.close()
}
companion object {
private fun <T : RoomDatabase> RoomDatabase.Builder<T>.frostBuild() = if (BuildConfig.DEBUG) {
fallbackToDestructiveMigration().build()
} else {
build()
}
fun create(context: Context): FrostDatabase {
val privateDb = Room.databaseBuilder(
context, FrostPrivateDatabase::class.java,
FrostPrivateDatabase.DATABASE_NAME
).frostBuild()
val publicDb = Room.databaseBuilder(
context, FrostPublicDatabase::class.java,
FrostPublicDatabase.DATABASE_NAME
).frostBuild()
return FrostDatabase(privateDb, publicDb)
}
fun module(context: Context) = module {
single { create(context) }
single { get<FrostDatabase>().cookieDao() }
single { get<FrostDatabase>().cacheDao() }
single { get<FrostDatabase>().notifDao() }
single { get<FrostDatabase>().genericDao() }
}
/**
* Get from koin
* For the most part, you can retrieve directly from other koin components
*/
fun get(): FrostDatabase = StandAloneContext.getKoin().koinContext.get()
}
}

View File

@ -14,23 +14,18 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package com.pitchedapps.frost.dbflow
package com.pitchedapps.frost.db
import com.pitchedapps.frost.facebook.FbItem
import com.pitchedapps.frost.facebook.defaultTabs
import com.pitchedapps.frost.utils.L
import com.raizlabs.android.dbflow.annotation.Database
import com.raizlabs.android.dbflow.annotation.PrimaryKey
import com.raizlabs.android.dbflow.annotation.Table
import com.raizlabs.android.dbflow.kotlinextensions.database
import com.raizlabs.android.dbflow.kotlinextensions.fastSave
import com.raizlabs.android.dbflow.kotlinextensions.from
import com.raizlabs.android.dbflow.kotlinextensions.select
import com.raizlabs.android.dbflow.structure.BaseModel
/**
* Created by Allan Wang on 2017-05-30.
*/
const val TAB_COUNT = 4
@Database(version = FbTabsDb.VERSION)
@ -41,18 +36,3 @@ object FbTabsDb {
@Table(database = FbTabsDb::class, allFields = true)
data class FbTabModel(@PrimaryKey var position: Int = -1, var tab: FbItem = FbItem.FEED) : BaseModel()
/**
* Load tabs synchronously
* Note that tab length should never be a big number anyways
*/
fun loadFbTabs(): List<FbItem> {
val tabs: List<FbTabModel>? = (select from (FbTabModel::class)).orderBy(FbTabModel_Table.position, true).queryList()
if (tabs?.size == TAB_COUNT) return tabs.map(FbTabModel::tab)
L.d { "No tabs (${tabs?.size}); loading default" }
return defaultTabs()
}
fun List<FbItem>.save() {
database<FbTabsDb>().beginTransactionAsync(mapIndexed(::FbTabModel).fastSave().build()).execute()
}

View File

@ -0,0 +1,71 @@
/*
* Copyright 2018 Allan Wang
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package com.pitchedapps.frost.db
import androidx.room.Dao
import androidx.room.Entity
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.PrimaryKey
import androidx.room.Query
import com.pitchedapps.frost.facebook.FbItem
import com.pitchedapps.frost.facebook.defaultTabs
/**
* Created by Allan Wang on 2017-05-30.
*/
/**
* Generic cache to store serialized content
*/
@Entity(tableName = "frost_generic")
data class GenericEntity(
@PrimaryKey
val type: String,
val contents: String
)
@Dao
interface GenericDao {
@Query("SELECT contents FROM frost_generic WHERE type = :type")
fun _select(type: String): String?
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun _save(entity: GenericEntity)
@Query("DELETE FROM frost_generic WHERE type = :type")
fun _delete(type: String)
companion object {
const val TYPE_TABS = "generic_tabs"
}
}
suspend fun GenericDao.saveTabs(tabs: List<FbItem>) = dao {
val content = tabs.joinToString(",") { it.name }
_save(GenericEntity(GenericDao.TYPE_TABS, content))
}
suspend fun GenericDao.getTabs(): List<FbItem> = dao {
val allTabs = FbItem.values.map { it.name to it }.toMap()
_select(GenericDao.TYPE_TABS)
?.split(",")
?.mapNotNull { allTabs[it] }
?.takeIf { it.isNotEmpty() }
?: defaultTabs()
}

View File

@ -0,0 +1,202 @@
/*
* Copyright 2018 Allan Wang
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package com.pitchedapps.frost.db
import androidx.room.ColumnInfo
import androidx.room.Dao
import androidx.room.Embedded
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Transaction
import com.pitchedapps.frost.services.NOTIF_CHANNEL_GENERAL
import com.pitchedapps.frost.services.NOTIF_CHANNEL_MESSAGES
import com.pitchedapps.frost.services.NotificationContent
import com.pitchedapps.frost.utils.L
import com.raizlabs.android.dbflow.annotation.ConflictAction
import com.raizlabs.android.dbflow.annotation.Database
import com.raizlabs.android.dbflow.annotation.Migration
import com.raizlabs.android.dbflow.annotation.PrimaryKey
import com.raizlabs.android.dbflow.annotation.Table
import com.raizlabs.android.dbflow.kotlinextensions.eq
import com.raizlabs.android.dbflow.kotlinextensions.from
import com.raizlabs.android.dbflow.kotlinextensions.select
import com.raizlabs.android.dbflow.kotlinextensions.where
import com.raizlabs.android.dbflow.sql.SQLiteType
import com.raizlabs.android.dbflow.sql.migration.AlterTableMigration
import com.raizlabs.android.dbflow.structure.BaseModel
@Entity(
tableName = "notifications",
primaryKeys = ["notif_id", "userId"],
foreignKeys = [ForeignKey(
entity = CookieEntity::class,
parentColumns = ["cookie_id"],
childColumns = ["userId"],
onDelete = ForeignKey.CASCADE
)],
indices = [Index("notif_id"), Index("userId")]
)
data class NotificationEntity(
@ColumnInfo(name = "notif_id")
val id: Long,
val userId: Long,
val href: String,
val title: String?,
val text: String,
val timestamp: Long,
val profileUrl: String?,
// Type essentially refers to channel
val type: String,
val unread: Boolean
) {
constructor(
type: String,
content: NotificationContent
) : this(
content.id,
content.data.id,
content.href,
content.title,
content.text,
content.timestamp,
content.profileUrl,
type,
content.unread
)
}
data class NotificationContentEntity(
@Embedded
val cookie: CookieEntity,
@Embedded
val notif: NotificationEntity
) {
fun toNotifContent() = NotificationContent(
data = cookie,
id = notif.id,
href = notif.href,
title = notif.title,
text = notif.text,
timestamp = notif.timestamp,
profileUrl = notif.profileUrl,
unread = notif.unread
)
}
@Dao
interface NotificationDao {
/**
* Note that notifications are guaranteed to be ordered by descending timestamp
*/
@Transaction
@Query("SELECT * FROM cookies INNER JOIN notifications ON cookie_id = userId WHERE userId = :userId AND type = :type ORDER BY timestamp DESC")
fun _selectNotifications(userId: Long, type: String): List<NotificationContentEntity>
@Query("SELECT timestamp FROM notifications WHERE userId = :userId AND type = :type ORDER BY timestamp DESC LIMIT 1")
fun _selectEpoch(userId: Long, type: String): Long?
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun _insertNotifications(notifs: List<NotificationEntity>)
@Query("DELETE FROM notifications WHERE userId = :userId AND type = :type")
fun _deleteNotifications(userId: Long, type: String)
@Query("DELETE FROM notifications")
fun _deleteAll()
/**
* It is assumed that the notification batch comes from the same user
*/
@Transaction
fun _saveNotifications(type: String, notifs: List<NotificationContent>) {
val userId = notifs.firstOrNull()?.data?.id ?: return
val entities = notifs.map { NotificationEntity(type, it) }
_deleteNotifications(userId, type)
_insertNotifications(entities)
}
}
suspend fun NotificationDao.deleteAll() = dao { _deleteAll() }
fun NotificationDao.selectNotificationsSync(userId: Long, type: String): List<NotificationContent> =
_selectNotifications(userId, type).map { it.toNotifContent() }
suspend fun NotificationDao.selectNotifications(userId: Long, type: String): List<NotificationContent> = dao {
selectNotificationsSync(userId, type)
}
/**
* Returns true if successful, given that there are constraints to the insertion
*/
suspend fun NotificationDao.saveNotifications(type: String, notifs: List<NotificationContent>): Boolean {
if (notifs.isEmpty()) return true
return dao {
try {
_saveNotifications(type, notifs)
true
} catch (e: Exception) {
L.e(e) { "Notif save failed for $type" }
false
}
}
}
suspend fun NotificationDao.latestEpoch(userId: Long, type: String): Long = dao {
_selectEpoch(userId, type) ?: lastNotificationTime(userId).let {
when (type) {
NOTIF_CHANNEL_GENERAL -> it.epoch
NOTIF_CHANNEL_MESSAGES -> it.epochIm
else -> -1L
}
}
}
/**
* Created by Allan Wang on 2017-05-30.
*/
@Database(version = NotificationDb.VERSION)
object NotificationDb {
const val NAME = "Notifications"
const val VERSION = 2
}
@Migration(version = 2, database = NotificationDb::class)
class NotificationMigration2(modelClass: Class<NotificationModel>) :
AlterTableMigration<NotificationModel>(modelClass) {
override fun onPreMigrate() {
super.onPreMigrate()
addColumn(SQLiteType.INTEGER, "epochIm")
L.d { "Added column" }
}
}
@Table(database = NotificationDb::class, allFields = true, primaryKeyConflict = ConflictAction.REPLACE)
data class NotificationModel(
@PrimaryKey var id: Long = -1L,
var epoch: Long = -1L,
var epochIm: Long = -1L
) : BaseModel()
internal fun lastNotificationTime(id: Long): NotificationModel =
(select from NotificationModel::class where (NotificationModel_Table.id eq id)).querySingle()
?: NotificationModel(id = id)

View File

@ -1,92 +0,0 @@
/*
* Copyright 2018 Allan Wang
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package com.pitchedapps.frost.dbflow
import android.os.Parcelable
import com.pitchedapps.frost.utils.L
import com.raizlabs.android.dbflow.annotation.ConflictAction
import com.raizlabs.android.dbflow.annotation.Database
import com.raizlabs.android.dbflow.annotation.PrimaryKey
import com.raizlabs.android.dbflow.annotation.Table
import com.raizlabs.android.dbflow.kotlinextensions.async
import com.raizlabs.android.dbflow.kotlinextensions.delete
import com.raizlabs.android.dbflow.kotlinextensions.eq
import com.raizlabs.android.dbflow.kotlinextensions.from
import com.raizlabs.android.dbflow.kotlinextensions.save
import com.raizlabs.android.dbflow.kotlinextensions.select
import com.raizlabs.android.dbflow.kotlinextensions.where
import com.raizlabs.android.dbflow.structure.BaseModel
import kotlinx.android.parcel.Parcelize
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
/**
* Created by Allan Wang on 2017-05-30.
*/
@Database(version = CookiesDb.VERSION)
object CookiesDb {
const val NAME = "Cookies"
const val VERSION = 2
}
@Parcelize
@Table(database = CookiesDb::class, allFields = true, primaryKeyConflict = ConflictAction.REPLACE)
data class CookieModel(@PrimaryKey var id: Long = -1L, var name: String? = null, var cookie: String? = null) :
BaseModel(), Parcelable {
override fun toString(): String = "CookieModel(${hashCode()})"
fun toSensitiveString(): String = "CookieModel(id=$id, name=$name, cookie=$cookie)"
}
fun loadFbCookie(id: Long): CookieModel? =
(select from CookieModel::class where (CookieModel_Table.id eq id)).querySingle()
fun loadFbCookie(name: String): CookieModel? =
(select from CookieModel::class where (CookieModel_Table.name eq name)).querySingle()
/**
* Loads cookies sorted by name
*/
fun loadFbCookiesAsync(callback: (cookies: List<CookieModel>) -> Unit) {
(select from CookieModel::class).orderBy(CookieModel_Table.name, true).async()
.queryListResultCallback { _, tResult -> callback(tResult) }.execute()
}
fun loadFbCookiesSync(): List<CookieModel> =
(select from CookieModel::class).orderBy(CookieModel_Table.name, true).queryList()
// TODO temp method until dbflow supports coroutines
suspend fun loadFbCookiesSuspend(): List<CookieModel> = withContext(Dispatchers.IO) {
loadFbCookiesSync()
}
inline fun saveFbCookie(cookie: CookieModel, crossinline callback: (() -> Unit) = {}) {
cookie.async save {
L.d { "Fb cookie saved" }
L._d { cookie.toSensitiveString() }
callback()
}
}
fun removeCookie(id: Long) {
loadFbCookie(id)?.async?.delete {
L.d { "Fb cookie deleted" }
L._d { id }
}
}

View File

@ -1,39 +0,0 @@
/*
* Copyright 2018 Allan Wang
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package com.pitchedapps.frost.dbflow
import android.content.Context
import com.pitchedapps.frost.utils.L
import com.raizlabs.android.dbflow.config.FlowManager
import com.raizlabs.android.dbflow.structure.database.transaction.FastStoreModelTransaction
/**
* Created by Allan Wang on 2017-05-30.
*/
object DbUtils {
fun db(name: String) = FlowManager.getDatabase(name)
fun dbName(name: String) = "$name.db"
fun deleteDatabase(c: Context, name: String) = c.deleteDatabase(dbName(name))
}
inline fun <reified T : Any> List<T>.replace(dbName: String) {
L.d { "Replacing $dbName.db" }
DbUtils.db(dbName).reset()
FastStoreModelTransaction.saveBuilder(FlowManager.getModelAdapter(T::class.java)).addAll(this).build()
}

View File

@ -1,72 +0,0 @@
/*
* Copyright 2018 Allan Wang
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package com.pitchedapps.frost.dbflow
import com.pitchedapps.frost.utils.L
import com.raizlabs.android.dbflow.annotation.ConflictAction
import com.raizlabs.android.dbflow.annotation.Database
import com.raizlabs.android.dbflow.annotation.Migration
import com.raizlabs.android.dbflow.annotation.PrimaryKey
import com.raizlabs.android.dbflow.annotation.Table
import com.raizlabs.android.dbflow.kotlinextensions.async
import com.raizlabs.android.dbflow.kotlinextensions.eq
import com.raizlabs.android.dbflow.kotlinextensions.from
import com.raizlabs.android.dbflow.kotlinextensions.save
import com.raizlabs.android.dbflow.kotlinextensions.select
import com.raizlabs.android.dbflow.kotlinextensions.where
import com.raizlabs.android.dbflow.sql.SQLiteType
import com.raizlabs.android.dbflow.sql.migration.AlterTableMigration
import com.raizlabs.android.dbflow.structure.BaseModel
/**
* Created by Allan Wang on 2017-05-30.
*/
@Database(version = NotificationDb.VERSION)
object NotificationDb {
const val NAME = "Notifications"
const val VERSION = 2
}
@Migration(version = 2, database = NotificationDb::class)
class NotificationMigration2(modelClass: Class<NotificationModel>) :
AlterTableMigration<NotificationModel>(modelClass) {
override fun onPreMigrate() {
super.onPreMigrate()
addColumn(SQLiteType.INTEGER, "epochIm")
L.d { "Added column" }
}
}
@Table(database = NotificationDb::class, allFields = true, primaryKeyConflict = ConflictAction.REPLACE)
data class NotificationModel(
@PrimaryKey var id: Long = -1L,
var epoch: Long = -1L,
var epochIm: Long = -1L
) : BaseModel()
fun lastNotificationTime(id: Long): NotificationModel =
(select from NotificationModel::class where (NotificationModel_Table.id eq id)).querySingle()
?: NotificationModel(id = id)
fun saveNotificationTime(notificationModel: NotificationModel, callback: (() -> Unit)? = null) {
notificationModel.async save {
L.d { "Fb notification model saved" }
L._d { notificationModel }
callback?.invoke()
}
}

View File

@ -19,10 +19,12 @@ package com.pitchedapps.frost.facebook
import android.app.Activity
import android.content.Context
import android.webkit.CookieManager
import com.pitchedapps.frost.dbflow.CookieModel
import com.pitchedapps.frost.dbflow.loadFbCookie
import com.pitchedapps.frost.dbflow.removeCookie
import com.pitchedapps.frost.dbflow.saveFbCookie
import com.pitchedapps.frost.db.CookieDao
import com.pitchedapps.frost.db.CookieEntity
import com.pitchedapps.frost.db.FrostDatabase
import com.pitchedapps.frost.db.deleteById
import com.pitchedapps.frost.db.save
import com.pitchedapps.frost.db.selectById
import com.pitchedapps.frost.utils.L
import com.pitchedapps.frost.utils.Prefs
import com.pitchedapps.frost.utils.cookies
@ -50,6 +52,10 @@ object FbCookie {
inline val webCookie: String?
get() = CookieManager.getInstance().getCookie(COOKIE_DOMAIN)
private val cookieDao: CookieDao by lazy {
FrostDatabase.get().cookieDao()
}
private suspend fun CookieManager.suspendSetWebCookie(cookie: String?): Boolean {
cookie ?: return true
return withContext(NonCancellable) {
@ -77,12 +83,12 @@ object FbCookie {
}
}
fun save(id: Long) {
suspend fun save(id: Long) {
L.d { "New cookie found" }
Prefs.userId = id
CookieManager.getInstance().flush()
val cookie = CookieModel(Prefs.userId, "", webCookie)
saveFbCookie(cookie)
val cookie = CookieEntity(Prefs.userId, null, webCookie)
cookieDao.save(cookie)
}
suspend fun reset() {
@ -93,11 +99,12 @@ object FbCookie {
}
}
suspend fun switchUser(id: Long) = switchUser(loadFbCookie(id))
suspend fun switchUser(id: Long) {
val cookie = cookieDao.selectById(id) ?: return L.e { "No cookie for id" }
switchUser(cookie)
}
suspend fun switchUser(name: String) = switchUser(loadFbCookie(name))
suspend fun switchUser(cookie: CookieModel?) {
suspend fun switchUser(cookie: CookieEntity?) {
if (cookie == null) {
L.d { "Switching User; null cookie" }
return
@ -114,7 +121,7 @@ object FbCookie {
* and launch the proper login page
*/
suspend fun logout(context: Context) {
val cookies = arrayListOf<CookieModel>()
val cookies = arrayListOf<CookieEntity>()
if (context is Activity)
cookies.addAll(context.cookies().filter { it.id != Prefs.userId })
logout(Prefs.userId)
@ -126,7 +133,9 @@ object FbCookie {
*/
suspend fun logout(id: Long) {
L.d { "Logging out user" }
removeCookie(id)
cookieDao.deleteById(id)
L.d { "Fb cookie deleted" }
L._d { id }
reset()
}

View File

@ -16,7 +16,7 @@
*/
package com.pitchedapps.frost.facebook.parsers
import com.pitchedapps.frost.dbflow.CookieModel
import com.pitchedapps.frost.db.CookieEntity
import com.pitchedapps.frost.facebook.FB_CSS_URL_MATCHER
import com.pitchedapps.frost.facebook.formattedFbUrl
import com.pitchedapps.frost.facebook.get
@ -81,7 +81,7 @@ data class ParseResponse<out T>(val cookie: String, val data: T) {
}
interface ParseNotification {
fun getUnreadNotifications(data: CookieModel): List<NotificationContent>
fun getUnreadNotifications(data: CookieEntity): List<NotificationContent>
}
internal fun <T> List<T>.toJsonString(tag: String, indent: Int) = StringBuilder().apply {

View File

@ -16,7 +16,7 @@
*/
package com.pitchedapps.frost.facebook.parsers
import com.pitchedapps.frost.dbflow.CookieModel
import com.pitchedapps.frost.db.CookieEntity
import com.pitchedapps.frost.facebook.FB_EPOCH_MATCHER
import com.pitchedapps.frost.facebook.FB_MESSAGE_NOTIF_ID_MATCHER
import com.pitchedapps.frost.facebook.FbItem
@ -54,7 +54,7 @@ data class FrostMessages(
append("}")
}.toString()
override fun getUnreadNotifications(data: CookieModel) =
override fun getUnreadNotifications(data: CookieEntity) =
threads.asSequence().filter(FrostThread::unread).map {
with(it) {
NotificationContent(
@ -64,7 +64,8 @@ data class FrostMessages(
title = title,
text = content ?: "",
timestamp = time,
profileUrl = img
profileUrl = img,
unread = unread
)
}
}.toList()

View File

@ -16,7 +16,7 @@
*/
package com.pitchedapps.frost.facebook.parsers
import com.pitchedapps.frost.dbflow.CookieModel
import com.pitchedapps.frost.db.CookieEntity
import com.pitchedapps.frost.facebook.FB_EPOCH_MATCHER
import com.pitchedapps.frost.facebook.FB_NOTIF_ID_MATCHER
import com.pitchedapps.frost.facebook.FbItem
@ -43,7 +43,7 @@ data class FrostNotifs(
append("}")
}.toString()
override fun getUnreadNotifications(data: CookieModel) =
override fun getUnreadNotifications(data: CookieEntity) =
notifs.asSequence().filter(FrostNotif::unread).map {
with(it) {
NotificationContent(
@ -53,7 +53,8 @@ data class FrostNotifs(
title = null,
text = content,
timestamp = time,
profileUrl = img
profileUrl = img,
unread = unread
)
}
}.toList()

View File

@ -31,9 +31,10 @@ import ca.allanwang.kau.utils.string
import com.pitchedapps.frost.BuildConfig
import com.pitchedapps.frost.R
import com.pitchedapps.frost.activities.FrostWebActivity
import com.pitchedapps.frost.dbflow.CookieModel
import com.pitchedapps.frost.dbflow.NotificationModel
import com.pitchedapps.frost.dbflow.lastNotificationTime
import com.pitchedapps.frost.db.CookieEntity
import com.pitchedapps.frost.db.FrostDatabase
import com.pitchedapps.frost.db.latestEpoch
import com.pitchedapps.frost.db.saveNotifications
import com.pitchedapps.frost.enums.OverlayContext
import com.pitchedapps.frost.facebook.FbItem
import com.pitchedapps.frost.facebook.parsers.FrostParser
@ -60,12 +61,10 @@ private val _40_DP = 40.dpToPx
* Enum to handle notification creations
*/
enum class NotificationType(
private val channelId: String,
val channelId: String,
private val overlayContext: OverlayContext,
private val fbItem: FbItem,
private val parser: FrostParser<ParseNotification>,
private val getTime: (notif: NotificationModel) -> Long,
private val putTime: (notif: NotificationModel, time: Long) -> NotificationModel,
private val ringtone: () -> String
) {
@ -74,8 +73,6 @@ enum class NotificationType(
OverlayContext.NOTIFICATION,
FbItem.NOTIFICATIONS,
NotifParser,
NotificationModel::epoch,
{ notif, time -> notif.copy(epoch = time) },
Prefs::notificationRingtone
) {
@ -88,8 +85,6 @@ enum class NotificationType(
OverlayContext.MESSAGE,
FbItem.MESSAGES,
MessageParser,
NotificationModel::epochIm,
{ notif, time -> notif.copy(epochIm = time) },
Prefs::messageRingtone
);
@ -100,8 +95,8 @@ enum class NotificationType(
*/
internal open fun bindRequest(content: NotificationContent, cookie: String): (BaseBundle.() -> Unit)? = null
private fun bindRequest(intent: Intent, content: NotificationContent, cookie: String?) {
cookie ?: return
private fun bindRequest(intent: Intent, content: NotificationContent) {
val cookie = content.data.cookie ?: return
val binder = bindRequest(content, cookie) ?: return
val bundle = Bundle()
bundle.binder()
@ -116,7 +111,8 @@ enum class NotificationType(
* Returns the number of notifications generated,
* or -1 if an error occurred
*/
fun fetch(context: Context, data: CookieModel): Int {
suspend fun fetch(context: Context, data: CookieEntity): Int {
val notifDao = FrostDatabase.get().notifDao()
val response = try {
parser.parse(data.cookie)
} catch (ignored: Exception) {
@ -142,36 +138,42 @@ enum class NotificationType(
}
if (notifContents.isEmpty()) return 0
val userId = data.id
val prevNotifTime = lastNotificationTime(userId)
val prevLatestEpoch = getTime(prevNotifTime)
// Legacy, remove with dbflow
val prevLatestEpoch = notifDao.latestEpoch(userId, channelId)
L.v { "Notif $name prev epoch $prevLatestEpoch" }
var newLatestEpoch = prevLatestEpoch
val notifs = mutableListOf<FrostNotification>()
notifContents.forEach { notif ->
L.v { "Notif timestamp ${notif.timestamp}" }
if (notif.timestamp <= prevLatestEpoch) return@forEach
notifs.add(createNotification(context, notif))
if (notif.timestamp > newLatestEpoch)
newLatestEpoch = notif.timestamp
}
if (newLatestEpoch > prevLatestEpoch)
putTime(prevNotifTime, newLatestEpoch).save()
L.d { "Notif $name new epoch ${getTime(lastNotificationTime(userId))}" }
if (prevLatestEpoch == -1L && !BuildConfig.DEBUG) {
L.d { "Skipping first notification fetch" }
return 0 // do not notify the first time
}
val newNotifContents = notifContents.filter { it.timestamp > prevLatestEpoch }
if (newNotifContents.isEmpty()) {
L.d { "No new notifs found for $name" }
return 0
}
L.d { "${newNotifContents.size} new notifs found for $name" }
if (!notifDao.saveNotifications(channelId, newNotifContents)) {
L.d { "Skip notifs for $name as saving failed" }
return 0
}
val notifs = newNotifContents.map { createNotification(context, it) }
frostEvent("Notifications", "Type" to name, "Count" to notifs.size)
if (notifs.size > 1)
summaryNotification(context, userId, notifs.size).notify(context)
val ringtone = ringtone()
notifs.forEachIndexed { i, notif ->
// Ring at most twice
notif.withAlert(i < 2, ringtone).notify(context)
}
return notifs.size
}
fun debugNotification(context: Context, data: CookieModel) {
fun debugNotification(context: Context, data: CookieEntity) {
val content = NotificationContent(
data,
System.currentTimeMillis(),
@ -179,23 +181,40 @@ enum class NotificationType(
"Debug Notif",
"Test 123",
System.currentTimeMillis() / 1000,
"https://www.iconexperience.com/_img/v_collection_png/256x256/shadow/dog.png"
"https://www.iconexperience.com/_img/v_collection_png/256x256/shadow/dog.png",
false
)
createNotification(context, content).notify(context)
}
/**
* Attach content related data to an intent
*/
fun putContentExtra(intent: Intent, content: NotificationContent): Intent {
// We will show the notification page for dependent urls. We can trigger a click next time
intent.data = Uri.parse(if (content.href.isIndependent) content.href else FbItem.NOTIFICATIONS.url)
bindRequest(intent, content)
return intent
}
/**
* Create a generic content for the provided type and user id.
* No content related data is added
*/
fun createCommonIntent(context: Context, userId: Long): Intent {
val intent = Intent(context, FrostWebActivity::class.java)
intent.putExtra(ARG_USER_ID, userId)
overlayContext.put(intent)
return intent
}
/**
* Create and submit a new notification with the given [content]
*/
private fun createNotification(context: Context, content: NotificationContent): FrostNotification =
with(content) {
val intent = Intent(context, FrostWebActivity::class.java)
// TODO temp fix; we will show notification page for dependent urls. We can trigger a click next time
intent.data = Uri.parse(if (href.isIndependent) href else FbItem.NOTIFICATIONS.url)
intent.putExtra(ARG_USER_ID, data.id)
overlayContext.put(intent)
bindRequest(intent, content, data.cookie)
val intent = createCommonIntent(context, content.data.id)
putContentExtra(intent, content)
val group = "${groupPrefix}_${data.id}"
val pendingIntent = PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT)
val notifBuilder = context.frostNotification(channelId)
@ -257,13 +276,15 @@ enum class NotificationType(
* Notification data holder
*/
data class NotificationContent(
val data: CookieModel,
// TODO replace data with userId?
val data: CookieEntity,
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 unread: Boolean
) {
val notifId = Math.abs(id.toInt())

View File

@ -21,16 +21,19 @@ import androidx.core.app.NotificationManagerCompat
import ca.allanwang.kau.utils.string
import com.pitchedapps.frost.BuildConfig
import com.pitchedapps.frost.R
import com.pitchedapps.frost.dbflow.CookieModel
import com.pitchedapps.frost.dbflow.loadFbCookiesSync
import com.pitchedapps.frost.db.CookieDao
import com.pitchedapps.frost.db.CookieEntity
import com.pitchedapps.frost.db.selectAll
import com.pitchedapps.frost.utils.L
import com.pitchedapps.frost.utils.Prefs
import com.pitchedapps.frost.utils.frostEvent
import com.pitchedapps.frost.widgets.NotificationWidget
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.coroutines.yield
import org.koin.android.ext.android.inject
/**
* Created by Allan Wang on 2017-06-14.
@ -42,6 +45,8 @@ import kotlinx.coroutines.yield
*/
class NotificationService : BaseJobService() {
val cookieDao: CookieDao by inject()
override fun onStopJob(params: JobParameters?): Boolean {
super.onStopJob(params)
prepareFinish(true)
@ -81,7 +86,7 @@ class NotificationService : BaseJobService() {
private suspend fun sendNotifications(params: JobParameters?): Unit = withContext(Dispatchers.Default) {
val currentId = Prefs.userId
val cookies = loadFbCookiesSync()
val cookies = cookieDao.selectAll()
yield()
val jobId = params?.extras?.getInt(NOTIFICATION_PARAM_ID, -1) ?: -1
var notifCount = 0
@ -101,13 +106,16 @@ class NotificationService : BaseJobService() {
L.i { "Sent $notifCount notifications" }
if (notifCount == 0 && jobId == NOTIFICATION_JOB_NOW)
generalNotification(665, R.string.no_new_notifications, BuildConfig.DEBUG)
if (notifCount > 0) {
NotificationWidget.forceUpdate(this@NotificationService)
}
}
/**
* Implemented fetch to also notify when an error occurs
* Also normalized the output to return the number of notifications received
*/
private fun fetch(jobId: Int, type: NotificationType, cookie: CookieModel): Int {
private suspend fun fetch(jobId: Int, type: NotificationType, cookie: CookieEntity): Int {
val count = type.fetch(this, cookie)
if (count < 0) {
if (jobId == NOTIFICATION_JOB_NOW)

View File

@ -29,14 +29,15 @@ import ca.allanwang.kau.utils.string
import com.pitchedapps.frost.BuildConfig
import com.pitchedapps.frost.R
import com.pitchedapps.frost.activities.SettingsActivity
import com.pitchedapps.frost.dbflow.NotificationModel
import com.pitchedapps.frost.dbflow.loadFbCookiesAsync
import com.pitchedapps.frost.db.FrostDatabase
import com.pitchedapps.frost.db.deleteAll
import com.pitchedapps.frost.services.fetchNotifications
import com.pitchedapps.frost.services.scheduleNotifications
import com.pitchedapps.frost.utils.Prefs
import com.pitchedapps.frost.utils.frostSnackbar
import com.pitchedapps.frost.utils.materialDialogThemed
import com.pitchedapps.frost.views.Keywords
import kotlinx.coroutines.launch
/**
* Created by Allan Wang on 2017-06-29.
@ -171,8 +172,8 @@ fun SettingsActivity.getNotificationPrefs(): KPrefAdapterBuilder.() -> Unit = {
if (BuildConfig.DEBUG) {
plainText(R.string.reset_notif_epoch) {
onClick = {
loadFbCookiesAsync { cookies ->
cookies.map { NotificationModel(it.id) }.forEach { it.save() }
launch {
FrostDatabase.get().notifDao().deleteAll()
}
}
}

View File

@ -29,7 +29,7 @@ import ca.allanwang.kau.utils.showAppInfo
import ca.allanwang.kau.utils.string
import ca.allanwang.kau.utils.toast
import com.pitchedapps.frost.R
import com.pitchedapps.frost.dbflow.loadFbCookie
import com.pitchedapps.frost.db.CookieEntity
import com.pitchedapps.frost.facebook.USER_AGENT_DESKTOP
/**
@ -38,6 +38,7 @@ import com.pitchedapps.frost.facebook.USER_AGENT_DESKTOP
* With reference to <a href="https://stackoverflow.com/questions/33434532/android-webview-download-files-like-browsers-do">Stack Overflow</a>
*/
fun Context.frostDownload(
cookie: CookieEntity,
url: String?,
userAgent: String = USER_AGENT_DESKTOP,
contentDisposition: String? = null,
@ -45,10 +46,11 @@ fun Context.frostDownload(
contentLength: Long = 0L
) {
url ?: return
frostDownload(Uri.parse(url), userAgent, contentDisposition, mimeType, contentLength)
frostDownload(cookie, Uri.parse(url), userAgent, contentDisposition, mimeType, contentLength)
}
fun Context.frostDownload(
cookie: CookieEntity,
uri: Uri?,
userAgent: String = USER_AGENT_DESKTOP,
contentDisposition: String? = null,
@ -75,7 +77,6 @@ fun Context.frostDownload(
if (!granted) return@kauRequestPermissions
val request = DownloadManager.Request(uri)
request.setMimeType(mimeType)
val cookie = loadFbCookie(Prefs.userId) ?: return@kauRequestPermissions
val title = URLUtil.guessFileName(uri.toString(), contentDisposition, mimeType)
request.addRequestHeader("Cookie", cookie.cookie)
request.addRequestHeader("User-Agent", userAgent)

View File

@ -50,6 +50,11 @@ object L : KauLogger("Frost", {
d(message)
}
inline fun _e(e: Throwable?, message: () -> Any?) {
if (BuildConfig.DEBUG)
e(e, message)
}
override fun logImpl(priority: Int, message: String?, t: Throwable?) {
if (BuildConfig.DEBUG)
super.logImpl(priority, message, t)

View File

@ -0,0 +1,50 @@
/*
* Copyright 2019 Allan Wang
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package com.pitchedapps.frost.utils
import android.content.Context
import ca.allanwang.kau.utils.string
import com.pitchedapps.frost.R
import java.text.DateFormat
import java.text.SimpleDateFormat
import java.util.Calendar
import java.util.Date
import java.util.Locale
/**
* Converts time in millis to readable date,
* eg Apr 24 at 7:32 PM
*
* With regards to date modifications in calendars,
* it appears to respect calendar rules;
* see https://stackoverflow.com/a/43227817/4407321
*/
fun Long.toReadableTime(context: Context): String {
val cal = Calendar.getInstance()
cal.timeInMillis = this
val timeFormatter = SimpleDateFormat.getTimeInstance(DateFormat.SHORT)
val time = timeFormatter.format(Date(this))
val day = when {
cal >= Calendar.getInstance().apply { add(Calendar.DAY_OF_MONTH, -1) } -> context.string(R.string.today)
cal >= Calendar.getInstance().apply { add(Calendar.DAY_OF_MONTH, -2) } -> context.string(R.string.yesterday)
else -> {
val dayFormatter = SimpleDateFormat("MMM dd", Locale.getDefault())
dayFormatter.format(Date(this))
}
}
return context.getString(R.string.time_template, day, time)
}

View File

@ -62,7 +62,7 @@ import com.pitchedapps.frost.activities.TabCustomizerActivity
import com.pitchedapps.frost.activities.WebOverlayActivity
import com.pitchedapps.frost.activities.WebOverlayActivityBase
import com.pitchedapps.frost.activities.WebOverlayDesktopActivity
import com.pitchedapps.frost.dbflow.CookieModel
import com.pitchedapps.frost.db.CookieEntity
import com.pitchedapps.frost.facebook.FACEBOOK_COM
import com.pitchedapps.frost.facebook.FBCDN_NET
import com.pitchedapps.frost.facebook.FbCookie
@ -103,7 +103,7 @@ internal inline val Context.ctxCoroutine: CoroutineScope
get() = this as? CoroutineScope ?: GlobalScope
inline fun <reified T : Activity> Context.launchNewTask(
cookieList: ArrayList<CookieModel> = arrayListOf(),
cookieList: ArrayList<CookieEntity> = arrayListOf(),
clearStack: Boolean = false
) {
startActivity<T>(clearStack, intentBuilder = {
@ -111,13 +111,13 @@ inline fun <reified T : Activity> Context.launchNewTask(
})
}
fun Context.launchLogin(cookieList: ArrayList<CookieModel>, clearStack: Boolean = true) {
fun Context.launchLogin(cookieList: ArrayList<CookieEntity>, clearStack: Boolean = true) {
if (cookieList.isNotEmpty()) launchNewTask<SelectorActivity>(cookieList, clearStack)
else launchNewTask<LoginActivity>(clearStack = clearStack)
}
fun Activity.cookies(): ArrayList<CookieModel> {
return intent?.getParcelableArrayListExtra<CookieModel>(EXTRA_COOKIES) ?: arrayListOf()
fun Activity.cookies(): ArrayList<CookieEntity> {
return intent?.getParcelableArrayListExtra<CookieEntity>(EXTRA_COOKIES) ?: arrayListOf()
}
/**
@ -186,7 +186,7 @@ fun MaterialDialog.Builder.theme(): MaterialDialog.Builder {
}
fun Activity.setFrostTheme(forceTransparent: Boolean = false) {
val isTransparent = (Color.alpha(Prefs.bgColor) != 255) || forceTransparent
val isTransparent = (Color.alpha(Prefs.bgColor) != 255) || (Color.alpha(Prefs.headerColor) != 255) || forceTransparent
if (Prefs.bgColor.isColorDark)
setTheme(if (isTransparent) R.style.FrostTheme_Transparent else R.style.FrostTheme)
else

View File

@ -33,7 +33,7 @@ import com.bumptech.glide.request.RequestListener
import com.bumptech.glide.request.target.Target
import com.mikepenz.google_material_typeface_library.GoogleMaterial
import com.pitchedapps.frost.R
import com.pitchedapps.frost.dbflow.CookieModel
import com.pitchedapps.frost.db.CookieEntity
import com.pitchedapps.frost.facebook.profilePictureUrl
import com.pitchedapps.frost.glide.FrostGlide
import com.pitchedapps.frost.glide.GlideApp
@ -42,7 +42,7 @@ import com.pitchedapps.frost.utils.Prefs
/**
* Created by Allan Wang on 2017-06-05.
*/
class AccountItem(val cookie: CookieModel?) : KauIItem<AccountItem, AccountItem.ViewHolder>
class AccountItem(val cookie: CookieEntity?) : KauIItem<AccountItem, AccountItem.ViewHolder>
(R.layout.view_account, { ViewHolder(it) }, R.id.item_account) {
override fun bindView(viewHolder: ViewHolder, payloads: MutableList<Any>) {

View File

@ -32,6 +32,7 @@ import ca.allanwang.kau.utils.inflate
import ca.allanwang.kau.utils.isColorDark
import ca.allanwang.kau.utils.isGone
import ca.allanwang.kau.utils.isVisible
import ca.allanwang.kau.utils.launchMain
import ca.allanwang.kau.utils.setIcon
import ca.allanwang.kau.utils.setMenuIcons
import ca.allanwang.kau.utils.visible
@ -39,8 +40,11 @@ import ca.allanwang.kau.utils.withMinAlpha
import com.devbrackets.android.exomedia.listener.VideoControlsVisibilityListener
import com.mikepenz.google_material_typeface_library.GoogleMaterial
import com.pitchedapps.frost.R
import com.pitchedapps.frost.db.FrostDatabase
import com.pitchedapps.frost.db.currentCookie
import com.pitchedapps.frost.utils.L
import com.pitchedapps.frost.utils.Prefs
import com.pitchedapps.frost.utils.ctxCoroutine
import com.pitchedapps.frost.utils.frostDownload
import kotlinx.android.synthetic.main.view_video.view.*
@ -96,7 +100,10 @@ class FrostVideoViewer @JvmOverloads constructor(
video_toolbar.setOnMenuItemClickListener {
when (it.itemId) {
R.id.action_pip -> video.isExpanded = false
R.id.action_download -> context.frostDownload(video.videoUri)
R.id.action_download -> context.ctxCoroutine.launchMain {
val cookie = FrostDatabase.get().cookieDao().currentCookie() ?: return@launchMain
context.frostDownload(cookie, video.videoUri)
}
}
true
}

View File

@ -24,15 +24,19 @@ import android.util.AttributeSet
import android.view.View
import android.view.ViewGroup
import ca.allanwang.kau.utils.AnimHolder
import ca.allanwang.kau.utils.launchMain
import com.pitchedapps.frost.contracts.FrostContentContainer
import com.pitchedapps.frost.contracts.FrostContentCore
import com.pitchedapps.frost.contracts.FrostContentParent
import com.pitchedapps.frost.db.FrostDatabase
import com.pitchedapps.frost.db.currentCookie
import com.pitchedapps.frost.facebook.FB_HOME_URL
import com.pitchedapps.frost.facebook.FbItem
import com.pitchedapps.frost.facebook.USER_AGENT_DESKTOP
import com.pitchedapps.frost.facebook.USER_AGENT_MOBILE
import com.pitchedapps.frost.fragments.WebFragment
import com.pitchedapps.frost.utils.Prefs
import com.pitchedapps.frost.utils.ctxCoroutine
import com.pitchedapps.frost.utils.frostDownload
import com.pitchedapps.frost.web.FrostChromeClient
import com.pitchedapps.frost.web.FrostJSI
@ -81,7 +85,13 @@ class FrostWebView @JvmOverloads constructor(
webChromeClient = FrostChromeClient(this)
addJavascriptInterface(FrostJSI(this), "Frost")
setBackgroundColor(Color.TRANSPARENT)
setDownloadListener(context::frostDownload)
val db = FrostDatabase.get()
setDownloadListener { url, userAgent, contentDisposition, mimetype, contentLength ->
context.ctxCoroutine.launchMain {
val cookie = db.cookieDao().currentCookie() ?: return@launchMain
context.frostDownload(cookie, url, userAgent, contentDisposition, mimetype, contentLength)
}
}
return this
}

View File

@ -58,6 +58,7 @@ class DebugWebView @JvmOverloads constructor(
settings.userAgentString = USER_AGENT_MOBILE
setLayerType(View.LAYER_TYPE_HARDWARE, null)
webViewClient = DebugClient()
@Suppress("DEPRECATION")
isDrawingCacheEnabled = true
}
@ -72,6 +73,7 @@ class DebugWebView @JvmOverloads constructor(
}
try {
output.outputStream().use {
@Suppress("DEPRECATION")
drawingCache.compress(Bitmap.CompressFormat.PNG, 100, it)
}
L.d { "Created screenshot at ${output.absolutePath}" }

View File

@ -21,7 +21,7 @@ import android.webkit.JavascriptInterface
import com.pitchedapps.frost.activities.MainActivity
import com.pitchedapps.frost.contracts.MainActivityContract
import com.pitchedapps.frost.contracts.VideoViewHolder
import com.pitchedapps.frost.dbflow.CookieModel
import com.pitchedapps.frost.db.CookieEntity
import com.pitchedapps.frost.facebook.FbCookie
import com.pitchedapps.frost.utils.L
import com.pitchedapps.frost.utils.Prefs
@ -44,7 +44,7 @@ class FrostJSI(val web: FrostWebView) {
private val activity: MainActivity? = context as? MainActivity
private val header: SendChannel<String>? = activity?.headerBadgeChannel
private val refresh: SendChannel<Boolean> = web.parent.refreshChannel
private val cookies: List<CookieModel> = activity?.cookies() ?: arrayListOf()
private val cookies: List<CookieEntity> = activity?.cookies() ?: arrayListOf()
/**
* Attempts to load the url in an overlay

View File

@ -29,7 +29,7 @@ import android.webkit.WebView
import ca.allanwang.kau.utils.fadeIn
import ca.allanwang.kau.utils.isVisible
import ca.allanwang.kau.utils.launchMain
import com.pitchedapps.frost.dbflow.CookieModel
import com.pitchedapps.frost.db.CookieEntity
import com.pitchedapps.frost.facebook.FB_LOGIN_URL
import com.pitchedapps.frost.facebook.FB_USER_MATCHER
import com.pitchedapps.frost.facebook.FbCookie
@ -51,7 +51,7 @@ class LoginWebView @JvmOverloads constructor(
defStyleAttr: Int = 0
) : WebView(context, attrs, defStyleAttr) {
private val completable: CompletableDeferred<CookieModel> = CompletableDeferred()
private val completable: CompletableDeferred<CookieEntity> = CompletableDeferred()
private lateinit var progressCallback: (Int) -> Unit
@SuppressLint("SetJavaScriptEnabled")
@ -62,7 +62,7 @@ class LoginWebView @JvmOverloads constructor(
webChromeClient = LoginChromeClient()
}
suspend fun loadLogin(progressCallback: (Int) -> Unit): CompletableDeferred<CookieModel> = coroutineScope {
suspend fun loadLogin(progressCallback: (Int) -> Unit): CompletableDeferred<CookieEntity> = coroutineScope {
this@LoginWebView.progressCallback = progressCallback
L.d { "Begin loading login" }
launchMain {
@ -77,18 +77,18 @@ class LoginWebView @JvmOverloads constructor(
override fun onPageFinished(view: WebView, url: String?) {
super.onPageFinished(view, url)
val cookieModel = checkForLogin(url)
if (cookieModel != null)
completable.complete(cookieModel)
val cookie = checkForLogin(url)
if (cookie != null)
completable.complete(cookie)
if (!view.isVisible) view.fadeIn()
}
fun checkForLogin(url: String?): CookieModel? {
fun checkForLogin(url: String?): CookieEntity? {
if (!url.isFacebookUrl) return null
val cookie = CookieManager.getInstance().getCookie(url) ?: return null
L.d { "Checking cookie for login" }
val id = FB_USER_MATCHER.find(cookie)[1]?.toLong() ?: return null
return CookieModel(id, "", cookie)
return CookieEntity(id, null, cookie)
}
override fun onPageCommitVisible(view: WebView, url: String?) {

View File

@ -0,0 +1,194 @@
/*
* Copyright 2019 Allan Wang
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package com.pitchedapps.frost.widgets
import android.app.PendingIntent
import android.appwidget.AppWidgetManager
import android.appwidget.AppWidgetProvider
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Canvas
import android.graphics.Paint
import android.graphics.PorterDuff
import android.graphics.PorterDuffColorFilter
import android.graphics.drawable.Icon
import android.os.Build
import android.widget.RemoteViews
import android.widget.RemoteViewsService
import androidx.annotation.ColorInt
import androidx.annotation.DrawableRes
import androidx.annotation.IdRes
import ca.allanwang.kau.utils.dimenPixelSize
import ca.allanwang.kau.utils.withAlpha
import com.pitchedapps.frost.R
import com.pitchedapps.frost.activities.MainActivity
import com.pitchedapps.frost.db.NotificationDao
import com.pitchedapps.frost.db.selectNotificationsSync
import com.pitchedapps.frost.glide.FrostGlide
import com.pitchedapps.frost.glide.GlideApp
import com.pitchedapps.frost.services.NotificationContent
import com.pitchedapps.frost.services.NotificationType
import com.pitchedapps.frost.utils.Prefs
import com.pitchedapps.frost.utils.toReadableTime
import org.koin.standalone.KoinComponent
import org.koin.standalone.inject
class NotificationWidget : AppWidgetProvider() {
override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) {
super.onUpdate(context, appWidgetManager, appWidgetIds)
val type = NotificationType.GENERAL
val userId = Prefs.userId
val intent = NotificationWidgetService.createIntent(context, type, userId)
for (id in appWidgetIds) {
val views = RemoteViews(context.packageName, R.layout.widget_notifications)
views.setBackgroundColor(R.id.widget_layout_toolbar, Prefs.headerColor)
views.setIcon(R.id.img_frost, context, R.drawable.frost_f_24, Prefs.iconColor)
views.setOnClickPendingIntent(
R.id.img_frost,
PendingIntent.getActivity(context, 0, Intent(context, MainActivity::class.java), 0)
)
views.setBackgroundColor(R.id.widget_notification_list, Prefs.bgColor)
views.setRemoteAdapter(R.id.widget_notification_list, intent)
val pendingIntentTemplate = PendingIntent.getActivity(
context,
0,
type.createCommonIntent(context, userId),
PendingIntent.FLAG_UPDATE_CURRENT
)
views.setPendingIntentTemplate(R.id.widget_notification_list, pendingIntentTemplate)
appWidgetManager.updateAppWidget(id, views)
}
appWidgetManager.notifyAppWidgetViewDataChanged(appWidgetIds, R.id.widget_notification_list)
}
companion object {
fun forceUpdate(context: Context) {
val manager = AppWidgetManager.getInstance(context)
val ids = manager.getAppWidgetIds(ComponentName(context, NotificationWidget::class.java))
val intent = Intent().apply {
action = AppWidgetManager.ACTION_APPWIDGET_UPDATE
putExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS, ids)
}
context.sendBroadcast(intent)
}
}
}
private const val NOTIF_WIDGET_TYPE = "notif_widget_type"
private const val NOTIF_WIDGET_USER_ID = "notif_widget_user_id"
private fun RemoteViews.setBackgroundColor(@IdRes viewId: Int, @ColorInt color: Int) {
setInt(viewId, "setBackgroundColor", color)
}
/**
* Adds backward compatibility to setting tinted icons
*/
private fun RemoteViews.setIcon(@IdRes viewId: Int, context: Context, @DrawableRes res: Int, @ColorInt color: Int) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val icon = Icon.createWithResource(context, res).setTint(color).setTintMode(PorterDuff.Mode.SRC_IN)
setImageViewIcon(viewId, icon)
} else {
val bitmap = BitmapFactory.decodeResource(context.resources, res)
if (bitmap != null) {
val paint = Paint()
paint.colorFilter = PorterDuffColorFilter(color, PorterDuff.Mode.SRC_IN)
val result = Bitmap.createBitmap(bitmap.width, bitmap.height, Bitmap.Config.ARGB_8888)
val canvas = Canvas(result)
canvas.drawBitmap(bitmap, 0f, 0f, paint)
setImageViewBitmap(viewId, result)
} else {
// Fallback to just icon
setImageViewResource(viewId, res)
}
}
}
class NotificationWidgetService : RemoteViewsService() {
override fun onGetViewFactory(intent: Intent): RemoteViewsFactory = NotificationWidgetDataProvider(this, intent)
companion object {
fun createIntent(context: Context, type: NotificationType, userId: Long): Intent =
Intent(context, NotificationWidgetService::class.java)
.putExtra(NOTIF_WIDGET_TYPE, type.name)
.putExtra(NOTIF_WIDGET_USER_ID, userId)
}
}
class NotificationWidgetDataProvider(val context: Context, val intent: Intent) : RemoteViewsService.RemoteViewsFactory,
KoinComponent {
private val notifDao: NotificationDao by inject()
@Volatile
private var content: List<NotificationContent> = emptyList()
private val type = NotificationType.valueOf(intent.getStringExtra(NOTIF_WIDGET_TYPE))
private val userId = intent.getLongExtra(NOTIF_WIDGET_USER_ID, -1)
private val avatarSize = context.dimenPixelSize(R.dimen.avatar_image_size)
private val glide = GlideApp.with(context).asBitmap()
private fun loadNotifications() {
content = notifDao.selectNotificationsSync(userId, type.channelId)
}
override fun onCreate() {
}
override fun onDataSetChanged() {
loadNotifications()
}
override fun getLoadingView(): RemoteViews? = null
override fun getItemId(position: Int): Long = content[position].id
override fun hasStableIds(): Boolean = true
override fun getViewAt(position: Int): RemoteViews {
val views = RemoteViews(context.packageName, R.layout.widget_notification_item)
val notif = content[position]
views.setBackgroundColor(R.id.item_frame, Prefs.nativeBgColor(notif.unread))
views.setTextColor(R.id.item_content, Prefs.textColor)
views.setTextViewText(R.id.item_content, notif.text)
views.setTextColor(R.id.item_date, Prefs.textColor.withAlpha(150))
views.setTextViewText(R.id.item_date, notif.timestamp.toReadableTime(context))
val avatar = glide.load(notif.profileUrl).transform(FrostGlide.circleCrop).submit(avatarSize, avatarSize).get()
views.setImageViewBitmap(R.id.item_avatar, avatar)
views.setOnClickFillInIntent(R.id.item_frame, type.putContentExtra(Intent(), notif))
return views
}
override fun getCount(): Int = content.size
override fun getViewTypeCount(): Int = 1
override fun onDestroy() {
}
}

View File

@ -0,0 +1,48 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="30dp"
android:height="40dp"
android:viewportWidth="300"
android:viewportHeight="400">
<path
android:pathData="M0,0h300v400H0V0z"
android:fillColor="#fafafa"/>
<path
android:pathData="M0,0h300v50H0V0z"
android:fillColor="@color/facebook_blue"/>
<path
android:pathData="M65,170a20,20 0,1 1,-40 0,20 20,0 0,1 40,0z"
android:fillColor="#DE000000"/>
<path
android:pathData="M85,150h184v11H85v-11z"
android:fillColor="#DE000000"/>
<path
android:pathData="M85,179h146v11H85v-11z"
android:fillColor="#DE000000"/>
<path
android:pathData="M65,95a20,20 0,1 1,-40 0,20 20,0 0,1 40,0z"
android:fillColor="#DE000000"/>
<path
android:pathData="M85,75h184v11H85V75z"
android:fillColor="#DE000000"/>
<path
android:pathData="M85,104h146v11H85v-11z"
android:fillColor="#DE000000"/>
<path
android:pathData="M65,245a20,20 0,1 1,-40 0,20 20,0 0,1 40,0z"
android:fillColor="#DE000000"/>
<path
android:pathData="M85,225h184v11H85v-11z"
android:fillColor="#DE000000"/>
<path
android:pathData="M85,254h146v11H85v-11z"
android:fillColor="#DE000000"/>
<path
android:pathData="M65,320a20,20 0,1 1,-40 0,20 20,0 0,1 40,0z"
android:fillColor="#DE000000"/>
<path
android:pathData="M85,300h184v11H85v-11z"
android:fillColor="#DE000000"/>
<path
android:pathData="M85,329h146v11H85v-11z"
android:fillColor="#DE000000"/>
</vector>

View File

@ -0,0 +1,46 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/item_frame"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?android:selectableItemBackground"
android:orientation="horizontal"
android:paddingStart="@dimen/kau_activity_horizontal_margin"
android:paddingTop="@dimen/kau_activity_vertical_margin"
android:paddingEnd="@dimen/kau_activity_horizontal_margin"
android:paddingBottom="@dimen/kau_activity_vertical_margin">
<ImageView
android:id="@+id/item_avatar"
android:layout_width="@dimen/avatar_image_size"
android:layout_height="@dimen/avatar_image_size" />
<!--
Unlike the actual notification panel,
we do not show thumbnails, and we limit the title length
-->
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/kau_padding_normal"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:id="@+id/item_content"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ellipsize="end"
android:lines="2" />
<TextView
android:id="@+id/item_date"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ellipsize="end"
android:lines="1"
android:textSize="12sp" />
</LinearLayout>
</LinearLayout>

View File

@ -0,0 +1,31 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/widget_layout_container"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<LinearLayout
android:id="@+id/widget_layout_toolbar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:paddingStart="@dimen/kau_padding_small"
android:paddingEnd="@dimen/kau_padding_small">
<ImageView
android:id="@+id/img_frost"
android:layout_width="@dimen/toolbar_icon_size"
android:layout_height="@dimen/toolbar_icon_size"
android:layout_gravity="center_vertical"
android:layout_margin="@dimen/kau_padding_small"
android:background="?android:selectableItemBackgroundBorderless" />
</LinearLayout>
<ListView
android:id="@+id/widget_notification_list"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1" />
</LinearLayout>

View File

@ -9,4 +9,6 @@
<dimen name="tab_bar_height">50dp</dimen>
<dimen name="intro_bar_height">64dp</dimen>
<dimen name="badge_icon_size">20dp</dimen>
<dimen name="toolbar_icon_size">24dp</dimen>
</resources>

View File

@ -62,4 +62,16 @@
<string name="no_new_notifications">No new notifications found</string>
<string name="today">Today</string>
<string name="yesterday">Today</string>
<!--
Template used to display human readable string;
For instance:
Today at 1:23 PM
Mar 13 at 9:00 AM
The first element is the day, and the second element is the time
-->
<string name="time_template">%1s at %2s</string>
</resources>

View File

@ -6,12 +6,22 @@
<item text="" />
-->
<version title="v2.3.0" />
<item text="Converted internals of Facebook data storage; auto migration will only work from 2.2.x to 2.3.x" />
<item text="Added notification widget" />
<item text="" />
<item text="" />
<item text="" />
<item text="" />
<item text="" />
<item text="" />
<item text="" />
<version title="v2.2.4" />
<item text="Show top bar to allow sharing posts" />
<item text="Fix unmuting videos when autoplay is enabled" />
<item text="Add shortcut to toggle autoplay in settings > behaviour" />
<item text="Update theme" />
<item text="" />
<version title="v2.2.3" />
<item text="Add ability to hide stories" />

View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?><!--
For sizing see:
https://developer.android.com/guide/practices/ui_guidelines/widget_design.html#anatomy_determining_size
-->
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:initialKeyguardLayout="@layout/widget_notifications"
android:initialLayout="@layout/widget_notifications"
android:minWidth="180dp"
android:minHeight="250dp"
android:previewImage="@drawable/notification_widget_preview" />

View File

@ -0,0 +1,195 @@
{
"formatVersion": 1,
"database": {
"version": 1,
"identityHash": "fe8f5b6c27f48d7e0733ee6819f06f40",
"entities": [
{
"tableName": "cookies",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`cookie_id` INTEGER NOT NULL, `name` TEXT, `cookie` TEXT, PRIMARY KEY(`cookie_id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "cookie_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "cookie",
"columnName": "cookie",
"affinity": "TEXT",
"notNull": false
}
],
"primaryKey": {
"columnNames": [
"cookie_id"
],
"autoGenerate": false
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "notifications",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`notif_id` INTEGER NOT NULL, `userId` INTEGER NOT NULL, `href` TEXT NOT NULL, `title` TEXT, `text` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `profileUrl` TEXT, `type` TEXT NOT NULL, `unread` INTEGER NOT NULL, PRIMARY KEY(`notif_id`, `userId`), FOREIGN KEY(`userId`) REFERENCES `cookies`(`cookie_id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "notif_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "userId",
"columnName": "userId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "href",
"columnName": "href",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "text",
"columnName": "text",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "timestamp",
"columnName": "timestamp",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "profileUrl",
"columnName": "profileUrl",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "type",
"columnName": "type",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "unread",
"columnName": "unread",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"notif_id",
"userId"
],
"autoGenerate": false
},
"indices": [
{
"name": "index_notifications_notif_id",
"unique": false,
"columnNames": [
"notif_id"
],
"createSql": "CREATE INDEX `index_notifications_notif_id` ON `${TABLE_NAME}` (`notif_id`)"
},
{
"name": "index_notifications_userId",
"unique": false,
"columnNames": [
"userId"
],
"createSql": "CREATE INDEX `index_notifications_userId` ON `${TABLE_NAME}` (`userId`)"
}
],
"foreignKeys": [
{
"table": "cookies",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"userId"
],
"referencedColumns": [
"cookie_id"
]
}
]
},
{
"tableName": "frost_cache",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `type` TEXT NOT NULL, `lastUpdated` INTEGER NOT NULL, `contents` TEXT NOT NULL, PRIMARY KEY(`id`, `type`), FOREIGN KEY(`id`) REFERENCES `cookies`(`cookie_id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "type",
"columnName": "type",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "lastUpdated",
"columnName": "lastUpdated",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "contents",
"columnName": "contents",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"id",
"type"
],
"autoGenerate": false
},
"indices": [],
"foreignKeys": [
{
"table": "cookies",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"id"
],
"referencedColumns": [
"cookie_id"
]
}
]
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, \"fe8f5b6c27f48d7e0733ee6819f06f40\")"
]
}
}

View File

@ -0,0 +1,40 @@
{
"formatVersion": 1,
"database": {
"version": 1,
"identityHash": "ee4d2fe4052ad3a1892be17681816c2c",
"entities": [
{
"tableName": "frost_generic",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`type` TEXT NOT NULL, `contents` TEXT NOT NULL, PRIMARY KEY(`type`))",
"fields": [
{
"fieldPath": "type",
"columnName": "type",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "contents",
"columnName": "contents",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"type"
],
"autoGenerate": false
},
"indices": [],
"foreignKeys": []
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, \"ee4d2fe4052ad3a1892be17681816c2c\")"
]
}
}

View File

@ -27,9 +27,9 @@ import org.junit.Assume.assumeTrue
import java.io.File
import java.util.zip.ZipFile
import kotlin.test.AfterTest
import kotlin.test.Test
import kotlin.test.BeforeTest
import kotlin.test.Ignore
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertTrue

View File

@ -1,5 +1,9 @@
# Changelog
## v2.3.0
* Converted internals of Facebook data storage; auto migration will only work from 2.2.x to 2.3.x
* Added notification widget
## v2.2.4
* Show top bar to allow sharing posts
* Fix unmuting videos when autoplay is enabled

View File

@ -56,6 +56,8 @@ MATERIAL_DRAWER_KT=2.0.1
# https://github.com/square/okhttp/releases
OKHTTP=3.14.0
# http://robolectric.org/getting-started/
# https://developer.android.com/jetpack/androidx/releases/room
ROOM=2.1.0-alpha04
ROBOELECTRIC=4.2
# https://github.com/davemorrissey/subsampling-scale-image-view#quick-start
SCALE_IMAGE_VIEW=3.10.0