mirror of
https://github.com/AllanWang/Frost-for-Facebook.git
synced 2024-11-10 04:52:38 +01:00
commit
4f3e44b7b9
@ -1,7 +1,7 @@
|
||||
# Frost-for-Facebook
|
||||
|
||||
[![Releaes Version](https://img.shields.io/github/release/AllanWang/Frost-for-Facebook.svg)](https://github.com/AllanWang/Frost-for-Facebook/releases)
|
||||
[![Build Status](https://travis-ci.org/AllanWang/Frost-for-Facebook.svg?branch=dev)](https://travis-ci.org/AllanWang/Frost-for-Facebook)
|
||||
[![Build Status](https://travis-ci.com/AllanWang/Frost-for-Facebook.svg?branch=dev)](https://travis-ci.com/AllanWang/Frost-for-Facebook)
|
||||
[![Crowdin](https://d322cqt584bo4o.cloudfront.net/frost-for-facebook/localized.svg)](https://crowdin.com/project/frost-for-facebook)
|
||||
[![ZenHub](https://img.shields.io/badge/Shipping%20faster%20with-ZenHub-45529A.svg)](https://app.zenhub.com/workspace/o/allanwang/frost-for-facebook/boards)
|
||||
[![BugSnag](https://img.shields.io/badge/Bug%20tracking%20with-BugSnag-37C2D9.svg)](https://www.bugsnag.com/)
|
||||
|
@ -27,6 +27,11 @@ android {
|
||||
versionName androidGitVersion.name()
|
||||
multiDexEnabled true
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
javaCompileOptions {
|
||||
annotationProcessorOptions {
|
||||
arguments = ["room.schemaLocation": "$projectDir/src/schemas".toString()]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
applicationVariants.all { variant ->
|
||||
@ -174,6 +179,7 @@ dependencies {
|
||||
androidTestImplementation kauDependency.espresso
|
||||
androidTestImplementation kauDependency.testRules
|
||||
androidTestImplementation kauDependency.testRunner
|
||||
androidTestImplementation "org.jetbrains.kotlin:kotlin-reflect:${KOTLIN}"
|
||||
|
||||
testImplementation kauDependency.kotlinTest
|
||||
testImplementation "org.jetbrains.kotlin:kotlin-reflect:${KOTLIN}"
|
||||
@ -200,9 +206,9 @@ dependencies {
|
||||
|
||||
implementation "androidx.core:core-ktx:${KTX}"
|
||||
|
||||
// implementation "org.koin:koin-android:${KOIN}"
|
||||
// testImplementation "org.koin:koin-test:${KOIN}"
|
||||
// androidTestImplementation "org.koin:koin-test:${KOIN}"
|
||||
implementation "org.koin:koin-android:${KOIN}"
|
||||
testImplementation "org.koin:koin-test:${KOIN}"
|
||||
androidTestImplementation "org.koin:koin-test:${KOIN}"
|
||||
|
||||
// androidTestImplementation "io.mockk:mockk:${MOCKK}"
|
||||
|
||||
@ -253,6 +259,11 @@ dependencies {
|
||||
|
||||
implementation "com.sothree.slidinguppanel:library:${SLIDING_PANEL}"
|
||||
|
||||
implementation "androidx.room:room-coroutines:${ROOM}"
|
||||
implementation "androidx.room:room-runtime:${ROOM}"
|
||||
kapt "androidx.room:room-compiler:${ROOM}"
|
||||
testImplementation "androidx.room:room-testing:${ROOM}"
|
||||
|
||||
}
|
||||
|
||||
// Validates code and generates apk
|
||||
|
@ -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()
|
||||
}
|
||||
}
|
@ -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})"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
@ -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")
|
||||
}
|
||||
}
|
@ -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"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,145 @@
|
||||
/*
|
||||
* Copyright 2019 Allan Wang
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.pitchedapps.frost.db
|
||||
|
||||
import com.pitchedapps.frost.services.NOTIF_CHANNEL_GENERAL
|
||||
import com.pitchedapps.frost.services.NOTIF_CHANNEL_MESSAGES
|
||||
import com.pitchedapps.frost.services.NotificationContent
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class NotificationDbTest : BaseDbTest() {
|
||||
|
||||
private val dao get() = db.notifDao()
|
||||
|
||||
private fun cookie(id: Long) = CookieEntity(id, "name$id", "cookie$id")
|
||||
|
||||
private fun notifContent(id: Long, cookie: CookieEntity, time: Long = id) = NotificationContent(
|
||||
data = cookie,
|
||||
id = id,
|
||||
href = "",
|
||||
title = null,
|
||||
text = "",
|
||||
timestamp = time,
|
||||
profileUrl = null,
|
||||
unread = true
|
||||
)
|
||||
|
||||
@Test
|
||||
fun saveAndRetrieve() {
|
||||
val cookie = cookie(12345L)
|
||||
// Unique unsorted ids
|
||||
val notifs = listOf(0L, 4L, 2L, 6L, 99L, 3L).map { notifContent(it, cookie) }
|
||||
runBlocking {
|
||||
db.cookieDao().save(cookie)
|
||||
dao.saveNotifications(NOTIF_CHANNEL_GENERAL, notifs)
|
||||
val dbNotifs = dao.selectNotifications(cookie.id, NOTIF_CHANNEL_GENERAL)
|
||||
assertEquals(notifs.sortedByDescending { it.timestamp }, dbNotifs, "Incorrect notification list received")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun selectConditions() {
|
||||
runBlocking {
|
||||
val cookie1 = cookie(12345L)
|
||||
val cookie2 = cookie(12L)
|
||||
val notifs1 = (0L..2L).map { notifContent(it, cookie1) }
|
||||
val notifs2 = (5L..10L).map { notifContent(it, cookie2) }
|
||||
db.cookieDao().save(cookie1)
|
||||
db.cookieDao().save(cookie2)
|
||||
dao.saveNotifications(NOTIF_CHANNEL_GENERAL, notifs1)
|
||||
dao.saveNotifications(NOTIF_CHANNEL_MESSAGES, notifs2)
|
||||
assertEquals(
|
||||
emptyList(),
|
||||
dao.selectNotifications(cookie1.id, NOTIF_CHANNEL_MESSAGES),
|
||||
"Filtering by type did not work for cookie1"
|
||||
)
|
||||
assertEquals(
|
||||
notifs1.sortedByDescending { it.timestamp },
|
||||
dao.selectNotifications(cookie1.id, NOTIF_CHANNEL_GENERAL),
|
||||
"Selection for cookie1 failed"
|
||||
)
|
||||
assertEquals(
|
||||
emptyList(),
|
||||
dao.selectNotifications(cookie2.id, NOTIF_CHANNEL_GENERAL),
|
||||
"Filtering by type did not work for cookie2"
|
||||
)
|
||||
assertEquals(
|
||||
notifs2.sortedByDescending { it.timestamp },
|
||||
dao.selectNotifications(cookie2.id, NOTIF_CHANNEL_MESSAGES),
|
||||
"Selection for cookie2 failed"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Primary key is both id and userId, in the event that the same notification to multiple users has the same id
|
||||
*/
|
||||
@Test
|
||||
fun primaryKeyCheck() {
|
||||
runBlocking {
|
||||
val cookie1 = cookie(12345L)
|
||||
val cookie2 = cookie(12L)
|
||||
val notifs1 = (0L..2L).map { notifContent(it, cookie1) }
|
||||
val notifs2 = notifs1.map { it.copy(data = cookie2) }
|
||||
db.cookieDao().save(cookie1)
|
||||
db.cookieDao().save(cookie2)
|
||||
assertTrue(dao.saveNotifications(NOTIF_CHANNEL_GENERAL, notifs1), "Notif1 save failed")
|
||||
assertTrue(dao.saveNotifications(NOTIF_CHANNEL_GENERAL, notifs2), "Notif2 save failed")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun cascadeDeletion() {
|
||||
val cookie = cookie(12345L)
|
||||
// Unique unsorted ids
|
||||
val notifs = listOf(0L, 4L, 2L, 6L, 99L, 3L).map { notifContent(it, cookie) }
|
||||
runBlocking {
|
||||
db.cookieDao().save(cookie)
|
||||
dao.saveNotifications(NOTIF_CHANNEL_GENERAL, notifs)
|
||||
db.cookieDao().deleteById(cookie.id)
|
||||
val dbNotifs = dao.selectNotifications(cookie.id, NOTIF_CHANNEL_GENERAL)
|
||||
assertTrue(dbNotifs.isEmpty(), "Cascade deletion failed")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun latestEpoch() {
|
||||
val cookie = cookie(12345L)
|
||||
// Unique unsorted ids
|
||||
val notifs = listOf(0L, 4L, 2L, 6L, 99L, 3L).map { notifContent(it, cookie) }
|
||||
runBlocking {
|
||||
assertEquals(-1L, dao.latestEpoch(cookie.id, NOTIF_CHANNEL_GENERAL), "Default epoch failed")
|
||||
db.cookieDao().save(cookie)
|
||||
dao.saveNotifications(NOTIF_CHANNEL_GENERAL, notifs)
|
||||
assertEquals(99L, dao.latestEpoch(cookie.id, NOTIF_CHANNEL_GENERAL), "Latest epoch failed")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun insertionWithInvalidCookies() {
|
||||
runBlocking {
|
||||
assertFalse(
|
||||
dao.saveNotifications(NOTIF_CHANNEL_GENERAL, listOf(notifContent(1L, cookie(2L)))),
|
||||
"Notif save should not have passed without relevant cookie entries"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
@ -173,6 +173,20 @@
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<!--Widgets-->
|
||||
<receiver android:name=".widgets.NotificationWidget">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="android.appwidget.provider"
|
||||
android:resource="@xml/notification_widget_info" />
|
||||
</receiver>
|
||||
<service
|
||||
android:name=".widgets.NotificationWidgetService"
|
||||
android:enabled="true"
|
||||
android:permission="android.permission.BIND_REMOTEVIEWS" />
|
||||
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.provider"
|
||||
|
@ -29,9 +29,10 @@ import com.bumptech.glide.request.RequestOptions
|
||||
import com.bumptech.glide.signature.ApplicationVersionSignature
|
||||
import com.mikepenz.materialdrawer.util.AbstractDrawerImageLoader
|
||||
import com.mikepenz.materialdrawer.util.DrawerImageLoader
|
||||
import com.pitchedapps.frost.dbflow.CookiesDb
|
||||
import com.pitchedapps.frost.dbflow.FbTabsDb
|
||||
import com.pitchedapps.frost.dbflow.NotificationDb
|
||||
import com.pitchedapps.frost.db.CookiesDb
|
||||
import com.pitchedapps.frost.db.FbTabsDb
|
||||
import com.pitchedapps.frost.db.FrostDatabase
|
||||
import com.pitchedapps.frost.db.NotificationDb
|
||||
import com.pitchedapps.frost.glide.GlideApp
|
||||
import com.pitchedapps.frost.services.scheduleNotifications
|
||||
import com.pitchedapps.frost.services.setupNotificationChannels
|
||||
@ -44,6 +45,7 @@ import com.raizlabs.android.dbflow.config.DatabaseConfig
|
||||
import com.raizlabs.android.dbflow.config.FlowConfig
|
||||
import com.raizlabs.android.dbflow.config.FlowManager
|
||||
import com.raizlabs.android.dbflow.runtime.ContentResolverNotifier
|
||||
import org.koin.android.ext.android.startKoin
|
||||
import java.util.Random
|
||||
import kotlin.reflect.KClass
|
||||
|
||||
@ -132,6 +134,7 @@ class FrostApp : Application() {
|
||||
L.d { "Activity ${activity.localClassName} created" }
|
||||
}
|
||||
})
|
||||
startKoin(this, listOf(FrostDatabase.module(this)))
|
||||
}
|
||||
|
||||
private fun initBugsnag() {
|
||||
|
@ -32,17 +32,27 @@ import com.mikepenz.google_material_typeface_library.GoogleMaterial
|
||||
import com.pitchedapps.frost.activities.LoginActivity
|
||||
import com.pitchedapps.frost.activities.MainActivity
|
||||
import com.pitchedapps.frost.activities.SelectorActivity
|
||||
import com.pitchedapps.frost.dbflow.CookieModel
|
||||
import com.pitchedapps.frost.dbflow.loadFbCookiesSync
|
||||
import com.pitchedapps.frost.db.CookieDao
|
||||
import com.pitchedapps.frost.db.CookieEntity
|
||||
import com.pitchedapps.frost.db.CookieModel
|
||||
import com.pitchedapps.frost.db.FbTabModel
|
||||
import com.pitchedapps.frost.db.GenericDao
|
||||
import com.pitchedapps.frost.db.getTabs
|
||||
import com.pitchedapps.frost.db.save
|
||||
import com.pitchedapps.frost.db.saveTabs
|
||||
import com.pitchedapps.frost.db.selectAll
|
||||
import com.pitchedapps.frost.facebook.FbCookie
|
||||
import com.pitchedapps.frost.utils.EXTRA_COOKIES
|
||||
import com.pitchedapps.frost.utils.L
|
||||
import com.pitchedapps.frost.utils.Prefs
|
||||
import com.pitchedapps.frost.utils.launchNewTask
|
||||
import com.pitchedapps.frost.utils.loadAssets
|
||||
import com.raizlabs.android.dbflow.kotlinextensions.from
|
||||
import com.raizlabs.android.dbflow.kotlinextensions.select
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.koin.android.ext.android.inject
|
||||
import java.util.ArrayList
|
||||
|
||||
/**
|
||||
@ -50,6 +60,9 @@ import java.util.ArrayList
|
||||
*/
|
||||
class StartActivity : KauBaseActivity() {
|
||||
|
||||
private val cookieDao: CookieDao by inject()
|
||||
private val genericDao: GenericDao by inject()
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
@ -67,12 +80,11 @@ class StartActivity : KauBaseActivity() {
|
||||
|
||||
launch {
|
||||
try {
|
||||
migrate()
|
||||
FbCookie.switchBackUser()
|
||||
val cookies = ArrayList(withContext(Dispatchers.IO) {
|
||||
loadFbCookiesSync()
|
||||
})
|
||||
val cookies = ArrayList(cookieDao.selectAll())
|
||||
L.i { "Cookies loaded at time ${System.currentTimeMillis()}" }
|
||||
L._d { "Cookies: ${cookies.joinToString("\t", transform = CookieModel::toSensitiveString)}" }
|
||||
L._d { "Cookies: ${cookies.joinToString("\t", transform = CookieEntity::toSensitiveString)}" }
|
||||
loadAssets()
|
||||
when {
|
||||
cookies.isEmpty() -> launchNewTask<LoginActivity>()
|
||||
@ -85,11 +97,32 @@ class StartActivity : KauBaseActivity() {
|
||||
})
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
L._e(e) { "Load start failed" }
|
||||
showInvalidWebView()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrate from dbflow to room
|
||||
* TODO delete dbflow data
|
||||
*/
|
||||
private suspend fun migrate() = withContext(Dispatchers.IO) {
|
||||
if (cookieDao.selectAll().isNotEmpty()) return@withContext
|
||||
val cookies = (select from CookieModel::class).queryList().map { CookieEntity(it.id, it.name, it.cookie) }
|
||||
if (cookies.isNotEmpty()) {
|
||||
cookieDao.save(cookies)
|
||||
L._d { "Migrated cookies ${cookieDao.selectAll()}" }
|
||||
}
|
||||
val tabs = (select from FbTabModel::class).queryList().map(FbTabModel::tab)
|
||||
if (tabs.isNotEmpty()) {
|
||||
genericDao.saveTabs(tabs)
|
||||
L._d { "Migrated tabs ${genericDao.getTabs()}" }
|
||||
}
|
||||
deleteDatabase("Cookies.db")
|
||||
deleteDatabase("FrostTabs.db")
|
||||
}
|
||||
|
||||
private fun showInvalidWebView() =
|
||||
showInvalidView(R.string.error_webview)
|
||||
|
||||
|
@ -69,9 +69,10 @@ import com.pitchedapps.frost.contracts.FileChooserContract
|
||||
import com.pitchedapps.frost.contracts.FileChooserDelegate
|
||||
import com.pitchedapps.frost.contracts.MainActivityContract
|
||||
import com.pitchedapps.frost.contracts.VideoViewHolder
|
||||
import com.pitchedapps.frost.dbflow.TAB_COUNT
|
||||
import com.pitchedapps.frost.dbflow.loadFbCookie
|
||||
import com.pitchedapps.frost.dbflow.loadFbTabs
|
||||
import com.pitchedapps.frost.db.CookieDao
|
||||
import com.pitchedapps.frost.db.GenericDao
|
||||
import com.pitchedapps.frost.db.currentCookie
|
||||
import com.pitchedapps.frost.db.getTabs
|
||||
import com.pitchedapps.frost.enums.MainActivityLayout
|
||||
import com.pitchedapps.frost.facebook.FbCookie
|
||||
import com.pitchedapps.frost.facebook.FbItem
|
||||
@ -103,12 +104,14 @@ import com.pitchedapps.frost.utils.setFrostColors
|
||||
import com.pitchedapps.frost.views.BadgedIcon
|
||||
import com.pitchedapps.frost.views.FrostVideoViewer
|
||||
import com.pitchedapps.frost.views.FrostViewPager
|
||||
import com.pitchedapps.frost.widgets.NotificationWidget
|
||||
import kotlinx.android.synthetic.main.activity_frame_wrapper.*
|
||||
import kotlinx.android.synthetic.main.view_main_fab.*
|
||||
import kotlinx.android.synthetic.main.view_main_toolbar.*
|
||||
import kotlinx.android.synthetic.main.view_main_viewpager.*
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.android.ext.android.inject
|
||||
|
||||
/**
|
||||
* Created by Allan Wang on 20/12/17.
|
||||
@ -120,9 +123,14 @@ abstract class BaseMainActivity : BaseActivity(), MainActivityContract,
|
||||
FileChooserContract by FileChooserDelegate(),
|
||||
VideoViewHolder, SearchViewHolder {
|
||||
|
||||
protected lateinit var adapter: SectionsPagerAdapter
|
||||
/**
|
||||
* Note that tabs themselves are initialized through a coroutine during onCreate
|
||||
*/
|
||||
protected val adapter: SectionsPagerAdapter = SectionsPagerAdapter()
|
||||
override val frameWrapper: FrameLayout get() = frame_wrapper
|
||||
val viewPager: FrostViewPager get() = container
|
||||
val cookieDao: CookieDao by inject()
|
||||
val genericDao: GenericDao by inject()
|
||||
|
||||
/*
|
||||
* Components with the same id in multiple layout files
|
||||
@ -131,6 +139,8 @@ abstract class BaseMainActivity : BaseActivity(), MainActivityContract,
|
||||
val appBar: AppBarLayout by bindView(R.id.appbar)
|
||||
val coordinator: CoordinatorLayout by bindView(R.id.main_content)
|
||||
|
||||
protected var lastPosition = -1
|
||||
|
||||
override var videoViewer: FrostVideoViewer? = null
|
||||
private lateinit var drawer: Drawer
|
||||
private lateinit var drawerHeader: AccountHeader
|
||||
@ -151,12 +161,13 @@ abstract class BaseMainActivity : BaseActivity(), MainActivityContract,
|
||||
background(viewPager)
|
||||
}
|
||||
setSupportActionBar(toolbar)
|
||||
adapter = SectionsPagerAdapter(loadFbTabs())
|
||||
viewPager.adapter = adapter
|
||||
viewPager.offscreenPageLimit = TAB_COUNT
|
||||
tabs.setBackgroundColor(Prefs.mainActivityLayout.backgroundColor())
|
||||
onNestedCreate(savedInstanceState)
|
||||
L.i { "Main finished loading UI in ${System.currentTimeMillis() - start} ms" }
|
||||
launch {
|
||||
adapter.setPages(genericDao.getTabs())
|
||||
}
|
||||
controlWebview = WebView(this)
|
||||
if (BuildConfig.VERSION_CODE > Prefs.versionCode) {
|
||||
Prefs.prevVersionCode = Prefs.versionCode
|
||||
@ -274,36 +285,37 @@ abstract class BaseMainActivity : BaseActivity(), MainActivityContract,
|
||||
if (current) launchWebOverlay(FbItem.PROFILE.url)
|
||||
else when (profile.identifier) {
|
||||
-2L -> {
|
||||
val currentCookie = loadFbCookie(Prefs.userId)
|
||||
// TODO no backpressure support
|
||||
this@BaseMainActivity.launch {
|
||||
val currentCookie = cookieDao.currentCookie()
|
||||
if (currentCookie == null) {
|
||||
toast(R.string.account_not_found)
|
||||
launch {
|
||||
FbCookie.reset()
|
||||
launchLogin(cookies(), true)
|
||||
}
|
||||
} else {
|
||||
materialDialogThemed {
|
||||
title(R.string.kau_logout)
|
||||
content(
|
||||
String.format(
|
||||
string(R.string.kau_logout_confirm_as_x), currentCookie.name
|
||||
?: Prefs.userId.toString()
|
||||
string(R.string.kau_logout_confirm_as_x),
|
||||
currentCookie.name ?: Prefs.userId.toString()
|
||||
)
|
||||
)
|
||||
positiveText(R.string.kau_yes)
|
||||
negativeText(R.string.kau_no)
|
||||
onPositive { _, _ ->
|
||||
launch {
|
||||
this@BaseMainActivity.launch {
|
||||
FbCookie.logout(this@BaseMainActivity)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
-3L -> launchNewTask<LoginActivity>(clearStack = false)
|
||||
-4L -> launchNewTask<SelectorActivity>(cookies(), false)
|
||||
else -> {
|
||||
launch {
|
||||
this@BaseMainActivity.launch {
|
||||
FbCookie.switchUser(profile.identifier)
|
||||
tabsForEachView { _, view -> view.badgeText = null }
|
||||
refreshAll()
|
||||
@ -439,7 +451,11 @@ abstract class BaseMainActivity : BaseActivity(), MainActivityContract,
|
||||
Runtime.getRuntime().exit(0)
|
||||
return
|
||||
}
|
||||
if (resultCode and REQUEST_RESTART > 0) return restart()
|
||||
if (resultCode and REQUEST_RESTART > 0) {
|
||||
NotificationWidget.forceUpdate(this)
|
||||
restart()
|
||||
return
|
||||
}
|
||||
/*
|
||||
* These results can be stacked
|
||||
*/
|
||||
@ -454,16 +470,12 @@ abstract class BaseMainActivity : BaseActivity(), MainActivityContract,
|
||||
|
||||
override fun onSaveInstanceState(outState: Bundle) {
|
||||
super.onSaveInstanceState(outState)
|
||||
outState.putStringArrayList(STATE_FORCE_FALLBACK, ArrayList(adapter.forcedFallbacks))
|
||||
adapter.saveInstanceState(outState)
|
||||
}
|
||||
|
||||
override fun onRestoreInstanceState(savedInstanceState: Bundle) {
|
||||
super.onRestoreInstanceState(savedInstanceState)
|
||||
adapter.forcedFallbacks.clear()
|
||||
adapter.forcedFallbacks.addAll(
|
||||
savedInstanceState.getStringArrayList(STATE_FORCE_FALLBACK)
|
||||
?: emptyList()
|
||||
)
|
||||
adapter.restoreInstanceState(savedInstanceState)
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
@ -518,9 +530,48 @@ abstract class BaseMainActivity : BaseActivity(), MainActivityContract,
|
||||
runOnUiThread { adapter.reloadFragment(fragment) }
|
||||
}
|
||||
|
||||
inner class SectionsPagerAdapter(val pages: List<FbItem>) : FragmentPagerAdapter(supportFragmentManager) {
|
||||
inner class SectionsPagerAdapter : FragmentPagerAdapter(supportFragmentManager) {
|
||||
|
||||
val forcedFallbacks = mutableSetOf<String>()
|
||||
private val pages: MutableList<FbItem> = mutableListOf()
|
||||
|
||||
private val forcedFallbacks = mutableSetOf<String>()
|
||||
|
||||
/**
|
||||
* Update page list and prompt reload
|
||||
*/
|
||||
fun setPages(pages: List<FbItem>) {
|
||||
this.pages.clear()
|
||||
this.pages.addAll(pages)
|
||||
notifyDataSetChanged()
|
||||
tabs.removeAllTabs()
|
||||
this.pages.forEachIndexed { index, fbItem ->
|
||||
tabs.addTab(
|
||||
tabs.newTab()
|
||||
.setCustomView(BadgedIcon(this@BaseMainActivity).apply { iicon = fbItem.icon }.also {
|
||||
it.setAllAlpha(if (index == 0) SELECTED_TAB_ALPHA else UNSELECTED_TAB_ALPHA)
|
||||
})
|
||||
)
|
||||
}
|
||||
lastPosition = 0
|
||||
viewPager.setCurrentItem(0, false)
|
||||
viewPager.offscreenPageLimit = pages.size
|
||||
viewPager.post {
|
||||
if (!fragmentChannel.isClosedForSend)
|
||||
fragmentChannel.offer(0)
|
||||
} //trigger hook so title is set
|
||||
}
|
||||
|
||||
fun saveInstanceState(outState: Bundle) {
|
||||
outState.putStringArrayList(STATE_FORCE_FALLBACK, ArrayList(forcedFallbacks))
|
||||
}
|
||||
|
||||
fun restoreInstanceState(savedInstanceState: Bundle) {
|
||||
forcedFallbacks.clear()
|
||||
forcedFallbacks.addAll(
|
||||
savedInstanceState.getStringArrayList(STATE_FORCE_FALLBACK)
|
||||
?: emptyList()
|
||||
)
|
||||
}
|
||||
|
||||
fun reloadFragment(fragment: BaseFragment) {
|
||||
if (fragment is WebFragment) return
|
||||
@ -559,4 +610,9 @@ abstract class BaseMainActivity : BaseActivity(), MainActivityContract,
|
||||
PointF(0f, toolbar.height.toFloat())
|
||||
else
|
||||
PointF(0f, 0f)
|
||||
|
||||
companion object {
|
||||
const val SELECTED_TAB_ALPHA = 255f
|
||||
const val UNSELECTED_TAB_ALPHA = 128f
|
||||
}
|
||||
}
|
||||
|
@ -32,9 +32,10 @@ import com.bumptech.glide.load.engine.GlideException
|
||||
import com.bumptech.glide.request.RequestListener
|
||||
import com.bumptech.glide.request.target.Target
|
||||
import com.pitchedapps.frost.R
|
||||
import com.pitchedapps.frost.dbflow.CookieModel
|
||||
import com.pitchedapps.frost.dbflow.loadFbCookiesSuspend
|
||||
import com.pitchedapps.frost.dbflow.saveFbCookie
|
||||
import com.pitchedapps.frost.db.CookieDao
|
||||
import com.pitchedapps.frost.db.CookieEntity
|
||||
import com.pitchedapps.frost.db.save
|
||||
import com.pitchedapps.frost.db.selectAll
|
||||
import com.pitchedapps.frost.facebook.FbCookie
|
||||
import com.pitchedapps.frost.facebook.FbItem
|
||||
import com.pitchedapps.frost.facebook.profilePictureUrl
|
||||
@ -58,6 +59,7 @@ import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import org.koin.android.ext.android.inject
|
||||
import java.net.UnknownHostException
|
||||
import kotlin.coroutines.resume
|
||||
|
||||
@ -71,6 +73,7 @@ class LoginActivity : BaseActivity() {
|
||||
private val swipeRefresh: SwipeRefreshLayout by bindView(R.id.swipe_refresh)
|
||||
private val textview: AppCompatTextView by bindView(R.id.textview)
|
||||
private val profile: ImageView by bindView(R.id.profile)
|
||||
private val cookieDao: CookieDao by inject()
|
||||
|
||||
private lateinit var profileLoader: RequestManager
|
||||
private val refreshChannel = Channel<Boolean>(10)
|
||||
@ -109,13 +112,13 @@ class LoginActivity : BaseActivity() {
|
||||
refreshChannel.offer(refreshing)
|
||||
}
|
||||
|
||||
private suspend fun loadInfo(cookie: CookieModel): Unit = withMainContext {
|
||||
private suspend fun loadInfo(cookie: CookieEntity): Unit = withMainContext {
|
||||
refresh(true)
|
||||
|
||||
val imageDeferred = async { loadProfile(cookie.id) }
|
||||
val nameDeferred = async { loadUsername(cookie) }
|
||||
|
||||
val name: String = nameDeferred.await()
|
||||
val name: String? = nameDeferred.await()
|
||||
val foundImage: Boolean = imageDeferred.await()
|
||||
|
||||
L._d { "Logged in and received data" }
|
||||
@ -126,7 +129,7 @@ class LoginActivity : BaseActivity() {
|
||||
L._i { cookie }
|
||||
}
|
||||
|
||||
textview.text = String.format(getString(R.string.welcome), name)
|
||||
textview.text = String.format(getString(R.string.welcome), name ?: "")
|
||||
textview.fadeIn()
|
||||
frostEvent("Login", "success" to true)
|
||||
|
||||
@ -134,7 +137,7 @@ class LoginActivity : BaseActivity() {
|
||||
* The user may have logged into an account that is already in the database
|
||||
* We will let the db handle duplicates and load it now after the new account has been saved
|
||||
*/
|
||||
val cookies = ArrayList(loadFbCookiesSuspend())
|
||||
val cookies = ArrayList(cookieDao.selectAll())
|
||||
delay(1000)
|
||||
if (Showcase.intro)
|
||||
launchNewTask<IntroActivity>(cookies, true)
|
||||
@ -171,23 +174,23 @@ class LoginActivity : BaseActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun loadUsername(cookie: CookieModel): String = withContext(Dispatchers.IO) {
|
||||
val result: String = try {
|
||||
private suspend fun loadUsername(cookie: CookieEntity): String? = withContext(Dispatchers.IO) {
|
||||
val result: String? = try {
|
||||
withTimeout(5000) {
|
||||
frostJsoup(cookie.cookie, FbItem.PROFILE.url).title()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (e !is UnknownHostException)
|
||||
e.logFrostEvent("Fetch username failed")
|
||||
""
|
||||
null
|
||||
}
|
||||
|
||||
if (cookie.name?.isNotBlank() == false && result != cookie.name) {
|
||||
cookie.name = result
|
||||
saveFbCookie(cookie)
|
||||
if (result != null) {
|
||||
cookieDao.save(cookie.copy(name = result))
|
||||
return@withContext result
|
||||
}
|
||||
|
||||
cookie.name ?: ""
|
||||
return@withContext cookie.name
|
||||
}
|
||||
|
||||
override fun backConsumer(): Boolean {
|
||||
|
@ -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 })
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -25,6 +25,7 @@ import androidx.recyclerview.widget.GridLayoutManager
|
||||
import androidx.recyclerview.widget.ItemTouchHelper
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import ca.allanwang.kau.kotlin.lazyContext
|
||||
import ca.allanwang.kau.utils.launchMain
|
||||
import ca.allanwang.kau.utils.scaleXY
|
||||
import ca.allanwang.kau.utils.setIcon
|
||||
import ca.allanwang.kau.utils.withAlpha
|
||||
@ -33,14 +34,19 @@ import com.mikepenz.fastadapter_extensions.drag.ItemTouchCallback
|
||||
import com.mikepenz.fastadapter_extensions.drag.SimpleDragCallback
|
||||
import com.mikepenz.google_material_typeface_library.GoogleMaterial
|
||||
import com.pitchedapps.frost.R
|
||||
import com.pitchedapps.frost.dbflow.TAB_COUNT
|
||||
import com.pitchedapps.frost.dbflow.loadFbTabs
|
||||
import com.pitchedapps.frost.dbflow.save
|
||||
import com.pitchedapps.frost.db.GenericDao
|
||||
import com.pitchedapps.frost.db.TAB_COUNT
|
||||
import com.pitchedapps.frost.db.getTabs
|
||||
import com.pitchedapps.frost.db.saveTabs
|
||||
import com.pitchedapps.frost.facebook.FbItem
|
||||
import com.pitchedapps.frost.iitems.TabIItem
|
||||
import com.pitchedapps.frost.utils.L
|
||||
import com.pitchedapps.frost.utils.Prefs
|
||||
import com.pitchedapps.frost.utils.setFrostColors
|
||||
import kotlinx.android.synthetic.main.activity_tab_customizer.*
|
||||
import kotlinx.coroutines.NonCancellable
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.android.ext.android.inject
|
||||
import java.util.Collections
|
||||
|
||||
/**
|
||||
@ -48,6 +54,8 @@ import java.util.Collections
|
||||
*/
|
||||
class TabCustomizerActivity : BaseActivity() {
|
||||
|
||||
private val genericDao: GenericDao by inject()
|
||||
|
||||
private val adapter = FastItemAdapter<TabIItem>()
|
||||
|
||||
private val wobble = lazyContext { AnimationUtils.loadAnimation(it, R.anim.rotate_delta) }
|
||||
@ -65,25 +73,31 @@ class TabCustomizerActivity : BaseActivity() {
|
||||
divider.setBackgroundColor(Prefs.textColor.withAlpha(30))
|
||||
instructions.setTextColor(Prefs.textColor)
|
||||
|
||||
val tabs = loadFbTabs().toMutableList()
|
||||
launch {
|
||||
val tabs = genericDao.getTabs().toMutableList()
|
||||
L.d { "Tabs $tabs" }
|
||||
val remaining = FbItem.values().filter { it.name[0] != '_' }.toMutableList()
|
||||
remaining.removeAll(tabs)
|
||||
tabs.addAll(remaining)
|
||||
adapter.set(tabs.map(::TabIItem))
|
||||
|
||||
adapter.add(tabs.map(::TabIItem))
|
||||
bindSwapper(adapter, tab_recycler)
|
||||
|
||||
adapter.withOnClickListener { view, _, _, _ -> view!!.wobble(); true }
|
||||
}
|
||||
|
||||
setResult(Activity.RESULT_CANCELED)
|
||||
|
||||
fab_save.setIcon(GoogleMaterial.Icon.gmd_check, Prefs.iconColor)
|
||||
fab_save.backgroundTintList = ColorStateList.valueOf(Prefs.accentColor)
|
||||
fab_save.setOnClickListener {
|
||||
adapter.adapterItems.subList(0, TAB_COUNT).map(TabIItem::item).save()
|
||||
launchMain(NonCancellable) {
|
||||
val tabs = adapter.adapterItems.subList(0, TAB_COUNT).map(TabIItem::item)
|
||||
genericDao.saveTabs(tabs)
|
||||
setResult(Activity.RESULT_OK)
|
||||
finish()
|
||||
}
|
||||
}
|
||||
fab_cancel.setIcon(GoogleMaterial.Icon.gmd_close, Prefs.iconColor)
|
||||
fab_cancel.backgroundTintList = ColorStateList.valueOf(Prefs.accentColor)
|
||||
fab_cancel.setOnClickListener { finish() }
|
||||
|
86
app/src/main/kotlin/com/pitchedapps/frost/db/CacheDb.kt
Normal file
86
app/src/main/kotlin/com/pitchedapps/frost/db/CacheDb.kt
Normal file
@ -0,0 +1,86 @@
|
||||
/*
|
||||
* Copyright 2018 Allan Wang
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.pitchedapps.frost.db
|
||||
|
||||
import android.os.Parcelable
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Entity
|
||||
import androidx.room.ForeignKey
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import com.pitchedapps.frost.utils.L
|
||||
import kotlinx.android.parcel.Parcelize
|
||||
|
||||
/**
|
||||
* Created by Allan Wang on 2017-05-30.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Generic cache to store serialized content
|
||||
*/
|
||||
@Entity(
|
||||
tableName = "frost_cache",
|
||||
primaryKeys = ["id", "type"],
|
||||
foreignKeys = [ForeignKey(
|
||||
entity = CookieEntity::class,
|
||||
parentColumns = ["cookie_id"],
|
||||
childColumns = ["id"],
|
||||
onDelete = ForeignKey.CASCADE
|
||||
)]
|
||||
)
|
||||
@Parcelize
|
||||
data class CacheEntity(
|
||||
val id: Long,
|
||||
val type: String,
|
||||
val lastUpdated: Long,
|
||||
val contents: String
|
||||
) : Parcelable
|
||||
|
||||
@Dao
|
||||
interface CacheDao {
|
||||
|
||||
@Query("SELECT * FROM frost_cache WHERE id = :id AND type = :type")
|
||||
fun _select(id: Long, type: String): CacheEntity?
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
fun _insertCache(cache: CacheEntity)
|
||||
|
||||
@Query("DELETE FROM frost_cache WHERE id = :id AND type = :type")
|
||||
fun _delete(id: Long, type: String)
|
||||
}
|
||||
|
||||
suspend fun CacheDao.select(id: Long, type: String) = dao {
|
||||
_select(id, type)
|
||||
}
|
||||
|
||||
suspend fun CacheDao.delete(id: Long, type: String) = dao {
|
||||
_delete(id, type)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if successful, given that there are constraints to the insertion
|
||||
*/
|
||||
suspend fun CacheDao.save(id: Long, type: String, contents: String): Boolean = dao {
|
||||
try {
|
||||
_insertCache(CacheEntity(id, type, System.currentTimeMillis(), contents))
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
L.e(e) { "Cache save failed for $type" }
|
||||
false
|
||||
}
|
||||
}
|
90
app/src/main/kotlin/com/pitchedapps/frost/db/CookiesDb.kt
Normal file
90
app/src/main/kotlin/com/pitchedapps/frost/db/CookiesDb.kt
Normal file
@ -0,0 +1,90 @@
|
||||
/*
|
||||
* Copyright 2018 Allan Wang
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.pitchedapps.frost.db
|
||||
|
||||
import android.os.Parcelable
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Entity
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import com.pitchedapps.frost.utils.Prefs
|
||||
import com.raizlabs.android.dbflow.annotation.ConflictAction
|
||||
import com.raizlabs.android.dbflow.annotation.Database
|
||||
import com.raizlabs.android.dbflow.annotation.PrimaryKey
|
||||
import com.raizlabs.android.dbflow.annotation.Table
|
||||
import com.raizlabs.android.dbflow.structure.BaseModel
|
||||
import kotlinx.android.parcel.Parcelize
|
||||
|
||||
/**
|
||||
* Created by Allan Wang on 2017-05-30.
|
||||
*/
|
||||
|
||||
@Entity(tableName = "cookies")
|
||||
@Parcelize
|
||||
data class CookieEntity(
|
||||
@androidx.room.PrimaryKey
|
||||
@ColumnInfo(name = "cookie_id")
|
||||
val id: Long,
|
||||
val name: String?,
|
||||
val cookie: String?
|
||||
) : Parcelable {
|
||||
override fun toString(): String = "CookieEntity(${hashCode()})"
|
||||
|
||||
fun toSensitiveString(): String = "CookieEntity(id=$id, name=$name, cookie=$cookie)"
|
||||
}
|
||||
|
||||
@Dao
|
||||
interface CookieDao {
|
||||
|
||||
@Query("SELECT * FROM cookies")
|
||||
fun _selectAll(): List<CookieEntity>
|
||||
|
||||
@Query("SELECT * FROM cookies WHERE cookie_id = :id")
|
||||
fun _selectById(id: Long): CookieEntity?
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
fun _save(cookie: CookieEntity)
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
fun _save(cookies: List<CookieEntity>)
|
||||
|
||||
@Query("DELETE FROM cookies WHERE cookie_id = :id")
|
||||
fun _deleteById(id: Long)
|
||||
}
|
||||
|
||||
suspend fun CookieDao.selectAll() = dao { _selectAll() }
|
||||
suspend fun CookieDao.selectById(id: Long) = dao { _selectById(id) }
|
||||
suspend fun CookieDao.save(cookie: CookieEntity) = dao { _save(cookie) }
|
||||
suspend fun CookieDao.save(cookies: List<CookieEntity>) = dao { _save(cookies) }
|
||||
suspend fun CookieDao.deleteById(id: Long) = dao { _deleteById(id) }
|
||||
suspend fun CookieDao.currentCookie() = selectById(Prefs.userId)
|
||||
|
||||
@Database(version = CookiesDb.VERSION)
|
||||
object CookiesDb {
|
||||
const val NAME = "Cookies"
|
||||
const val VERSION = 2
|
||||
}
|
||||
|
||||
@Parcelize
|
||||
@Table(database = CookiesDb::class, allFields = true, primaryKeyConflict = ConflictAction.REPLACE)
|
||||
data class CookieModel(@PrimaryKey var id: Long = -1L, var name: String? = null, var cookie: String? = null) :
|
||||
BaseModel(), Parcelable {
|
||||
|
||||
override fun toString(): String = "CookieModel(${hashCode()})"
|
||||
}
|
28
app/src/main/kotlin/com/pitchedapps/frost/db/DaoUtils.kt
Normal file
28
app/src/main/kotlin/com/pitchedapps/frost/db/DaoUtils.kt
Normal file
@ -0,0 +1,28 @@
|
||||
/*
|
||||
* Copyright 2019 Allan Wang
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.pitchedapps.frost.db
|
||||
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* Wraps dao calls to work with coroutines
|
||||
* Non transactional queries were supposed to be fixed in https://issuetracker.google.com/issues/69474692,
|
||||
* but it still requires dispatch from a non ui thread.
|
||||
* This avoids that constraint
|
||||
*/
|
||||
suspend inline fun <T> dao(crossinline block: () -> T) = withContext(Dispatchers.IO) { block() }
|
106
app/src/main/kotlin/com/pitchedapps/frost/db/Database.kt
Normal file
106
app/src/main/kotlin/com/pitchedapps/frost/db/Database.kt
Normal file
@ -0,0 +1,106 @@
|
||||
/*
|
||||
* Copyright 2019 Allan Wang
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.pitchedapps.frost.db
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room.Database
|
||||
import androidx.room.Room
|
||||
import androidx.room.RoomDatabase
|
||||
import com.pitchedapps.frost.BuildConfig
|
||||
import org.koin.dsl.module.module
|
||||
import org.koin.standalone.StandAloneContext
|
||||
|
||||
interface FrostPrivateDao {
|
||||
fun cookieDao(): CookieDao
|
||||
fun notifDao(): NotificationDao
|
||||
fun cacheDao(): CacheDao
|
||||
}
|
||||
|
||||
@Database(
|
||||
entities = [CookieEntity::class, NotificationEntity::class, CacheEntity::class],
|
||||
version = 1,
|
||||
exportSchema = true
|
||||
)
|
||||
abstract class FrostPrivateDatabase : RoomDatabase(), FrostPrivateDao {
|
||||
companion object {
|
||||
const val DATABASE_NAME = "frost-priv-db"
|
||||
}
|
||||
}
|
||||
|
||||
interface FrostPublicDao {
|
||||
fun genericDao(): GenericDao
|
||||
}
|
||||
|
||||
@Database(entities = [GenericEntity::class], version = 1, exportSchema = true)
|
||||
abstract class FrostPublicDatabase : RoomDatabase(), FrostPublicDao {
|
||||
companion object {
|
||||
const val DATABASE_NAME = "frost-db"
|
||||
}
|
||||
}
|
||||
|
||||
interface FrostDao : FrostPrivateDao, FrostPublicDao {
|
||||
fun close()
|
||||
}
|
||||
|
||||
/**
|
||||
* Composition of all database interfaces
|
||||
*/
|
||||
class FrostDatabase(private val privateDb: FrostPrivateDatabase, private val publicDb: FrostPublicDatabase) :
|
||||
FrostDao,
|
||||
FrostPrivateDao by privateDb,
|
||||
FrostPublicDao by publicDb {
|
||||
|
||||
override fun close() {
|
||||
privateDb.close()
|
||||
publicDb.close()
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
private fun <T : RoomDatabase> RoomDatabase.Builder<T>.frostBuild() = if (BuildConfig.DEBUG) {
|
||||
fallbackToDestructiveMigration().build()
|
||||
} else {
|
||||
build()
|
||||
}
|
||||
|
||||
fun create(context: Context): FrostDatabase {
|
||||
val privateDb = Room.databaseBuilder(
|
||||
context, FrostPrivateDatabase::class.java,
|
||||
FrostPrivateDatabase.DATABASE_NAME
|
||||
).frostBuild()
|
||||
val publicDb = Room.databaseBuilder(
|
||||
context, FrostPublicDatabase::class.java,
|
||||
FrostPublicDatabase.DATABASE_NAME
|
||||
).frostBuild()
|
||||
return FrostDatabase(privateDb, publicDb)
|
||||
}
|
||||
|
||||
fun module(context: Context) = module {
|
||||
single { create(context) }
|
||||
single { get<FrostDatabase>().cookieDao() }
|
||||
single { get<FrostDatabase>().cacheDao() }
|
||||
single { get<FrostDatabase>().notifDao() }
|
||||
single { get<FrostDatabase>().genericDao() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Get from koin
|
||||
* For the most part, you can retrieve directly from other koin components
|
||||
*/
|
||||
fun get(): FrostDatabase = StandAloneContext.getKoin().koinContext.get()
|
||||
}
|
||||
}
|
@ -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()
|
||||
}
|
71
app/src/main/kotlin/com/pitchedapps/frost/db/GenericDb.kt
Normal file
71
app/src/main/kotlin/com/pitchedapps/frost/db/GenericDb.kt
Normal file
@ -0,0 +1,71 @@
|
||||
/*
|
||||
* Copyright 2018 Allan Wang
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.pitchedapps.frost.db
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Entity
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.PrimaryKey
|
||||
import androidx.room.Query
|
||||
import com.pitchedapps.frost.facebook.FbItem
|
||||
import com.pitchedapps.frost.facebook.defaultTabs
|
||||
|
||||
/**
|
||||
* Created by Allan Wang on 2017-05-30.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Generic cache to store serialized content
|
||||
*/
|
||||
@Entity(tableName = "frost_generic")
|
||||
data class GenericEntity(
|
||||
@PrimaryKey
|
||||
val type: String,
|
||||
val contents: String
|
||||
)
|
||||
|
||||
@Dao
|
||||
interface GenericDao {
|
||||
|
||||
@Query("SELECT contents FROM frost_generic WHERE type = :type")
|
||||
fun _select(type: String): String?
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
fun _save(entity: GenericEntity)
|
||||
|
||||
@Query("DELETE FROM frost_generic WHERE type = :type")
|
||||
fun _delete(type: String)
|
||||
|
||||
companion object {
|
||||
const val TYPE_TABS = "generic_tabs"
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun GenericDao.saveTabs(tabs: List<FbItem>) = dao {
|
||||
val content = tabs.joinToString(",") { it.name }
|
||||
_save(GenericEntity(GenericDao.TYPE_TABS, content))
|
||||
}
|
||||
|
||||
suspend fun GenericDao.getTabs(): List<FbItem> = dao {
|
||||
val allTabs = FbItem.values.map { it.name to it }.toMap()
|
||||
_select(GenericDao.TYPE_TABS)
|
||||
?.split(",")
|
||||
?.mapNotNull { allTabs[it] }
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
?: defaultTabs()
|
||||
}
|
202
app/src/main/kotlin/com/pitchedapps/frost/db/NotificationDb.kt
Normal file
202
app/src/main/kotlin/com/pitchedapps/frost/db/NotificationDb.kt
Normal file
@ -0,0 +1,202 @@
|
||||
/*
|
||||
* Copyright 2018 Allan Wang
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.pitchedapps.frost.db
|
||||
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Embedded
|
||||
import androidx.room.Entity
|
||||
import androidx.room.ForeignKey
|
||||
import androidx.room.Index
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import androidx.room.Transaction
|
||||
import com.pitchedapps.frost.services.NOTIF_CHANNEL_GENERAL
|
||||
import com.pitchedapps.frost.services.NOTIF_CHANNEL_MESSAGES
|
||||
import com.pitchedapps.frost.services.NotificationContent
|
||||
import com.pitchedapps.frost.utils.L
|
||||
import com.raizlabs.android.dbflow.annotation.ConflictAction
|
||||
import com.raizlabs.android.dbflow.annotation.Database
|
||||
import com.raizlabs.android.dbflow.annotation.Migration
|
||||
import com.raizlabs.android.dbflow.annotation.PrimaryKey
|
||||
import com.raizlabs.android.dbflow.annotation.Table
|
||||
import com.raizlabs.android.dbflow.kotlinextensions.eq
|
||||
import com.raizlabs.android.dbflow.kotlinextensions.from
|
||||
import com.raizlabs.android.dbflow.kotlinextensions.select
|
||||
import com.raizlabs.android.dbflow.kotlinextensions.where
|
||||
import com.raizlabs.android.dbflow.sql.SQLiteType
|
||||
import com.raizlabs.android.dbflow.sql.migration.AlterTableMigration
|
||||
import com.raizlabs.android.dbflow.structure.BaseModel
|
||||
|
||||
@Entity(
|
||||
tableName = "notifications",
|
||||
primaryKeys = ["notif_id", "userId"],
|
||||
foreignKeys = [ForeignKey(
|
||||
entity = CookieEntity::class,
|
||||
parentColumns = ["cookie_id"],
|
||||
childColumns = ["userId"],
|
||||
onDelete = ForeignKey.CASCADE
|
||||
)],
|
||||
indices = [Index("notif_id"), Index("userId")]
|
||||
)
|
||||
data class NotificationEntity(
|
||||
@ColumnInfo(name = "notif_id")
|
||||
val id: Long,
|
||||
val userId: Long,
|
||||
val href: String,
|
||||
val title: String?,
|
||||
val text: String,
|
||||
val timestamp: Long,
|
||||
val profileUrl: String?,
|
||||
// Type essentially refers to channel
|
||||
val type: String,
|
||||
val unread: Boolean
|
||||
) {
|
||||
constructor(
|
||||
type: String,
|
||||
content: NotificationContent
|
||||
) : this(
|
||||
content.id,
|
||||
content.data.id,
|
||||
content.href,
|
||||
content.title,
|
||||
content.text,
|
||||
content.timestamp,
|
||||
content.profileUrl,
|
||||
type,
|
||||
content.unread
|
||||
)
|
||||
}
|
||||
|
||||
data class NotificationContentEntity(
|
||||
@Embedded
|
||||
val cookie: CookieEntity,
|
||||
@Embedded
|
||||
val notif: NotificationEntity
|
||||
) {
|
||||
fun toNotifContent() = NotificationContent(
|
||||
data = cookie,
|
||||
id = notif.id,
|
||||
href = notif.href,
|
||||
title = notif.title,
|
||||
text = notif.text,
|
||||
timestamp = notif.timestamp,
|
||||
profileUrl = notif.profileUrl,
|
||||
unread = notif.unread
|
||||
)
|
||||
}
|
||||
|
||||
@Dao
|
||||
interface NotificationDao {
|
||||
|
||||
/**
|
||||
* Note that notifications are guaranteed to be ordered by descending timestamp
|
||||
*/
|
||||
@Transaction
|
||||
@Query("SELECT * FROM cookies INNER JOIN notifications ON cookie_id = userId WHERE userId = :userId AND type = :type ORDER BY timestamp DESC")
|
||||
fun _selectNotifications(userId: Long, type: String): List<NotificationContentEntity>
|
||||
|
||||
@Query("SELECT timestamp FROM notifications WHERE userId = :userId AND type = :type ORDER BY timestamp DESC LIMIT 1")
|
||||
fun _selectEpoch(userId: Long, type: String): Long?
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
fun _insertNotifications(notifs: List<NotificationEntity>)
|
||||
|
||||
@Query("DELETE FROM notifications WHERE userId = :userId AND type = :type")
|
||||
fun _deleteNotifications(userId: Long, type: String)
|
||||
|
||||
@Query("DELETE FROM notifications")
|
||||
fun _deleteAll()
|
||||
|
||||
/**
|
||||
* It is assumed that the notification batch comes from the same user
|
||||
*/
|
||||
@Transaction
|
||||
fun _saveNotifications(type: String, notifs: List<NotificationContent>) {
|
||||
val userId = notifs.firstOrNull()?.data?.id ?: return
|
||||
val entities = notifs.map { NotificationEntity(type, it) }
|
||||
_deleteNotifications(userId, type)
|
||||
_insertNotifications(entities)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun NotificationDao.deleteAll() = dao { _deleteAll() }
|
||||
|
||||
fun NotificationDao.selectNotificationsSync(userId: Long, type: String): List<NotificationContent> =
|
||||
_selectNotifications(userId, type).map { it.toNotifContent() }
|
||||
|
||||
suspend fun NotificationDao.selectNotifications(userId: Long, type: String): List<NotificationContent> = dao {
|
||||
selectNotificationsSync(userId, type)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if successful, given that there are constraints to the insertion
|
||||
*/
|
||||
suspend fun NotificationDao.saveNotifications(type: String, notifs: List<NotificationContent>): Boolean {
|
||||
if (notifs.isEmpty()) return true
|
||||
return dao {
|
||||
try {
|
||||
_saveNotifications(type, notifs)
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
L.e(e) { "Notif save failed for $type" }
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun NotificationDao.latestEpoch(userId: Long, type: String): Long = dao {
|
||||
_selectEpoch(userId, type) ?: lastNotificationTime(userId).let {
|
||||
when (type) {
|
||||
NOTIF_CHANNEL_GENERAL -> it.epoch
|
||||
NOTIF_CHANNEL_MESSAGES -> it.epochIm
|
||||
else -> -1L
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Created by Allan Wang on 2017-05-30.
|
||||
*/
|
||||
|
||||
@Database(version = NotificationDb.VERSION)
|
||||
object NotificationDb {
|
||||
const val NAME = "Notifications"
|
||||
const val VERSION = 2
|
||||
}
|
||||
|
||||
@Migration(version = 2, database = NotificationDb::class)
|
||||
class NotificationMigration2(modelClass: Class<NotificationModel>) :
|
||||
AlterTableMigration<NotificationModel>(modelClass) {
|
||||
override fun onPreMigrate() {
|
||||
super.onPreMigrate()
|
||||
addColumn(SQLiteType.INTEGER, "epochIm")
|
||||
L.d { "Added column" }
|
||||
}
|
||||
}
|
||||
|
||||
@Table(database = NotificationDb::class, allFields = true, primaryKeyConflict = ConflictAction.REPLACE)
|
||||
data class NotificationModel(
|
||||
@PrimaryKey var id: Long = -1L,
|
||||
var epoch: Long = -1L,
|
||||
var epochIm: Long = -1L
|
||||
) : BaseModel()
|
||||
|
||||
internal fun lastNotificationTime(id: Long): NotificationModel =
|
||||
(select from NotificationModel::class where (NotificationModel_Table.id eq id)).querySingle()
|
||||
?: NotificationModel(id = id)
|
@ -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 }
|
||||
}
|
||||
}
|
@ -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()
|
||||
}
|
@ -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()
|
||||
}
|
||||
}
|
@ -19,10 +19,12 @@ package com.pitchedapps.frost.facebook
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.webkit.CookieManager
|
||||
import com.pitchedapps.frost.dbflow.CookieModel
|
||||
import com.pitchedapps.frost.dbflow.loadFbCookie
|
||||
import com.pitchedapps.frost.dbflow.removeCookie
|
||||
import com.pitchedapps.frost.dbflow.saveFbCookie
|
||||
import com.pitchedapps.frost.db.CookieDao
|
||||
import com.pitchedapps.frost.db.CookieEntity
|
||||
import com.pitchedapps.frost.db.FrostDatabase
|
||||
import com.pitchedapps.frost.db.deleteById
|
||||
import com.pitchedapps.frost.db.save
|
||||
import com.pitchedapps.frost.db.selectById
|
||||
import com.pitchedapps.frost.utils.L
|
||||
import com.pitchedapps.frost.utils.Prefs
|
||||
import com.pitchedapps.frost.utils.cookies
|
||||
@ -50,6 +52,10 @@ object FbCookie {
|
||||
inline val webCookie: String?
|
||||
get() = CookieManager.getInstance().getCookie(COOKIE_DOMAIN)
|
||||
|
||||
private val cookieDao: CookieDao by lazy {
|
||||
FrostDatabase.get().cookieDao()
|
||||
}
|
||||
|
||||
private suspend fun CookieManager.suspendSetWebCookie(cookie: String?): Boolean {
|
||||
cookie ?: return true
|
||||
return withContext(NonCancellable) {
|
||||
@ -77,12 +83,12 @@ object FbCookie {
|
||||
}
|
||||
}
|
||||
|
||||
fun save(id: Long) {
|
||||
suspend fun save(id: Long) {
|
||||
L.d { "New cookie found" }
|
||||
Prefs.userId = id
|
||||
CookieManager.getInstance().flush()
|
||||
val cookie = CookieModel(Prefs.userId, "", webCookie)
|
||||
saveFbCookie(cookie)
|
||||
val cookie = CookieEntity(Prefs.userId, null, webCookie)
|
||||
cookieDao.save(cookie)
|
||||
}
|
||||
|
||||
suspend fun reset() {
|
||||
@ -93,11 +99,12 @@ object FbCookie {
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun switchUser(id: Long) = switchUser(loadFbCookie(id))
|
||||
suspend fun switchUser(id: Long) {
|
||||
val cookie = cookieDao.selectById(id) ?: return L.e { "No cookie for id" }
|
||||
switchUser(cookie)
|
||||
}
|
||||
|
||||
suspend fun switchUser(name: String) = switchUser(loadFbCookie(name))
|
||||
|
||||
suspend fun switchUser(cookie: CookieModel?) {
|
||||
suspend fun switchUser(cookie: CookieEntity?) {
|
||||
if (cookie == null) {
|
||||
L.d { "Switching User; null cookie" }
|
||||
return
|
||||
@ -114,7 +121,7 @@ object FbCookie {
|
||||
* and launch the proper login page
|
||||
*/
|
||||
suspend fun logout(context: Context) {
|
||||
val cookies = arrayListOf<CookieModel>()
|
||||
val cookies = arrayListOf<CookieEntity>()
|
||||
if (context is Activity)
|
||||
cookies.addAll(context.cookies().filter { it.id != Prefs.userId })
|
||||
logout(Prefs.userId)
|
||||
@ -126,7 +133,9 @@ object FbCookie {
|
||||
*/
|
||||
suspend fun logout(id: Long) {
|
||||
L.d { "Logging out user" }
|
||||
removeCookie(id)
|
||||
cookieDao.deleteById(id)
|
||||
L.d { "Fb cookie deleted" }
|
||||
L._d { id }
|
||||
reset()
|
||||
}
|
||||
|
||||
|
@ -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 {
|
||||
|
@ -16,7 +16,7 @@
|
||||
*/
|
||||
package com.pitchedapps.frost.facebook.parsers
|
||||
|
||||
import com.pitchedapps.frost.dbflow.CookieModel
|
||||
import com.pitchedapps.frost.db.CookieEntity
|
||||
import com.pitchedapps.frost.facebook.FB_EPOCH_MATCHER
|
||||
import com.pitchedapps.frost.facebook.FB_MESSAGE_NOTIF_ID_MATCHER
|
||||
import com.pitchedapps.frost.facebook.FbItem
|
||||
@ -54,7 +54,7 @@ data class FrostMessages(
|
||||
append("}")
|
||||
}.toString()
|
||||
|
||||
override fun getUnreadNotifications(data: CookieModel) =
|
||||
override fun getUnreadNotifications(data: CookieEntity) =
|
||||
threads.asSequence().filter(FrostThread::unread).map {
|
||||
with(it) {
|
||||
NotificationContent(
|
||||
@ -64,7 +64,8 @@ data class FrostMessages(
|
||||
title = title,
|
||||
text = content ?: "",
|
||||
timestamp = time,
|
||||
profileUrl = img
|
||||
profileUrl = img,
|
||||
unread = unread
|
||||
)
|
||||
}
|
||||
}.toList()
|
||||
|
@ -16,7 +16,7 @@
|
||||
*/
|
||||
package com.pitchedapps.frost.facebook.parsers
|
||||
|
||||
import com.pitchedapps.frost.dbflow.CookieModel
|
||||
import com.pitchedapps.frost.db.CookieEntity
|
||||
import com.pitchedapps.frost.facebook.FB_EPOCH_MATCHER
|
||||
import com.pitchedapps.frost.facebook.FB_NOTIF_ID_MATCHER
|
||||
import com.pitchedapps.frost.facebook.FbItem
|
||||
@ -43,7 +43,7 @@ data class FrostNotifs(
|
||||
append("}")
|
||||
}.toString()
|
||||
|
||||
override fun getUnreadNotifications(data: CookieModel) =
|
||||
override fun getUnreadNotifications(data: CookieEntity) =
|
||||
notifs.asSequence().filter(FrostNotif::unread).map {
|
||||
with(it) {
|
||||
NotificationContent(
|
||||
@ -53,7 +53,8 @@ data class FrostNotifs(
|
||||
title = null,
|
||||
text = content,
|
||||
timestamp = time,
|
||||
profileUrl = img
|
||||
profileUrl = img,
|
||||
unread = unread
|
||||
)
|
||||
}
|
||||
}.toList()
|
||||
|
@ -31,9 +31,10 @@ import ca.allanwang.kau.utils.string
|
||||
import com.pitchedapps.frost.BuildConfig
|
||||
import com.pitchedapps.frost.R
|
||||
import com.pitchedapps.frost.activities.FrostWebActivity
|
||||
import com.pitchedapps.frost.dbflow.CookieModel
|
||||
import com.pitchedapps.frost.dbflow.NotificationModel
|
||||
import com.pitchedapps.frost.dbflow.lastNotificationTime
|
||||
import com.pitchedapps.frost.db.CookieEntity
|
||||
import com.pitchedapps.frost.db.FrostDatabase
|
||||
import com.pitchedapps.frost.db.latestEpoch
|
||||
import com.pitchedapps.frost.db.saveNotifications
|
||||
import com.pitchedapps.frost.enums.OverlayContext
|
||||
import com.pitchedapps.frost.facebook.FbItem
|
||||
import com.pitchedapps.frost.facebook.parsers.FrostParser
|
||||
@ -60,12 +61,10 @@ private val _40_DP = 40.dpToPx
|
||||
* Enum to handle notification creations
|
||||
*/
|
||||
enum class NotificationType(
|
||||
private val channelId: String,
|
||||
val channelId: String,
|
||||
private val overlayContext: OverlayContext,
|
||||
private val fbItem: FbItem,
|
||||
private val parser: FrostParser<ParseNotification>,
|
||||
private val getTime: (notif: NotificationModel) -> Long,
|
||||
private val putTime: (notif: NotificationModel, time: Long) -> NotificationModel,
|
||||
private val ringtone: () -> String
|
||||
) {
|
||||
|
||||
@ -74,8 +73,6 @@ enum class NotificationType(
|
||||
OverlayContext.NOTIFICATION,
|
||||
FbItem.NOTIFICATIONS,
|
||||
NotifParser,
|
||||
NotificationModel::epoch,
|
||||
{ notif, time -> notif.copy(epoch = time) },
|
||||
Prefs::notificationRingtone
|
||||
) {
|
||||
|
||||
@ -88,8 +85,6 @@ enum class NotificationType(
|
||||
OverlayContext.MESSAGE,
|
||||
FbItem.MESSAGES,
|
||||
MessageParser,
|
||||
NotificationModel::epochIm,
|
||||
{ notif, time -> notif.copy(epochIm = time) },
|
||||
Prefs::messageRingtone
|
||||
);
|
||||
|
||||
@ -100,8 +95,8 @@ enum class NotificationType(
|
||||
*/
|
||||
internal open fun bindRequest(content: NotificationContent, cookie: String): (BaseBundle.() -> Unit)? = null
|
||||
|
||||
private fun bindRequest(intent: Intent, content: NotificationContent, cookie: String?) {
|
||||
cookie ?: return
|
||||
private fun bindRequest(intent: Intent, content: NotificationContent) {
|
||||
val cookie = content.data.cookie ?: return
|
||||
val binder = bindRequest(content, cookie) ?: return
|
||||
val bundle = Bundle()
|
||||
bundle.binder()
|
||||
@ -116,7 +111,8 @@ enum class NotificationType(
|
||||
* Returns the number of notifications generated,
|
||||
* or -1 if an error occurred
|
||||
*/
|
||||
fun fetch(context: Context, data: CookieModel): Int {
|
||||
suspend fun fetch(context: Context, data: CookieEntity): Int {
|
||||
val notifDao = FrostDatabase.get().notifDao()
|
||||
val response = try {
|
||||
parser.parse(data.cookie)
|
||||
} catch (ignored: Exception) {
|
||||
@ -142,36 +138,42 @@ enum class NotificationType(
|
||||
}
|
||||
if (notifContents.isEmpty()) return 0
|
||||
val userId = data.id
|
||||
val prevNotifTime = lastNotificationTime(userId)
|
||||
val prevLatestEpoch = getTime(prevNotifTime)
|
||||
// Legacy, remove with dbflow
|
||||
val prevLatestEpoch = notifDao.latestEpoch(userId, channelId)
|
||||
L.v { "Notif $name prev epoch $prevLatestEpoch" }
|
||||
var newLatestEpoch = prevLatestEpoch
|
||||
val notifs = mutableListOf<FrostNotification>()
|
||||
notifContents.forEach { notif ->
|
||||
L.v { "Notif timestamp ${notif.timestamp}" }
|
||||
if (notif.timestamp <= prevLatestEpoch) return@forEach
|
||||
notifs.add(createNotification(context, notif))
|
||||
if (notif.timestamp > newLatestEpoch)
|
||||
newLatestEpoch = notif.timestamp
|
||||
}
|
||||
if (newLatestEpoch > prevLatestEpoch)
|
||||
putTime(prevNotifTime, newLatestEpoch).save()
|
||||
L.d { "Notif $name new epoch ${getTime(lastNotificationTime(userId))}" }
|
||||
if (prevLatestEpoch == -1L && !BuildConfig.DEBUG) {
|
||||
L.d { "Skipping first notification fetch" }
|
||||
return 0 // do not notify the first time
|
||||
}
|
||||
|
||||
val newNotifContents = notifContents.filter { it.timestamp > prevLatestEpoch }
|
||||
|
||||
if (newNotifContents.isEmpty()) {
|
||||
L.d { "No new notifs found for $name" }
|
||||
return 0
|
||||
}
|
||||
|
||||
L.d { "${newNotifContents.size} new notifs found for $name" }
|
||||
|
||||
if (!notifDao.saveNotifications(channelId, newNotifContents)) {
|
||||
L.d { "Skip notifs for $name as saving failed" }
|
||||
return 0
|
||||
}
|
||||
|
||||
val notifs = newNotifContents.map { createNotification(context, it) }
|
||||
|
||||
frostEvent("Notifications", "Type" to name, "Count" to notifs.size)
|
||||
if (notifs.size > 1)
|
||||
summaryNotification(context, userId, notifs.size).notify(context)
|
||||
val ringtone = ringtone()
|
||||
notifs.forEachIndexed { i, notif ->
|
||||
// Ring at most twice
|
||||
notif.withAlert(i < 2, ringtone).notify(context)
|
||||
}
|
||||
return notifs.size
|
||||
}
|
||||
|
||||
fun debugNotification(context: Context, data: CookieModel) {
|
||||
fun debugNotification(context: Context, data: CookieEntity) {
|
||||
val content = NotificationContent(
|
||||
data,
|
||||
System.currentTimeMillis(),
|
||||
@ -179,23 +181,40 @@ enum class NotificationType(
|
||||
"Debug Notif",
|
||||
"Test 123",
|
||||
System.currentTimeMillis() / 1000,
|
||||
"https://www.iconexperience.com/_img/v_collection_png/256x256/shadow/dog.png"
|
||||
"https://www.iconexperience.com/_img/v_collection_png/256x256/shadow/dog.png",
|
||||
false
|
||||
)
|
||||
createNotification(context, content).notify(context)
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach content related data to an intent
|
||||
*/
|
||||
fun putContentExtra(intent: Intent, content: NotificationContent): Intent {
|
||||
// We will show the notification page for dependent urls. We can trigger a click next time
|
||||
intent.data = Uri.parse(if (content.href.isIndependent) content.href else FbItem.NOTIFICATIONS.url)
|
||||
bindRequest(intent, content)
|
||||
return intent
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a generic content for the provided type and user id.
|
||||
* No content related data is added
|
||||
*/
|
||||
fun createCommonIntent(context: Context, userId: Long): Intent {
|
||||
val intent = Intent(context, FrostWebActivity::class.java)
|
||||
intent.putExtra(ARG_USER_ID, userId)
|
||||
overlayContext.put(intent)
|
||||
return intent
|
||||
}
|
||||
|
||||
/**
|
||||
* Create and submit a new notification with the given [content]
|
||||
*/
|
||||
private fun createNotification(context: Context, content: NotificationContent): FrostNotification =
|
||||
with(content) {
|
||||
val intent = Intent(context, FrostWebActivity::class.java)
|
||||
// TODO temp fix; we will show notification page for dependent urls. We can trigger a click next time
|
||||
intent.data = Uri.parse(if (href.isIndependent) href else FbItem.NOTIFICATIONS.url)
|
||||
intent.putExtra(ARG_USER_ID, data.id)
|
||||
overlayContext.put(intent)
|
||||
bindRequest(intent, content, data.cookie)
|
||||
|
||||
val intent = createCommonIntent(context, content.data.id)
|
||||
putContentExtra(intent, content)
|
||||
val group = "${groupPrefix}_${data.id}"
|
||||
val pendingIntent = PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT)
|
||||
val notifBuilder = context.frostNotification(channelId)
|
||||
@ -257,13 +276,15 @@ enum class NotificationType(
|
||||
* Notification data holder
|
||||
*/
|
||||
data class NotificationContent(
|
||||
val data: CookieModel,
|
||||
// TODO replace data with userId?
|
||||
val data: CookieEntity,
|
||||
val id: Long,
|
||||
val href: String,
|
||||
val title: String? = null, // defaults to frost title
|
||||
val text: String,
|
||||
val timestamp: Long,
|
||||
val profileUrl: String?
|
||||
val profileUrl: String?,
|
||||
val unread: Boolean
|
||||
) {
|
||||
|
||||
val notifId = Math.abs(id.toInt())
|
||||
|
@ -21,16 +21,19 @@ import androidx.core.app.NotificationManagerCompat
|
||||
import ca.allanwang.kau.utils.string
|
||||
import com.pitchedapps.frost.BuildConfig
|
||||
import com.pitchedapps.frost.R
|
||||
import com.pitchedapps.frost.dbflow.CookieModel
|
||||
import com.pitchedapps.frost.dbflow.loadFbCookiesSync
|
||||
import com.pitchedapps.frost.db.CookieDao
|
||||
import com.pitchedapps.frost.db.CookieEntity
|
||||
import com.pitchedapps.frost.db.selectAll
|
||||
import com.pitchedapps.frost.utils.L
|
||||
import com.pitchedapps.frost.utils.Prefs
|
||||
import com.pitchedapps.frost.utils.frostEvent
|
||||
import com.pitchedapps.frost.widgets.NotificationWidget
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.yield
|
||||
import org.koin.android.ext.android.inject
|
||||
|
||||
/**
|
||||
* Created by Allan Wang on 2017-06-14.
|
||||
@ -42,6 +45,8 @@ import kotlinx.coroutines.yield
|
||||
*/
|
||||
class NotificationService : BaseJobService() {
|
||||
|
||||
val cookieDao: CookieDao by inject()
|
||||
|
||||
override fun onStopJob(params: JobParameters?): Boolean {
|
||||
super.onStopJob(params)
|
||||
prepareFinish(true)
|
||||
@ -81,7 +86,7 @@ class NotificationService : BaseJobService() {
|
||||
|
||||
private suspend fun sendNotifications(params: JobParameters?): Unit = withContext(Dispatchers.Default) {
|
||||
val currentId = Prefs.userId
|
||||
val cookies = loadFbCookiesSync()
|
||||
val cookies = cookieDao.selectAll()
|
||||
yield()
|
||||
val jobId = params?.extras?.getInt(NOTIFICATION_PARAM_ID, -1) ?: -1
|
||||
var notifCount = 0
|
||||
@ -101,13 +106,16 @@ class NotificationService : BaseJobService() {
|
||||
L.i { "Sent $notifCount notifications" }
|
||||
if (notifCount == 0 && jobId == NOTIFICATION_JOB_NOW)
|
||||
generalNotification(665, R.string.no_new_notifications, BuildConfig.DEBUG)
|
||||
if (notifCount > 0) {
|
||||
NotificationWidget.forceUpdate(this@NotificationService)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Implemented fetch to also notify when an error occurs
|
||||
* Also normalized the output to return the number of notifications received
|
||||
*/
|
||||
private fun fetch(jobId: Int, type: NotificationType, cookie: CookieModel): Int {
|
||||
private suspend fun fetch(jobId: Int, type: NotificationType, cookie: CookieEntity): Int {
|
||||
val count = type.fetch(this, cookie)
|
||||
if (count < 0) {
|
||||
if (jobId == NOTIFICATION_JOB_NOW)
|
||||
|
@ -29,14 +29,15 @@ import ca.allanwang.kau.utils.string
|
||||
import com.pitchedapps.frost.BuildConfig
|
||||
import com.pitchedapps.frost.R
|
||||
import com.pitchedapps.frost.activities.SettingsActivity
|
||||
import com.pitchedapps.frost.dbflow.NotificationModel
|
||||
import com.pitchedapps.frost.dbflow.loadFbCookiesAsync
|
||||
import com.pitchedapps.frost.db.FrostDatabase
|
||||
import com.pitchedapps.frost.db.deleteAll
|
||||
import com.pitchedapps.frost.services.fetchNotifications
|
||||
import com.pitchedapps.frost.services.scheduleNotifications
|
||||
import com.pitchedapps.frost.utils.Prefs
|
||||
import com.pitchedapps.frost.utils.frostSnackbar
|
||||
import com.pitchedapps.frost.utils.materialDialogThemed
|
||||
import com.pitchedapps.frost.views.Keywords
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Created by Allan Wang on 2017-06-29.
|
||||
@ -171,8 +172,8 @@ fun SettingsActivity.getNotificationPrefs(): KPrefAdapterBuilder.() -> Unit = {
|
||||
if (BuildConfig.DEBUG) {
|
||||
plainText(R.string.reset_notif_epoch) {
|
||||
onClick = {
|
||||
loadFbCookiesAsync { cookies ->
|
||||
cookies.map { NotificationModel(it.id) }.forEach { it.save() }
|
||||
launch {
|
||||
FrostDatabase.get().notifDao().deleteAll()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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)
|
||||
|
@ -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)
|
||||
|
50
app/src/main/kotlin/com/pitchedapps/frost/utils/TimeUtils.kt
Normal file
50
app/src/main/kotlin/com/pitchedapps/frost/utils/TimeUtils.kt
Normal file
@ -0,0 +1,50 @@
|
||||
/*
|
||||
* Copyright 2019 Allan Wang
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.pitchedapps.frost.utils
|
||||
|
||||
import android.content.Context
|
||||
import ca.allanwang.kau.utils.string
|
||||
import com.pitchedapps.frost.R
|
||||
import java.text.DateFormat
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Calendar
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* Converts time in millis to readable date,
|
||||
* eg Apr 24 at 7:32 PM
|
||||
*
|
||||
* With regards to date modifications in calendars,
|
||||
* it appears to respect calendar rules;
|
||||
* see https://stackoverflow.com/a/43227817/4407321
|
||||
*/
|
||||
fun Long.toReadableTime(context: Context): String {
|
||||
val cal = Calendar.getInstance()
|
||||
cal.timeInMillis = this
|
||||
val timeFormatter = SimpleDateFormat.getTimeInstance(DateFormat.SHORT)
|
||||
val time = timeFormatter.format(Date(this))
|
||||
val day = when {
|
||||
cal >= Calendar.getInstance().apply { add(Calendar.DAY_OF_MONTH, -1) } -> context.string(R.string.today)
|
||||
cal >= Calendar.getInstance().apply { add(Calendar.DAY_OF_MONTH, -2) } -> context.string(R.string.yesterday)
|
||||
else -> {
|
||||
val dayFormatter = SimpleDateFormat("MMM dd", Locale.getDefault())
|
||||
dayFormatter.format(Date(this))
|
||||
}
|
||||
}
|
||||
return context.getString(R.string.time_template, day, time)
|
||||
}
|
@ -62,7 +62,7 @@ import com.pitchedapps.frost.activities.TabCustomizerActivity
|
||||
import com.pitchedapps.frost.activities.WebOverlayActivity
|
||||
import com.pitchedapps.frost.activities.WebOverlayActivityBase
|
||||
import com.pitchedapps.frost.activities.WebOverlayDesktopActivity
|
||||
import com.pitchedapps.frost.dbflow.CookieModel
|
||||
import com.pitchedapps.frost.db.CookieEntity
|
||||
import com.pitchedapps.frost.facebook.FACEBOOK_COM
|
||||
import com.pitchedapps.frost.facebook.FBCDN_NET
|
||||
import com.pitchedapps.frost.facebook.FbCookie
|
||||
@ -103,7 +103,7 @@ internal inline val Context.ctxCoroutine: CoroutineScope
|
||||
get() = this as? CoroutineScope ?: GlobalScope
|
||||
|
||||
inline fun <reified T : Activity> Context.launchNewTask(
|
||||
cookieList: ArrayList<CookieModel> = arrayListOf(),
|
||||
cookieList: ArrayList<CookieEntity> = arrayListOf(),
|
||||
clearStack: Boolean = false
|
||||
) {
|
||||
startActivity<T>(clearStack, intentBuilder = {
|
||||
@ -111,13 +111,13 @@ inline fun <reified T : Activity> Context.launchNewTask(
|
||||
})
|
||||
}
|
||||
|
||||
fun Context.launchLogin(cookieList: ArrayList<CookieModel>, clearStack: Boolean = true) {
|
||||
fun Context.launchLogin(cookieList: ArrayList<CookieEntity>, clearStack: Boolean = true) {
|
||||
if (cookieList.isNotEmpty()) launchNewTask<SelectorActivity>(cookieList, clearStack)
|
||||
else launchNewTask<LoginActivity>(clearStack = clearStack)
|
||||
}
|
||||
|
||||
fun Activity.cookies(): ArrayList<CookieModel> {
|
||||
return intent?.getParcelableArrayListExtra<CookieModel>(EXTRA_COOKIES) ?: arrayListOf()
|
||||
fun Activity.cookies(): ArrayList<CookieEntity> {
|
||||
return intent?.getParcelableArrayListExtra<CookieEntity>(EXTRA_COOKIES) ?: arrayListOf()
|
||||
}
|
||||
|
||||
/**
|
||||
@ -186,7 +186,7 @@ fun MaterialDialog.Builder.theme(): MaterialDialog.Builder {
|
||||
}
|
||||
|
||||
fun Activity.setFrostTheme(forceTransparent: Boolean = false) {
|
||||
val isTransparent = (Color.alpha(Prefs.bgColor) != 255) || forceTransparent
|
||||
val isTransparent = (Color.alpha(Prefs.bgColor) != 255) || (Color.alpha(Prefs.headerColor) != 255) || forceTransparent
|
||||
if (Prefs.bgColor.isColorDark)
|
||||
setTheme(if (isTransparent) R.style.FrostTheme_Transparent else R.style.FrostTheme)
|
||||
else
|
||||
|
@ -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>) {
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
|
||||
|
@ -58,6 +58,7 @@ class DebugWebView @JvmOverloads constructor(
|
||||
settings.userAgentString = USER_AGENT_MOBILE
|
||||
setLayerType(View.LAYER_TYPE_HARDWARE, null)
|
||||
webViewClient = DebugClient()
|
||||
@Suppress("DEPRECATION")
|
||||
isDrawingCacheEnabled = true
|
||||
}
|
||||
|
||||
@ -72,6 +73,7 @@ class DebugWebView @JvmOverloads constructor(
|
||||
}
|
||||
try {
|
||||
output.outputStream().use {
|
||||
@Suppress("DEPRECATION")
|
||||
drawingCache.compress(Bitmap.CompressFormat.PNG, 100, it)
|
||||
}
|
||||
L.d { "Created screenshot at ${output.absolutePath}" }
|
||||
|
@ -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
|
||||
|
@ -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?) {
|
||||
|
@ -0,0 +1,194 @@
|
||||
/*
|
||||
* Copyright 2019 Allan Wang
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.pitchedapps.frost.widgets
|
||||
|
||||
import android.app.PendingIntent
|
||||
import android.appwidget.AppWidgetManager
|
||||
import android.appwidget.AppWidgetProvider
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Paint
|
||||
import android.graphics.PorterDuff
|
||||
import android.graphics.PorterDuffColorFilter
|
||||
import android.graphics.drawable.Icon
|
||||
import android.os.Build
|
||||
import android.widget.RemoteViews
|
||||
import android.widget.RemoteViewsService
|
||||
import androidx.annotation.ColorInt
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.annotation.IdRes
|
||||
import ca.allanwang.kau.utils.dimenPixelSize
|
||||
import ca.allanwang.kau.utils.withAlpha
|
||||
import com.pitchedapps.frost.R
|
||||
import com.pitchedapps.frost.activities.MainActivity
|
||||
import com.pitchedapps.frost.db.NotificationDao
|
||||
import com.pitchedapps.frost.db.selectNotificationsSync
|
||||
import com.pitchedapps.frost.glide.FrostGlide
|
||||
import com.pitchedapps.frost.glide.GlideApp
|
||||
import com.pitchedapps.frost.services.NotificationContent
|
||||
import com.pitchedapps.frost.services.NotificationType
|
||||
import com.pitchedapps.frost.utils.Prefs
|
||||
import com.pitchedapps.frost.utils.toReadableTime
|
||||
import org.koin.standalone.KoinComponent
|
||||
import org.koin.standalone.inject
|
||||
|
||||
class NotificationWidget : AppWidgetProvider() {
|
||||
|
||||
override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) {
|
||||
super.onUpdate(context, appWidgetManager, appWidgetIds)
|
||||
val type = NotificationType.GENERAL
|
||||
val userId = Prefs.userId
|
||||
val intent = NotificationWidgetService.createIntent(context, type, userId)
|
||||
for (id in appWidgetIds) {
|
||||
val views = RemoteViews(context.packageName, R.layout.widget_notifications)
|
||||
|
||||
views.setBackgroundColor(R.id.widget_layout_toolbar, Prefs.headerColor)
|
||||
views.setIcon(R.id.img_frost, context, R.drawable.frost_f_24, Prefs.iconColor)
|
||||
views.setOnClickPendingIntent(
|
||||
R.id.img_frost,
|
||||
PendingIntent.getActivity(context, 0, Intent(context, MainActivity::class.java), 0)
|
||||
)
|
||||
|
||||
views.setBackgroundColor(R.id.widget_notification_list, Prefs.bgColor)
|
||||
views.setRemoteAdapter(R.id.widget_notification_list, intent)
|
||||
|
||||
val pendingIntentTemplate = PendingIntent.getActivity(
|
||||
context,
|
||||
0,
|
||||
type.createCommonIntent(context, userId),
|
||||
PendingIntent.FLAG_UPDATE_CURRENT
|
||||
)
|
||||
|
||||
views.setPendingIntentTemplate(R.id.widget_notification_list, pendingIntentTemplate)
|
||||
|
||||
appWidgetManager.updateAppWidget(id, views)
|
||||
}
|
||||
appWidgetManager.notifyAppWidgetViewDataChanged(appWidgetIds, R.id.widget_notification_list)
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun forceUpdate(context: Context) {
|
||||
val manager = AppWidgetManager.getInstance(context)
|
||||
val ids = manager.getAppWidgetIds(ComponentName(context, NotificationWidget::class.java))
|
||||
val intent = Intent().apply {
|
||||
action = AppWidgetManager.ACTION_APPWIDGET_UPDATE
|
||||
putExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS, ids)
|
||||
}
|
||||
context.sendBroadcast(intent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private const val NOTIF_WIDGET_TYPE = "notif_widget_type"
|
||||
private const val NOTIF_WIDGET_USER_ID = "notif_widget_user_id"
|
||||
|
||||
private fun RemoteViews.setBackgroundColor(@IdRes viewId: Int, @ColorInt color: Int) {
|
||||
setInt(viewId, "setBackgroundColor", color)
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds backward compatibility to setting tinted icons
|
||||
*/
|
||||
private fun RemoteViews.setIcon(@IdRes viewId: Int, context: Context, @DrawableRes res: Int, @ColorInt color: Int) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val icon = Icon.createWithResource(context, res).setTint(color).setTintMode(PorterDuff.Mode.SRC_IN)
|
||||
setImageViewIcon(viewId, icon)
|
||||
} else {
|
||||
val bitmap = BitmapFactory.decodeResource(context.resources, res)
|
||||
if (bitmap != null) {
|
||||
val paint = Paint()
|
||||
paint.colorFilter = PorterDuffColorFilter(color, PorterDuff.Mode.SRC_IN)
|
||||
val result = Bitmap.createBitmap(bitmap.width, bitmap.height, Bitmap.Config.ARGB_8888)
|
||||
val canvas = Canvas(result)
|
||||
canvas.drawBitmap(bitmap, 0f, 0f, paint)
|
||||
setImageViewBitmap(viewId, result)
|
||||
} else {
|
||||
// Fallback to just icon
|
||||
setImageViewResource(viewId, res)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class NotificationWidgetService : RemoteViewsService() {
|
||||
override fun onGetViewFactory(intent: Intent): RemoteViewsFactory = NotificationWidgetDataProvider(this, intent)
|
||||
|
||||
companion object {
|
||||
fun createIntent(context: Context, type: NotificationType, userId: Long): Intent =
|
||||
Intent(context, NotificationWidgetService::class.java)
|
||||
.putExtra(NOTIF_WIDGET_TYPE, type.name)
|
||||
.putExtra(NOTIF_WIDGET_USER_ID, userId)
|
||||
}
|
||||
}
|
||||
|
||||
class NotificationWidgetDataProvider(val context: Context, val intent: Intent) : RemoteViewsService.RemoteViewsFactory,
|
||||
KoinComponent {
|
||||
|
||||
private val notifDao: NotificationDao by inject()
|
||||
@Volatile
|
||||
private var content: List<NotificationContent> = emptyList()
|
||||
|
||||
private val type = NotificationType.valueOf(intent.getStringExtra(NOTIF_WIDGET_TYPE))
|
||||
|
||||
private val userId = intent.getLongExtra(NOTIF_WIDGET_USER_ID, -1)
|
||||
|
||||
private val avatarSize = context.dimenPixelSize(R.dimen.avatar_image_size)
|
||||
|
||||
private val glide = GlideApp.with(context).asBitmap()
|
||||
|
||||
private fun loadNotifications() {
|
||||
content = notifDao.selectNotificationsSync(userId, type.channelId)
|
||||
}
|
||||
|
||||
override fun onCreate() {
|
||||
}
|
||||
|
||||
override fun onDataSetChanged() {
|
||||
loadNotifications()
|
||||
}
|
||||
|
||||
override fun getLoadingView(): RemoteViews? = null
|
||||
|
||||
override fun getItemId(position: Int): Long = content[position].id
|
||||
|
||||
override fun hasStableIds(): Boolean = true
|
||||
|
||||
override fun getViewAt(position: Int): RemoteViews {
|
||||
val views = RemoteViews(context.packageName, R.layout.widget_notification_item)
|
||||
val notif = content[position]
|
||||
views.setBackgroundColor(R.id.item_frame, Prefs.nativeBgColor(notif.unread))
|
||||
views.setTextColor(R.id.item_content, Prefs.textColor)
|
||||
views.setTextViewText(R.id.item_content, notif.text)
|
||||
views.setTextColor(R.id.item_date, Prefs.textColor.withAlpha(150))
|
||||
views.setTextViewText(R.id.item_date, notif.timestamp.toReadableTime(context))
|
||||
|
||||
val avatar = glide.load(notif.profileUrl).transform(FrostGlide.circleCrop).submit(avatarSize, avatarSize).get()
|
||||
views.setImageViewBitmap(R.id.item_avatar, avatar)
|
||||
views.setOnClickFillInIntent(R.id.item_frame, type.putContentExtra(Intent(), notif))
|
||||
return views
|
||||
}
|
||||
|
||||
override fun getCount(): Int = content.size
|
||||
|
||||
override fun getViewTypeCount(): Int = 1
|
||||
|
||||
override fun onDestroy() {
|
||||
}
|
||||
}
|
48
app/src/main/res/drawable/notification_widget_preview.xml
Normal file
48
app/src/main/res/drawable/notification_widget_preview.xml
Normal file
@ -0,0 +1,48 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="30dp"
|
||||
android:height="40dp"
|
||||
android:viewportWidth="300"
|
||||
android:viewportHeight="400">
|
||||
<path
|
||||
android:pathData="M0,0h300v400H0V0z"
|
||||
android:fillColor="#fafafa"/>
|
||||
<path
|
||||
android:pathData="M0,0h300v50H0V0z"
|
||||
android:fillColor="@color/facebook_blue"/>
|
||||
<path
|
||||
android:pathData="M65,170a20,20 0,1 1,-40 0,20 20,0 0,1 40,0z"
|
||||
android:fillColor="#DE000000"/>
|
||||
<path
|
||||
android:pathData="M85,150h184v11H85v-11z"
|
||||
android:fillColor="#DE000000"/>
|
||||
<path
|
||||
android:pathData="M85,179h146v11H85v-11z"
|
||||
android:fillColor="#DE000000"/>
|
||||
<path
|
||||
android:pathData="M65,95a20,20 0,1 1,-40 0,20 20,0 0,1 40,0z"
|
||||
android:fillColor="#DE000000"/>
|
||||
<path
|
||||
android:pathData="M85,75h184v11H85V75z"
|
||||
android:fillColor="#DE000000"/>
|
||||
<path
|
||||
android:pathData="M85,104h146v11H85v-11z"
|
||||
android:fillColor="#DE000000"/>
|
||||
<path
|
||||
android:pathData="M65,245a20,20 0,1 1,-40 0,20 20,0 0,1 40,0z"
|
||||
android:fillColor="#DE000000"/>
|
||||
<path
|
||||
android:pathData="M85,225h184v11H85v-11z"
|
||||
android:fillColor="#DE000000"/>
|
||||
<path
|
||||
android:pathData="M85,254h146v11H85v-11z"
|
||||
android:fillColor="#DE000000"/>
|
||||
<path
|
||||
android:pathData="M65,320a20,20 0,1 1,-40 0,20 20,0 0,1 40,0z"
|
||||
android:fillColor="#DE000000"/>
|
||||
<path
|
||||
android:pathData="M85,300h184v11H85v-11z"
|
||||
android:fillColor="#DE000000"/>
|
||||
<path
|
||||
android:pathData="M85,329h146v11H85v-11z"
|
||||
android:fillColor="#DE000000"/>
|
||||
</vector>
|
46
app/src/main/res/layout/widget_notification_item.xml
Normal file
46
app/src/main/res/layout/widget_notification_item.xml
Normal file
@ -0,0 +1,46 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:id="@+id/item_frame"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="?android:selectableItemBackground"
|
||||
android:orientation="horizontal"
|
||||
android:paddingStart="@dimen/kau_activity_horizontal_margin"
|
||||
android:paddingTop="@dimen/kau_activity_vertical_margin"
|
||||
android:paddingEnd="@dimen/kau_activity_horizontal_margin"
|
||||
android:paddingBottom="@dimen/kau_activity_vertical_margin">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/item_avatar"
|
||||
android:layout_width="@dimen/avatar_image_size"
|
||||
android:layout_height="@dimen/avatar_image_size" />
|
||||
|
||||
<!--
|
||||
Unlike the actual notification panel,
|
||||
we do not show thumbnails, and we limit the title length
|
||||
-->
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="@dimen/kau_padding_normal"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/item_content"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:ellipsize="end"
|
||||
android:lines="2" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/item_date"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:ellipsize="end"
|
||||
android:lines="1"
|
||||
android:textSize="12sp" />
|
||||
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
31
app/src/main/res/layout/widget_notifications.xml
Normal file
31
app/src/main/res/layout/widget_notifications.xml
Normal file
@ -0,0 +1,31 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:id="@+id/widget_layout_container"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="vertical">
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/widget_layout_toolbar"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:paddingStart="@dimen/kau_padding_small"
|
||||
android:paddingEnd="@dimen/kau_padding_small">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/img_frost"
|
||||
android:layout_width="@dimen/toolbar_icon_size"
|
||||
android:layout_height="@dimen/toolbar_icon_size"
|
||||
android:layout_gravity="center_vertical"
|
||||
android:layout_margin="@dimen/kau_padding_small"
|
||||
android:background="?android:selectableItemBackgroundBorderless" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<ListView
|
||||
android:id="@+id/widget_notification_list"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1" />
|
||||
</LinearLayout>
|
@ -9,4 +9,6 @@
|
||||
<dimen name="tab_bar_height">50dp</dimen>
|
||||
<dimen name="intro_bar_height">64dp</dimen>
|
||||
<dimen name="badge_icon_size">20dp</dimen>
|
||||
|
||||
<dimen name="toolbar_icon_size">24dp</dimen>
|
||||
</resources>
|
||||
|
@ -62,4 +62,16 @@
|
||||
|
||||
<string name="no_new_notifications">No new notifications found</string>
|
||||
|
||||
<string name="today">Today</string>
|
||||
<string name="yesterday">Today</string>
|
||||
<!--
|
||||
Template used to display human readable string;
|
||||
For instance:
|
||||
Today at 1:23 PM
|
||||
Mar 13 at 9:00 AM
|
||||
|
||||
The first element is the day, and the second element is the time
|
||||
-->
|
||||
<string name="time_template">%1s at %2s</string>
|
||||
|
||||
</resources>
|
||||
|
@ -6,12 +6,22 @@
|
||||
<item text="" />
|
||||
-->
|
||||
|
||||
<version title="v2.3.0" />
|
||||
<item text="Converted internals of Facebook data storage; auto migration will only work from 2.2.x to 2.3.x" />
|
||||
<item text="Added notification widget" />
|
||||
<item text="" />
|
||||
<item text="" />
|
||||
<item text="" />
|
||||
<item text="" />
|
||||
<item text="" />
|
||||
<item text="" />
|
||||
<item text="" />
|
||||
|
||||
<version title="v2.2.4" />
|
||||
<item text="Show top bar to allow sharing posts" />
|
||||
<item text="Fix unmuting videos when autoplay is enabled" />
|
||||
<item text="Add shortcut to toggle autoplay in settings > behaviour" />
|
||||
<item text="Update theme" />
|
||||
<item text="" />
|
||||
|
||||
<version title="v2.2.3" />
|
||||
<item text="Add ability to hide stories" />
|
||||
|
10
app/src/main/res/xml/notification_widget_info.xml
Normal file
10
app/src/main/res/xml/notification_widget_info.xml
Normal file
@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><!--
|
||||
For sizing see:
|
||||
https://developer.android.com/guide/practices/ui_guidelines/widget_design.html#anatomy_determining_size
|
||||
-->
|
||||
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:initialKeyguardLayout="@layout/widget_notifications"
|
||||
android:initialLayout="@layout/widget_notifications"
|
||||
android:minWidth="180dp"
|
||||
android:minHeight="250dp"
|
||||
android:previewImage="@drawable/notification_widget_preview" />
|
@ -0,0 +1,195 @@
|
||||
{
|
||||
"formatVersion": 1,
|
||||
"database": {
|
||||
"version": 1,
|
||||
"identityHash": "fe8f5b6c27f48d7e0733ee6819f06f40",
|
||||
"entities": [
|
||||
{
|
||||
"tableName": "cookies",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`cookie_id` INTEGER NOT NULL, `name` TEXT, `cookie` TEXT, PRIMARY KEY(`cookie_id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "cookie_id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "name",
|
||||
"columnName": "name",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "cookie",
|
||||
"columnName": "cookie",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"columnNames": [
|
||||
"cookie_id"
|
||||
],
|
||||
"autoGenerate": false
|
||||
},
|
||||
"indices": [],
|
||||
"foreignKeys": []
|
||||
},
|
||||
{
|
||||
"tableName": "notifications",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`notif_id` INTEGER NOT NULL, `userId` INTEGER NOT NULL, `href` TEXT NOT NULL, `title` TEXT, `text` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `profileUrl` TEXT, `type` TEXT NOT NULL, `unread` INTEGER NOT NULL, PRIMARY KEY(`notif_id`, `userId`), FOREIGN KEY(`userId`) REFERENCES `cookies`(`cookie_id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "notif_id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "userId",
|
||||
"columnName": "userId",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "href",
|
||||
"columnName": "href",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "title",
|
||||
"columnName": "title",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "text",
|
||||
"columnName": "text",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "timestamp",
|
||||
"columnName": "timestamp",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "profileUrl",
|
||||
"columnName": "profileUrl",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "type",
|
||||
"columnName": "type",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "unread",
|
||||
"columnName": "unread",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"columnNames": [
|
||||
"notif_id",
|
||||
"userId"
|
||||
],
|
||||
"autoGenerate": false
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_notifications_notif_id",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"notif_id"
|
||||
],
|
||||
"createSql": "CREATE INDEX `index_notifications_notif_id` ON `${TABLE_NAME}` (`notif_id`)"
|
||||
},
|
||||
{
|
||||
"name": "index_notifications_userId",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"userId"
|
||||
],
|
||||
"createSql": "CREATE INDEX `index_notifications_userId` ON `${TABLE_NAME}` (`userId`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "cookies",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"userId"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"cookie_id"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "frost_cache",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `type` TEXT NOT NULL, `lastUpdated` INTEGER NOT NULL, `contents` TEXT NOT NULL, PRIMARY KEY(`id`, `type`), FOREIGN KEY(`id`) REFERENCES `cookies`(`cookie_id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "type",
|
||||
"columnName": "type",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastUpdated",
|
||||
"columnName": "lastUpdated",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "contents",
|
||||
"columnName": "contents",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"columnNames": [
|
||||
"id",
|
||||
"type"
|
||||
],
|
||||
"autoGenerate": false
|
||||
},
|
||||
"indices": [],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "cookies",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"id"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"cookie_id"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"views": [],
|
||||
"setupQueries": [
|
||||
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
|
||||
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, \"fe8f5b6c27f48d7e0733ee6819f06f40\")"
|
||||
]
|
||||
}
|
||||
}
|
@ -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\")"
|
||||
]
|
||||
}
|
||||
}
|
@ -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
|
||||
|
@ -1,5 +1,9 @@
|
||||
# Changelog
|
||||
|
||||
## v2.3.0
|
||||
* Converted internals of Facebook data storage; auto migration will only work from 2.2.x to 2.3.x
|
||||
* Added notification widget
|
||||
|
||||
## v2.2.4
|
||||
* Show top bar to allow sharing posts
|
||||
* Fix unmuting videos when autoplay is enabled
|
||||
|
@ -56,6 +56,8 @@ MATERIAL_DRAWER_KT=2.0.1
|
||||
# https://github.com/square/okhttp/releases
|
||||
OKHTTP=3.14.0
|
||||
# http://robolectric.org/getting-started/
|
||||
# https://developer.android.com/jetpack/androidx/releases/room
|
||||
ROOM=2.1.0-alpha04
|
||||
ROBOELECTRIC=4.2
|
||||
# https://github.com/davemorrissey/subsampling-scale-image-view#quick-start
|
||||
SCALE_IMAGE_VIEW=3.10.0
|
||||
|
Loading…
Reference in New Issue
Block a user