diff options
Diffstat (limited to 'app/src')
40 files changed, 1601 insertions, 389 deletions
diff --git a/app/src/androidTest/kotlin/com/pitchedapps/frost/db/BaseDbTest.kt b/app/src/androidTest/kotlin/com/pitchedapps/frost/db/BaseDbTest.kt new file mode 100644 index 00000000..bae56e2f --- /dev/null +++ b/app/src/androidTest/kotlin/com/pitchedapps/frost/db/BaseDbTest.kt @@ -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() + } +} diff --git a/app/src/androidTest/kotlin/com/pitchedapps/frost/db/CacheDbTest.kt b/app/src/androidTest/kotlin/com/pitchedapps/frost/db/CacheDbTest.kt new file mode 100644 index 00000000..417c6678 --- /dev/null +++ b/app/src/androidTest/kotlin/com/pitchedapps/frost/db/CacheDbTest.kt @@ -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})" + ) + } + } +} diff --git a/app/src/androidTest/kotlin/com/pitchedapps/frost/db/CookieDbTest.kt b/app/src/androidTest/kotlin/com/pitchedapps/frost/db/CookieDbTest.kt new file mode 100644 index 00000000..327ead86 --- /dev/null +++ b/app/src/androidTest/kotlin/com/pitchedapps/frost/db/CookieDbTest.kt @@ -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") + } + } +} diff --git a/app/src/androidTest/kotlin/com/pitchedapps/frost/db/DatabaseTest.kt b/app/src/androidTest/kotlin/com/pitchedapps/frost/db/DatabaseTest.kt new file mode 100644 index 00000000..c79d212e --- /dev/null +++ b/app/src/androidTest/kotlin/com/pitchedapps/frost/db/DatabaseTest.kt @@ -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") + } +} diff --git a/app/src/androidTest/kotlin/com/pitchedapps/frost/db/GenericDbTest.kt b/app/src/androidTest/kotlin/com/pitchedapps/frost/db/GenericDbTest.kt new file mode 100644 index 00000000..add9f509 --- /dev/null +++ b/app/src/androidTest/kotlin/com/pitchedapps/frost/db/GenericDbTest.kt @@ -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" + ) + } + } +} diff --git a/app/src/androidTest/kotlin/com/pitchedapps/frost/db/NotificationDbTest.kt b/app/src/androidTest/kotlin/com/pitchedapps/frost/db/NotificationDbTest.kt new file mode 100644 index 00000000..9167c7bf --- /dev/null +++ b/app/src/androidTest/kotlin/com/pitchedapps/frost/db/NotificationDbTest.kt @@ -0,0 +1,144 @@ +/* + * 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 + ) + + @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" + ) + } + } +} diff --git a/app/src/main/kotlin/com/pitchedapps/frost/FrostApp.kt b/app/src/main/kotlin/com/pitchedapps/frost/FrostApp.kt index 5b62afad..41c6ff4b 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/FrostApp.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/FrostApp.kt @@ -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 @@ -81,7 +83,7 @@ class FrostApp : Application() { ) Showcase.initialize(this, "${BuildConfig.APPLICATION_ID}.showcase") Prefs.initialize(this, "${BuildConfig.APPLICATION_ID}.prefs") - // if (LeakCanary.isInAnalyzerProcess(this)) return +// if (LeakCanary.isInAnalyzerProcess(this)) return // refWatcher = LeakCanary.install(this) initBugsnag() KL.shouldLog = { BuildConfig.DEBUG } @@ -132,6 +134,7 @@ class FrostApp : Application() { L.d { "Activity ${activity.localClassName} created" } } }) + startKoin(this, listOf(FrostDatabase.module(this))) } private fun initBugsnag() { diff --git a/app/src/main/kotlin/com/pitchedapps/frost/StartActivity.kt b/app/src/main/kotlin/com/pitchedapps/frost/StartActivity.kt index d0376144..87244864 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/StartActivity.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/StartActivity.kt @@ -32,17 +32,25 @@ 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.saveTabs 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 +58,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 +78,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 +95,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) diff --git a/app/src/main/kotlin/com/pitchedapps/frost/activities/BaseMainActivity.kt b/app/src/main/kotlin/com/pitchedapps/frost/activities/BaseMainActivity.kt index f1d88bc3..c43c31a2 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/activities/BaseMainActivity.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/activities/BaseMainActivity.kt @@ -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 @@ -109,6 +110,7 @@ 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 +122,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 +138,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 +160,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,27 +284,28 @@ abstract class BaseMainActivity : BaseActivity(), MainActivityContract, if (current) launchWebOverlay(FbItem.PROFILE.url) else when (profile.identifier) { -2L -> { - val currentCookie = loadFbCookie(Prefs.userId) - if (currentCookie == null) { - toast(R.string.account_not_found) - launch { + // TODO no backpressure support + this@BaseMainActivity.launch { + val currentCookie = cookieDao.currentCookie() + if (currentCookie == null) { + toast(R.string.account_not_found) 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() + } else { + materialDialogThemed { + title(R.string.kau_logout) + content( + String.format( + 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 { - FbCookie.logout(this@BaseMainActivity) + positiveText(R.string.kau_yes) + negativeText(R.string.kau_no) + onPositive { _, _ -> + this@BaseMainActivity.launch { + FbCookie.logout(this@BaseMainActivity) + } } } } @@ -303,7 +314,7 @@ abstract class BaseMainActivity : BaseActivity(), MainActivityContract, -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() @@ -454,16 +465,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 +525,48 @@ abstract class BaseMainActivity : BaseActivity(), MainActivityContract, runOnUiThread { adapter.reloadFragment(fragment) } } - inner class SectionsPagerAdapter(val pages: List<FbItem>) : FragmentPagerAdapter(supportFragmentManager) { + inner class SectionsPagerAdapter : FragmentPagerAdapter(supportFragmentManager) { + + private val pages: MutableList<FbItem> = mutableListOf() - val forcedFallbacks = mutableSetOf<String>() + 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 +605,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 + } } diff --git a/app/src/main/kotlin/com/pitchedapps/frost/activities/LoginActivity.kt b/app/src/main/kotlin/com/pitchedapps/frost/activities/LoginActivity.kt index 97abf5a2..b80f06f7 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/activities/LoginActivity.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/activities/LoginActivity.kt @@ -32,9 +32,8 @@ 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.facebook.FbCookie import com.pitchedapps.frost.facebook.FbItem import com.pitchedapps.frost.facebook.profilePictureUrl @@ -58,6 +57,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 +71,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 +110,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 +127,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 +135,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 +172,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 { diff --git a/app/src/main/kotlin/com/pitchedapps/frost/activities/MainActivity.kt b/app/src/main/kotlin/com/pitchedapps/frost/activities/MainActivity.kt index a1dba417..34674cb0 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/activities/MainActivity.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/activities/MainActivity.kt @@ -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 }) - ) - } } } diff --git a/app/src/main/kotlin/com/pitchedapps/frost/activities/TabCustomizerActivity.kt b/app/src/main/kotlin/com/pitchedapps/frost/activities/TabCustomizerActivity.kt index 7f632940..6ad7d3f2 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/activities/TabCustomizerActivity.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/activities/TabCustomizerActivity.kt @@ -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,24 +73,30 @@ class TabCustomizerActivity : BaseActivity() { divider.setBackgroundColor(Prefs.textColor.withAlpha(30)) instructions.setTextColor(Prefs.textColor) - val tabs = loadFbTabs().toMutableList() - val remaining = FbItem.values().filter { it.name[0] != '_' }.toMutableList() - remaining.removeAll(tabs) - tabs.addAll(remaining) + 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) + bindSwapper(adapter, tab_recycler) - adapter.withOnClickListener { view, _, _, _ -> view!!.wobble(); true } + 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() - setResult(Activity.RESULT_OK) - finish() + 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) diff --git a/app/src/main/kotlin/com/pitchedapps/frost/db/CacheDb.kt b/app/src/main/kotlin/com/pitchedapps/frost/db/CacheDb.kt new file mode 100644 index 00000000..4906f60a --- /dev/null +++ b/app/src/main/kotlin/com/pitchedapps/frost/db/CacheDb.kt @@ -0,0 +1,77 @@ +/* + * 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") + suspend fun select(id: Long, type: String): CacheEntity? + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertCache(cache: CacheEntity) + + @Query("DELETE FROM frost_cache WHERE id = :id AND type = :type") + suspend fun delete(id: Long, type: String) +} + +/** + * Returns true if successful, given that there are constraints to the insertion + */ +suspend fun CacheDao.save(id: Long, type: String, contents: String): Boolean = + try { + insertCache(CacheEntity(id, type, System.currentTimeMillis(), contents)) + true + } catch (e: Exception) { + L.e(e) { "Cache save failed for $type" } + false + } diff --git a/app/src/main/kotlin/com/pitchedapps/frost/db/CookiesDb.kt b/app/src/main/kotlin/com/pitchedapps/frost/db/CookiesDb.kt new file mode 100644 index 00000000..5aadbb02 --- /dev/null +++ b/app/src/main/kotlin/com/pitchedapps/frost/db/CookiesDb.kt @@ -0,0 +1,85 @@ +/* + * 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") + suspend fun selectAll(): List<CookieEntity> + + @Query("SELECT * FROM cookies WHERE cookie_id = :id") + suspend fun selectById(id: Long): CookieEntity? + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun save(cookie: CookieEntity) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun save(cookies: List<CookieEntity>) + + @Query("DELETE FROM cookies WHERE cookie_id = :id") + suspend fun deleteById(id: Long) +} + +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()})" +} diff --git a/app/src/main/kotlin/com/pitchedapps/frost/db/Database.kt b/app/src/main/kotlin/com/pitchedapps/frost/db/Database.kt new file mode 100644 index 00000000..e1b1d4c4 --- /dev/null +++ b/app/src/main/kotlin/com/pitchedapps/frost/db/Database.kt @@ -0,0 +1,98 @@ +/* + * 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 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 { + fun create(context: Context): FrostDatabase { + val privateDb = Room.databaseBuilder( + context, FrostPrivateDatabase::class.java, + FrostPrivateDatabase.DATABASE_NAME + ).build() + val publicDb = Room.databaseBuilder( + context, FrostPublicDatabase::class.java, + FrostPublicDatabase.DATABASE_NAME + ).build() + 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() + } +} diff --git a/app/src/main/kotlin/com/pitchedapps/frost/dbflow/FbTabsDb.kt b/app/src/main/kotlin/com/pitchedapps/frost/db/FbTabsDb.kt index 9d330169..a704ce82 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/dbflow/FbTabsDb.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/db/FbTabsDb.kt @@ -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() -} diff --git a/app/src/main/kotlin/com/pitchedapps/frost/db/GenericDb.kt b/app/src/main/kotlin/com/pitchedapps/frost/db/GenericDb.kt new file mode 100644 index 00000000..4177ae86 --- /dev/null +++ b/app/src/main/kotlin/com/pitchedapps/frost/db/GenericDb.kt @@ -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") + suspend fun select(type: String): String? + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun save(entity: GenericEntity) + + @Query("DELETE FROM frost_generic WHERE type = :type") + suspend fun delete(type: String) + + companion object { + const val TYPE_TABS = "generic_tabs" + } +} + +suspend fun GenericDao.saveTabs(tabs: List<FbItem>) { + val content = tabs.joinToString(",") { it.name } + save(GenericEntity(GenericDao.TYPE_TABS, content)) +} + +suspend fun GenericDao.getTabs(): List<FbItem> { + val allTabs = FbItem.values.map { it.name to it }.toMap() + return select(GenericDao.TYPE_TABS) + ?.split(",") + ?.mapNotNull { allTabs[it] } + ?.takeIf { it.isNotEmpty() } + ?: defaultTabs() +} diff --git a/app/src/main/kotlin/com/pitchedapps/frost/db/NotificationDb.kt b/app/src/main/kotlin/com/pitchedapps/frost/db/NotificationDb.kt new file mode 100644 index 00000000..e1f7fc76 --- /dev/null +++ b/app/src/main/kotlin/com/pitchedapps/frost/db/NotificationDb.kt @@ -0,0 +1,197 @@ +/* + * 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 +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +@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 +) { + constructor( + type: String, + content: NotificationContent + ) : this( + content.id, + content.data.id, + content.href, + content.title, + content.text, + content.timestamp, + content.profileUrl, + type + ) +} + +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 + ) +} + +@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") + suspend 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.selectNotifications(userId: Long, type: String): List<NotificationContent> = + withContext(Dispatchers.IO) { + _selectNotifications(userId, type).map { it.toNotifContent() } + } + +/** + * 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 withContext(Dispatchers.IO) { + 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 = withContext(Dispatchers.IO) { + _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) diff --git a/app/src/main/kotlin/com/pitchedapps/frost/db/ThreadDb.kt b/app/src/main/kotlin/com/pitchedapps/frost/db/ThreadDb.kt new file mode 100644 index 00000000..d7c91211 --- /dev/null +++ b/app/src/main/kotlin/com/pitchedapps/frost/db/ThreadDb.kt @@ -0,0 +1,115 @@ +/* + * 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.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.db.CookieModel_Table.cookie +import com.pitchedapps.frost.facebook.parsers.FrostThread +import com.pitchedapps.frost.utils.L +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +@Entity( + tableName = "threads", + primaryKeys = ["thread_id", "userId"], + foreignKeys = [ForeignKey( + entity = CookieEntity::class, + parentColumns = ["cookie_id"], + childColumns = ["userId"], + onDelete = ForeignKey.CASCADE + )], + indices = [Index("thread_id"), Index("userId")] +) +data class ThreadEntity( + @Embedded(prefix = "thread_") + val thread: FrostThread, + val userId: Long +) + +data class ThreadContentEntity( + @Embedded + val cookie: CookieEntity, + @Embedded(prefix = "thread_") + val thread: FrostThread +) + +@Dao +interface ThreadDao { + + /** + * Note that notifications are guaranteed to be ordered by descending timestamp + */ + @Transaction + @Query("SELECT * FROM cookies INNER JOIN threads ON cookie_id = userId WHERE userId = :userId ORDER BY thread_time DESC") + fun _selectThreads(userId: Long): List<ThreadContentEntity> + + @Query("SELECT thread_time FROM threads WHERE userId = :userId ORDER BY thread_time DESC LIMIT 1") + fun _selectEpoch(userId: Long): Long? + + @Insert(onConflict = OnConflictStrategy.REPLACE) + fun _insertThreads(notifs: List<ThreadEntity>) + + @Query("DELETE FROM threads WHERE userId = :userId ") + fun _deleteThreads(userId: Long) + + @Query("DELETE FROM threads") + suspend fun deleteAll() + + /** + * It is assumed that the notification batch comes from the same user + */ + @Transaction + fun _saveThreads(userId: Long, notifs: List<FrostThread>) { + val entities = notifs.map { ThreadEntity(it, userId) } + _deleteThreads(userId) + _insertThreads(entities) + } +} + +suspend fun ThreadDao.selectThreads(userId: Long): List<ThreadContentEntity> = + withContext(Dispatchers.IO) { + _selectThreads(userId) + } + +/** + * Returns true if successful, given that there are constraints to the insertion + */ +suspend fun ThreadDao.saveThreads(userId: Long, threads: List<FrostThread>): Boolean { + if (threads.isEmpty()) return true + return withContext(Dispatchers.IO) { + try { + _saveThreads(userId, threads) + true + } catch (e: Exception) { + L.e(e) { "Thread save failed" } + false + } + } +} + +suspend fun ThreadDao.latestEpoch(userId: Long, type: String): Long = + withContext(Dispatchers.IO) { + _selectEpoch(userId) ?: lastNotificationTime(userId).epochIm + } diff --git a/app/src/main/kotlin/com/pitchedapps/frost/dbflow/CookiesDb.kt b/app/src/main/kotlin/com/pitchedapps/frost/dbflow/CookiesDb.kt deleted file mode 100644 index 8411b8d7..00000000 --- a/app/src/main/kotlin/com/pitchedapps/frost/dbflow/CookiesDb.kt +++ /dev/null @@ -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 } - } -} diff --git a/app/src/main/kotlin/com/pitchedapps/frost/dbflow/DbUtils.kt b/app/src/main/kotlin/com/pitchedapps/frost/dbflow/DbUtils.kt deleted file mode 100644 index 740fef62..00000000 --- a/app/src/main/kotlin/com/pitchedapps/frost/dbflow/DbUtils.kt +++ /dev/null @@ -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() -} diff --git a/app/src/main/kotlin/com/pitchedapps/frost/dbflow/NotificationDb.kt b/app/src/main/kotlin/com/pitchedapps/frost/dbflow/NotificationDb.kt deleted file mode 100644 index a054d95e..00000000 --- a/app/src/main/kotlin/com/pitchedapps/frost/dbflow/NotificationDb.kt +++ /dev/null @@ -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() - } -} diff --git a/app/src/main/kotlin/com/pitchedapps/frost/facebook/FbCookie.kt b/app/src/main/kotlin/com/pitchedapps/frost/facebook/FbCookie.kt index 5683526a..6afbea4b 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/facebook/FbCookie.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/facebook/FbCookie.kt @@ -19,10 +19,9 @@ 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.utils.L import com.pitchedapps.frost.utils.Prefs import com.pitchedapps.frost.utils.cookies @@ -50,6 +49,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 +80,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 +96,12 @@ object FbCookie { } } - suspend fun switchUser(id: Long) = switchUser(loadFbCookie(id)) - - suspend fun switchUser(name: String) = switchUser(loadFbCookie(name)) + suspend fun switchUser(id: Long) { + val cookie = cookieDao.selectById(id) ?: return L.e { "No cookie for id" } + switchUser(cookie) + } - suspend fun switchUser(cookie: CookieModel?) { + suspend fun switchUser(cookie: CookieEntity?) { if (cookie == null) { L.d { "Switching User; null cookie" } return @@ -114,7 +118,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 +130,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() } diff --git a/app/src/main/kotlin/com/pitchedapps/frost/facebook/parsers/FrostParser.kt b/app/src/main/kotlin/com/pitchedapps/frost/facebook/parsers/FrostParser.kt index 5709bb9f..4c494e0b 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/facebook/parsers/FrostParser.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/facebook/parsers/FrostParser.kt @@ -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 { diff --git a/app/src/main/kotlin/com/pitchedapps/frost/facebook/parsers/MessageParser.kt b/app/src/main/kotlin/com/pitchedapps/frost/facebook/parsers/MessageParser.kt index f05c42e9..80ed8ee8 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/facebook/parsers/MessageParser.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/facebook/parsers/MessageParser.kt @@ -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( diff --git a/app/src/main/kotlin/com/pitchedapps/frost/facebook/parsers/NotifParser.kt b/app/src/main/kotlin/com/pitchedapps/frost/facebook/parsers/NotifParser.kt index b8aa899b..199fc685 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/facebook/parsers/NotifParser.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/facebook/parsers/NotifParser.kt @@ -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( diff --git a/app/src/main/kotlin/com/pitchedapps/frost/services/FrostNotifications.kt b/app/src/main/kotlin/com/pitchedapps/frost/services/FrostNotifications.kt index 54a9f8ae..6f039784 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/services/FrostNotifications.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/services/FrostNotifications.kt @@ -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 @@ -64,8 +65,6 @@ enum class NotificationType( 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 ); @@ -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(), @@ -257,7 +259,8 @@ 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 diff --git a/app/src/main/kotlin/com/pitchedapps/frost/services/NotificationService.kt b/app/src/main/kotlin/com/pitchedapps/frost/services/NotificationService.kt index b1e0ac8c..2e994577 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/services/NotificationService.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/services/NotificationService.kt @@ -21,8 +21,8 @@ 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.utils.L import com.pitchedapps.frost.utils.Prefs import com.pitchedapps.frost.utils.frostEvent @@ -31,6 +31,7 @@ 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 +43,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 +84,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 @@ -107,7 +110,7 @@ class NotificationService : BaseJobService() { * 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) diff --git a/app/src/main/kotlin/com/pitchedapps/frost/settings/Notifications.kt b/app/src/main/kotlin/com/pitchedapps/frost/settings/Notifications.kt index 0200f109..dafb259f 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/settings/Notifications.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/settings/Notifications.kt @@ -29,14 +29,14 @@ 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.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 +171,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() } } } diff --git a/app/src/main/kotlin/com/pitchedapps/frost/utils/Downloader.kt b/app/src/main/kotlin/com/pitchedapps/frost/utils/Downloader.kt index a8dc11f4..50863e10 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/utils/Downloader.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/utils/Downloader.kt @@ -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) diff --git a/app/src/main/kotlin/com/pitchedapps/frost/utils/L.kt b/app/src/main/kotlin/com/pitchedapps/frost/utils/L.kt index 8364c34e..7c8c1895 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/utils/L.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/utils/L.kt @@ -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) diff --git a/app/src/main/kotlin/com/pitchedapps/frost/utils/Utils.kt b/app/src/main/kotlin/com/pitchedapps/frost/utils/Utils.kt index 7f9ac98b..711d7e18 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/utils/Utils.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/utils/Utils.kt @@ -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() } /** diff --git a/app/src/main/kotlin/com/pitchedapps/frost/views/AccountItem.kt b/app/src/main/kotlin/com/pitchedapps/frost/views/AccountItem.kt index d60ea7ed..0269b1a9 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/views/AccountItem.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/views/AccountItem.kt @@ -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>) { diff --git a/app/src/main/kotlin/com/pitchedapps/frost/views/FrostVideoViewer.kt b/app/src/main/kotlin/com/pitchedapps/frost/views/FrostVideoViewer.kt index c2535940..4d88ad3d 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/views/FrostVideoViewer.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/views/FrostVideoViewer.kt @@ -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 } diff --git a/app/src/main/kotlin/com/pitchedapps/frost/views/FrostWebView.kt b/app/src/main/kotlin/com/pitchedapps/frost/views/FrostWebView.kt index cc8e3fbc..1dd027fd 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/views/FrostWebView.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/views/FrostWebView.kt @@ -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 } diff --git a/app/src/main/kotlin/com/pitchedapps/frost/web/FrostJSI.kt b/app/src/main/kotlin/com/pitchedapps/frost/web/FrostJSI.kt index 50a5e2e1..0d980ba0 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/web/FrostJSI.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/web/FrostJSI.kt @@ -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 diff --git a/app/src/main/kotlin/com/pitchedapps/frost/web/LoginWebView.kt b/app/src/main/kotlin/com/pitchedapps/frost/web/LoginWebView.kt index c27385fc..79c6d5ba 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/web/LoginWebView.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/web/LoginWebView.kt @@ -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?) { diff --git a/app/src/schemas/com.pitchedapps.frost.db.FrostPrivateDatabase/1.json b/app/src/schemas/com.pitchedapps.frost.db.FrostPrivateDatabase/1.json new file mode 100644 index 00000000..60d5cddd --- /dev/null +++ b/app/src/schemas/com.pitchedapps.frost.db.FrostPrivateDatabase/1.json @@ -0,0 +1,189 @@ +{ + "formatVersion": 1, + "database": { + "version": 1, + "identityHash": "0a9d994786b7e07fea95c11d9210ce0e", + "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, 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 + } + ], + "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, \"0a9d994786b7e07fea95c11d9210ce0e\")" + ] + } +}
\ No newline at end of file diff --git a/app/src/schemas/com.pitchedapps.frost.db.FrostPublicDatabase/1.json b/app/src/schemas/com.pitchedapps.frost.db.FrostPublicDatabase/1.json new file mode 100644 index 00000000..4a523c62 --- /dev/null +++ b/app/src/schemas/com.pitchedapps.frost.db.FrostPublicDatabase/1.json @@ -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\")" + ] + } +}
\ No newline at end of file diff --git a/app/src/test/kotlin/com/pitchedapps/frost/debugger/OfflineWebsiteTest.kt b/app/src/test/kotlin/com/pitchedapps/frost/debugger/OfflineWebsiteTest.kt index a2bafba9..0ad60126 100644 --- a/app/src/test/kotlin/com/pitchedapps/frost/debugger/OfflineWebsiteTest.kt +++ b/app/src/test/kotlin/com/pitchedapps/frost/debugger/OfflineWebsiteTest.kt @@ -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 |