aboutsummaryrefslogtreecommitdiff
path: root/app/src/main
diff options
context:
space:
mode:
authorAllan Wang <me@allanwang.ca>2019-04-25 15:14:10 -0700
committerGitHub <noreply@github.com>2019-04-25 15:14:10 -0700
commit4f3e44b7b9678dce48e7d322af72429b5f447acc (patch)
treeff913af0ff20f5d415428ac231372da13b9a6639 /app/src/main
parentf0f95295bdb2b853aa6262ec4bed2353ce326eee (diff)
parent00e04ce269197053a8cd8ae522c95629541181a1 (diff)
downloadfrost-4f3e44b7b9678dce48e7d322af72429b5f447acc.tar.gz
frost-4f3e44b7b9678dce48e7d322af72429b5f447acc.tar.bz2
frost-4f3e44b7b9678dce48e7d322af72429b5f447acc.zip
Merge pull request #1365 from AllanWang/room
Room
Diffstat (limited to 'app/src/main')
-rw-r--r--app/src/main/AndroidManifest.xml14
-rw-r--r--app/src/main/kotlin/com/pitchedapps/frost/FrostApp.kt11
-rw-r--r--app/src/main/kotlin/com/pitchedapps/frost/StartActivity.kt45
-rw-r--r--app/src/main/kotlin/com/pitchedapps/frost/activities/BaseMainActivity.kt124
-rw-r--r--app/src/main/kotlin/com/pitchedapps/frost/activities/LoginActivity.kt31
-rw-r--r--app/src/main/kotlin/com/pitchedapps/frost/activities/MainActivity.kt23
-rw-r--r--app/src/main/kotlin/com/pitchedapps/frost/activities/TabCustomizerActivity.kt40
-rw-r--r--app/src/main/kotlin/com/pitchedapps/frost/db/CacheDb.kt86
-rw-r--r--app/src/main/kotlin/com/pitchedapps/frost/db/CookiesDb.kt90
-rw-r--r--app/src/main/kotlin/com/pitchedapps/frost/db/DaoUtils.kt28
-rw-r--r--app/src/main/kotlin/com/pitchedapps/frost/db/Database.kt106
-rw-r--r--app/src/main/kotlin/com/pitchedapps/frost/db/FbTabsDb.kt (renamed from app/src/main/kotlin/com/pitchedapps/frost/dbflow/FbTabsDb.kt)24
-rw-r--r--app/src/main/kotlin/com/pitchedapps/frost/db/GenericDb.kt71
-rw-r--r--app/src/main/kotlin/com/pitchedapps/frost/db/NotificationDb.kt202
-rw-r--r--app/src/main/kotlin/com/pitchedapps/frost/dbflow/CookiesDb.kt92
-rw-r--r--app/src/main/kotlin/com/pitchedapps/frost/dbflow/DbUtils.kt39
-rw-r--r--app/src/main/kotlin/com/pitchedapps/frost/dbflow/NotificationDb.kt72
-rw-r--r--app/src/main/kotlin/com/pitchedapps/frost/facebook/FbCookie.kt35
-rw-r--r--app/src/main/kotlin/com/pitchedapps/frost/facebook/parsers/FrostParser.kt4
-rw-r--r--app/src/main/kotlin/com/pitchedapps/frost/facebook/parsers/MessageParser.kt7
-rw-r--r--app/src/main/kotlin/com/pitchedapps/frost/facebook/parsers/NotifParser.kt7
-rw-r--r--app/src/main/kotlin/com/pitchedapps/frost/services/FrostNotifications.kt97
-rw-r--r--app/src/main/kotlin/com/pitchedapps/frost/services/NotificationService.kt16
-rw-r--r--app/src/main/kotlin/com/pitchedapps/frost/settings/Notifications.kt9
-rw-r--r--app/src/main/kotlin/com/pitchedapps/frost/utils/Downloader.kt7
-rw-r--r--app/src/main/kotlin/com/pitchedapps/frost/utils/L.kt5
-rw-r--r--app/src/main/kotlin/com/pitchedapps/frost/utils/TimeUtils.kt50
-rw-r--r--app/src/main/kotlin/com/pitchedapps/frost/utils/Utils.kt12
-rw-r--r--app/src/main/kotlin/com/pitchedapps/frost/views/AccountItem.kt4
-rw-r--r--app/src/main/kotlin/com/pitchedapps/frost/views/FrostVideoViewer.kt9
-rw-r--r--app/src/main/kotlin/com/pitchedapps/frost/views/FrostWebView.kt12
-rw-r--r--app/src/main/kotlin/com/pitchedapps/frost/web/DebugWebView.kt2
-rw-r--r--app/src/main/kotlin/com/pitchedapps/frost/web/FrostJSI.kt4
-rw-r--r--app/src/main/kotlin/com/pitchedapps/frost/web/LoginWebView.kt16
-rw-r--r--app/src/main/kotlin/com/pitchedapps/frost/widgets/NotificationWidget.kt194
-rw-r--r--app/src/main/res/drawable/notification_widget_preview.xml48
-rw-r--r--app/src/main/res/layout/widget_notification_item.xml46
-rw-r--r--app/src/main/res/layout/widget_notifications.xml31
-rw-r--r--app/src/main/res/values/dimens.xml2
-rw-r--r--app/src/main/res/values/strings.xml12
-rw-r--r--app/src/main/res/xml/frost_changelog.xml12
-rw-r--r--app/src/main/res/xml/notification_widget_info.xml10
42 files changed, 1344 insertions, 405 deletions
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index ceb309f3..1ca91d62 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -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"
diff --git a/app/src/main/kotlin/com/pitchedapps/frost/FrostApp.kt b/app/src/main/kotlin/com/pitchedapps/frost/FrostApp.kt
index 5b62afad..41c6ff4b 100644
--- a/app/src/main/kotlin/com/pitchedapps/frost/FrostApp.kt
+++ b/app/src/main/kotlin/com/pitchedapps/frost/FrostApp.kt
@@ -29,9 +29,10 @@ import com.bumptech.glide.request.RequestOptions
import com.bumptech.glide.signature.ApplicationVersionSignature
import com.mikepenz.materialdrawer.util.AbstractDrawerImageLoader
import com.mikepenz.materialdrawer.util.DrawerImageLoader
-import com.pitchedapps.frost.dbflow.CookiesDb
-import com.pitchedapps.frost.dbflow.FbTabsDb
-import com.pitchedapps.frost.dbflow.NotificationDb
+import com.pitchedapps.frost.db.CookiesDb
+import com.pitchedapps.frost.db.FbTabsDb
+import com.pitchedapps.frost.db.FrostDatabase
+import com.pitchedapps.frost.db.NotificationDb
import com.pitchedapps.frost.glide.GlideApp
import com.pitchedapps.frost.services.scheduleNotifications
import com.pitchedapps.frost.services.setupNotificationChannels
@@ -44,6 +45,7 @@ import com.raizlabs.android.dbflow.config.DatabaseConfig
import com.raizlabs.android.dbflow.config.FlowConfig
import com.raizlabs.android.dbflow.config.FlowManager
import com.raizlabs.android.dbflow.runtime.ContentResolverNotifier
+import org.koin.android.ext.android.startKoin
import java.util.Random
import kotlin.reflect.KClass
@@ -81,7 +83,7 @@ class FrostApp : Application() {
)
Showcase.initialize(this, "${BuildConfig.APPLICATION_ID}.showcase")
Prefs.initialize(this, "${BuildConfig.APPLICATION_ID}.prefs")
- // if (LeakCanary.isInAnalyzerProcess(this)) return
+// if (LeakCanary.isInAnalyzerProcess(this)) return
// refWatcher = LeakCanary.install(this)
initBugsnag()
KL.shouldLog = { BuildConfig.DEBUG }
@@ -132,6 +134,7 @@ class FrostApp : Application() {
L.d { "Activity ${activity.localClassName} created" }
}
})
+ startKoin(this, listOf(FrostDatabase.module(this)))
}
private fun initBugsnag() {
diff --git a/app/src/main/kotlin/com/pitchedapps/frost/StartActivity.kt b/app/src/main/kotlin/com/pitchedapps/frost/StartActivity.kt
index d0376144..18ae4b0b 100644
--- a/app/src/main/kotlin/com/pitchedapps/frost/StartActivity.kt
+++ b/app/src/main/kotlin/com/pitchedapps/frost/StartActivity.kt
@@ -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)
diff --git a/app/src/main/kotlin/com/pitchedapps/frost/activities/BaseMainActivity.kt b/app/src/main/kotlin/com/pitchedapps/frost/activities/BaseMainActivity.kt
index f1d88bc3..d6f1abe7 100644
--- a/app/src/main/kotlin/com/pitchedapps/frost/activities/BaseMainActivity.kt
+++ b/app/src/main/kotlin/com/pitchedapps/frost/activities/BaseMainActivity.kt
@@ -69,9 +69,10 @@ import com.pitchedapps.frost.contracts.FileChooserContract
import com.pitchedapps.frost.contracts.FileChooserDelegate
import com.pitchedapps.frost.contracts.MainActivityContract
import com.pitchedapps.frost.contracts.VideoViewHolder
-import com.pitchedapps.frost.dbflow.TAB_COUNT
-import com.pitchedapps.frost.dbflow.loadFbCookie
-import com.pitchedapps.frost.dbflow.loadFbTabs
+import com.pitchedapps.frost.db.CookieDao
+import com.pitchedapps.frost.db.GenericDao
+import com.pitchedapps.frost.db.currentCookie
+import com.pitchedapps.frost.db.getTabs
import com.pitchedapps.frost.enums.MainActivityLayout
import com.pitchedapps.frost.facebook.FbCookie
import com.pitchedapps.frost.facebook.FbItem
@@ -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,27 +285,28 @@ abstract class BaseMainActivity : BaseActivity(), MainActivityContract,
if (current) launchWebOverlay(FbItem.PROFILE.url)
else when (profile.identifier) {
-2L -> {
- val currentCookie = loadFbCookie(Prefs.userId)
- if (currentCookie == null) {
- toast(R.string.account_not_found)
- launch {
+ // TODO no backpressure support
+ this@BaseMainActivity.launch {
+ val currentCookie = cookieDao.currentCookie()
+ if (currentCookie == null) {
+ toast(R.string.account_not_found)
FbCookie.reset()
launchLogin(cookies(), true)
- }
- } else {
- materialDialogThemed {
- title(R.string.kau_logout)
- content(
- String.format(
- string(R.string.kau_logout_confirm_as_x), currentCookie.name
- ?: Prefs.userId.toString()
+ } else {
+ materialDialogThemed {
+ title(R.string.kau_logout)
+ content(
+ String.format(
+ string(R.string.kau_logout_confirm_as_x),
+ currentCookie.name ?: Prefs.userId.toString()
+ )
)
- )
- positiveText(R.string.kau_yes)
- negativeText(R.string.kau_no)
- onPositive { _, _ ->
- launch {
- FbCookie.logout(this@BaseMainActivity)
+ positiveText(R.string.kau_yes)
+ negativeText(R.string.kau_no)
+ onPositive { _, _ ->
+ this@BaseMainActivity.launch {
+ FbCookie.logout(this@BaseMainActivity)
+ }
}
}
}
@@ -303,7 +315,7 @@ abstract class BaseMainActivity : BaseActivity(), MainActivityContract,
-3L -> launchNewTask<LoginActivity>(clearStack = false)
-4L -> launchNewTask<SelectorActivity>(cookies(), false)
else -> {
- launch {
+ this@BaseMainActivity.launch {
FbCookie.switchUser(profile.identifier)
tabsForEachView { _, view -> view.badgeText = null }
refreshAll()
@@ -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) {
+
+ private val pages: MutableList<FbItem> = mutableListOf()
- val forcedFallbacks = mutableSetOf<String>()
+ private val forcedFallbacks = mutableSetOf<String>()
+
+ /**
+ * Update page list and prompt reload
+ */
+ fun setPages(pages: List<FbItem>) {
+ this.pages.clear()
+ this.pages.addAll(pages)
+ notifyDataSetChanged()
+ tabs.removeAllTabs()
+ this.pages.forEachIndexed { index, fbItem ->
+ tabs.addTab(
+ tabs.newTab()
+ .setCustomView(BadgedIcon(this@BaseMainActivity).apply { iicon = fbItem.icon }.also {
+ it.setAllAlpha(if (index == 0) SELECTED_TAB_ALPHA else UNSELECTED_TAB_ALPHA)
+ })
+ )
+ }
+ lastPosition = 0
+ viewPager.setCurrentItem(0, false)
+ viewPager.offscreenPageLimit = pages.size
+ viewPager.post {
+ if (!fragmentChannel.isClosedForSend)
+ fragmentChannel.offer(0)
+ } //trigger hook so title is set
+ }
+
+ fun saveInstanceState(outState: Bundle) {
+ outState.putStringArrayList(STATE_FORCE_FALLBACK, ArrayList(forcedFallbacks))
+ }
+
+ fun restoreInstanceState(savedInstanceState: Bundle) {
+ forcedFallbacks.clear()
+ forcedFallbacks.addAll(
+ savedInstanceState.getStringArrayList(STATE_FORCE_FALLBACK)
+ ?: emptyList()
+ )
+ }
fun reloadFragment(fragment: BaseFragment) {
if (fragment is WebFragment) return
@@ -559,4 +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
+ }
}
diff --git a/app/src/main/kotlin/com/pitchedapps/frost/activities/LoginActivity.kt b/app/src/main/kotlin/com/pitchedapps/frost/activities/LoginActivity.kt
index 97abf5a2..ed207896 100644
--- a/app/src/main/kotlin/com/pitchedapps/frost/activities/LoginActivity.kt
+++ b/app/src/main/kotlin/com/pitchedapps/frost/activities/LoginActivity.kt
@@ -32,9 +32,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 {
diff --git a/app/src/main/kotlin/com/pitchedapps/frost/activities/MainActivity.kt b/app/src/main/kotlin/com/pitchedapps/frost/activities/MainActivity.kt
index a1dba417..34674cb0 100644
--- a/app/src/main/kotlin/com/pitchedapps/frost/activities/MainActivity.kt
+++ b/app/src/main/kotlin/com/pitchedapps/frost/activities/MainActivity.kt
@@ -35,7 +35,6 @@ class MainActivity : BaseMainActivity() {
override val fragmentChannel = BroadcastChannel<Int>(10)
override val headerBadgeChannel = BroadcastChannel<String>(Channel.CONFLATED)
- var lastPosition = -1
override fun onNestedCreate(savedInstanceState: Bundle?) {
setupTabs()
@@ -54,23 +53,18 @@ class MainActivity : BaseMainActivity() {
override fun onPageScrolled(position: Int, positionOffset: Float, positionOffsetPixels: Int) {
super.onPageScrolled(position, positionOffset, positionOffsetPixels)
- val delta = positionOffset * (255 - 128).toFloat()
+ val delta = positionOffset * (SELECTED_TAB_ALPHA - UNSELECTED_TAB_ALPHA)
tabsForEachView { tabPosition, view ->
view.setAllAlpha(
when (tabPosition) {
- position -> 255.0f - delta
- position + 1 -> 128.0f + delta
- else -> 128f
+ position -> SELECTED_TAB_ALPHA - delta
+ position + 1 -> UNSELECTED_TAB_ALPHA + delta
+ else -> UNSELECTED_TAB_ALPHA
}
)
}
}
})
- viewPager.post {
- if (!fragmentChannel.isClosedForSend)
- fragmentChannel.offer(0)
- lastPosition = 0
- } //trigger hook so title is set
}
private fun setupTabs() {
@@ -86,8 +80,7 @@ class MainActivity : BaseMainActivity() {
(tab.customView as BadgedIcon).badgeText = null
}
})
- headerBadgeChannel.subscribeDuringJob(this, Dispatchers.IO) {
- html ->
+ headerBadgeChannel.subscribeDuringJob(this, Dispatchers.IO) { html ->
try {
val doc = Jsoup.parse(html)
if (doc.select("[data-sigil=count]").isEmpty())
@@ -116,11 +109,5 @@ class MainActivity : BaseMainActivity() {
L.e(e) { "Header badge error" }
}
}
- adapter.pages.forEach {
- tabs.addTab(
- tabs.newTab()
- .setCustomView(BadgedIcon(this).apply { iicon = it.icon })
- )
- }
}
}
diff --git a/app/src/main/kotlin/com/pitchedapps/frost/activities/TabCustomizerActivity.kt b/app/src/main/kotlin/com/pitchedapps/frost/activities/TabCustomizerActivity.kt
index 7f632940..6ad7d3f2 100644
--- a/app/src/main/kotlin/com/pitchedapps/frost/activities/TabCustomizerActivity.kt
+++ b/app/src/main/kotlin/com/pitchedapps/frost/activities/TabCustomizerActivity.kt
@@ -25,6 +25,7 @@ import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.RecyclerView
import ca.allanwang.kau.kotlin.lazyContext
+import ca.allanwang.kau.utils.launchMain
import ca.allanwang.kau.utils.scaleXY
import ca.allanwang.kau.utils.setIcon
import ca.allanwang.kau.utils.withAlpha
@@ -33,14 +34,19 @@ import com.mikepenz.fastadapter_extensions.drag.ItemTouchCallback
import com.mikepenz.fastadapter_extensions.drag.SimpleDragCallback
import com.mikepenz.google_material_typeface_library.GoogleMaterial
import com.pitchedapps.frost.R
-import com.pitchedapps.frost.dbflow.TAB_COUNT
-import com.pitchedapps.frost.dbflow.loadFbTabs
-import com.pitchedapps.frost.dbflow.save
+import com.pitchedapps.frost.db.GenericDao
+import com.pitchedapps.frost.db.TAB_COUNT
+import com.pitchedapps.frost.db.getTabs
+import com.pitchedapps.frost.db.saveTabs
import com.pitchedapps.frost.facebook.FbItem
import com.pitchedapps.frost.iitems.TabIItem
+import com.pitchedapps.frost.utils.L
import com.pitchedapps.frost.utils.Prefs
import com.pitchedapps.frost.utils.setFrostColors
import kotlinx.android.synthetic.main.activity_tab_customizer.*
+import kotlinx.coroutines.NonCancellable
+import kotlinx.coroutines.launch
+import org.koin.android.ext.android.inject
import java.util.Collections
/**
@@ -48,6 +54,8 @@ import java.util.Collections
*/
class TabCustomizerActivity : BaseActivity() {
+ private val genericDao: GenericDao by inject()
+
private val adapter = FastItemAdapter<TabIItem>()
private val wobble = lazyContext { AnimationUtils.loadAnimation(it, R.anim.rotate_delta) }
@@ -65,24 +73,30 @@ class TabCustomizerActivity : BaseActivity() {
divider.setBackgroundColor(Prefs.textColor.withAlpha(30))
instructions.setTextColor(Prefs.textColor)
- val tabs = loadFbTabs().toMutableList()
- val remaining = FbItem.values().filter { it.name[0] != '_' }.toMutableList()
- remaining.removeAll(tabs)
- tabs.addAll(remaining)
+ launch {
+ val tabs = genericDao.getTabs().toMutableList()
+ L.d { "Tabs $tabs" }
+ val remaining = FbItem.values().filter { it.name[0] != '_' }.toMutableList()
+ remaining.removeAll(tabs)
+ tabs.addAll(remaining)
+ adapter.set(tabs.map(::TabIItem))
- adapter.add(tabs.map(::TabIItem))
- bindSwapper(adapter, tab_recycler)
+ bindSwapper(adapter, tab_recycler)
- adapter.withOnClickListener { view, _, _, _ -> view!!.wobble(); true }
+ adapter.withOnClickListener { view, _, _, _ -> view!!.wobble(); true }
+ }
setResult(Activity.RESULT_CANCELED)
fab_save.setIcon(GoogleMaterial.Icon.gmd_check, Prefs.iconColor)
fab_save.backgroundTintList = ColorStateList.valueOf(Prefs.accentColor)
fab_save.setOnClickListener {
- adapter.adapterItems.subList(0, TAB_COUNT).map(TabIItem::item).save()
- setResult(Activity.RESULT_OK)
- finish()
+ launchMain(NonCancellable) {
+ val tabs = adapter.adapterItems.subList(0, TAB_COUNT).map(TabIItem::item)
+ genericDao.saveTabs(tabs)
+ setResult(Activity.RESULT_OK)
+ finish()
+ }
}
fab_cancel.setIcon(GoogleMaterial.Icon.gmd_close, Prefs.iconColor)
fab_cancel.backgroundTintList = ColorStateList.valueOf(Prefs.accentColor)
diff --git a/app/src/main/kotlin/com/pitchedapps/frost/db/CacheDb.kt b/app/src/main/kotlin/com/pitchedapps/frost/db/CacheDb.kt
new file mode 100644
index 00000000..f0dacdc7
--- /dev/null
+++ b/app/src/main/kotlin/com/pitchedapps/frost/db/CacheDb.kt
@@ -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
+ }
+}
diff --git a/app/src/main/kotlin/com/pitchedapps/frost/db/CookiesDb.kt b/app/src/main/kotlin/com/pitchedapps/frost/db/CookiesDb.kt
new file mode 100644
index 00000000..b81ce365
--- /dev/null
+++ b/app/src/main/kotlin/com/pitchedapps/frost/db/CookiesDb.kt
@@ -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()})"
+}
diff --git a/app/src/main/kotlin/com/pitchedapps/frost/db/DaoUtils.kt b/app/src/main/kotlin/com/pitchedapps/frost/db/DaoUtils.kt
new file mode 100644
index 00000000..c31aa9b7
--- /dev/null
+++ b/app/src/main/kotlin/com/pitchedapps/frost/db/DaoUtils.kt
@@ -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() }
diff --git a/app/src/main/kotlin/com/pitchedapps/frost/db/Database.kt b/app/src/main/kotlin/com/pitchedapps/frost/db/Database.kt
new file mode 100644
index 00000000..67372e23
--- /dev/null
+++ b/app/src/main/kotlin/com/pitchedapps/frost/db/Database.kt
@@ -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()
+ }
+}
diff --git a/app/src/main/kotlin/com/pitchedapps/frost/dbflow/FbTabsDb.kt b/app/src/main/kotlin/com/pitchedapps/frost/db/FbTabsDb.kt
index 9d330169..a704ce82 100644
--- a/app/src/main/kotlin/com/pitchedapps/frost/dbflow/FbTabsDb.kt
+++ b/app/src/main/kotlin/com/pitchedapps/frost/db/FbTabsDb.kt
@@ -14,23 +14,18 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
-package com.pitchedapps.frost.dbflow
+package com.pitchedapps.frost.db
import com.pitchedapps.frost.facebook.FbItem
-import com.pitchedapps.frost.facebook.defaultTabs
-import com.pitchedapps.frost.utils.L
import com.raizlabs.android.dbflow.annotation.Database
import com.raizlabs.android.dbflow.annotation.PrimaryKey
import com.raizlabs.android.dbflow.annotation.Table
-import com.raizlabs.android.dbflow.kotlinextensions.database
-import com.raizlabs.android.dbflow.kotlinextensions.fastSave
-import com.raizlabs.android.dbflow.kotlinextensions.from
-import com.raizlabs.android.dbflow.kotlinextensions.select
import com.raizlabs.android.dbflow.structure.BaseModel
/**
* Created by Allan Wang on 2017-05-30.
*/
+
const val TAB_COUNT = 4
@Database(version = FbTabsDb.VERSION)
@@ -41,18 +36,3 @@ object FbTabsDb {
@Table(database = FbTabsDb::class, allFields = true)
data class FbTabModel(@PrimaryKey var position: Int = -1, var tab: FbItem = FbItem.FEED) : BaseModel()
-
-/**
- * Load tabs synchronously
- * Note that tab length should never be a big number anyways
- */
-fun loadFbTabs(): List<FbItem> {
- val tabs: List<FbTabModel>? = (select from (FbTabModel::class)).orderBy(FbTabModel_Table.position, true).queryList()
- if (tabs?.size == TAB_COUNT) return tabs.map(FbTabModel::tab)
- L.d { "No tabs (${tabs?.size}); loading default" }
- return defaultTabs()
-}
-
-fun List<FbItem>.save() {
- database<FbTabsDb>().beginTransactionAsync(mapIndexed(::FbTabModel).fastSave().build()).execute()
-}
diff --git a/app/src/main/kotlin/com/pitchedapps/frost/db/GenericDb.kt b/app/src/main/kotlin/com/pitchedapps/frost/db/GenericDb.kt
new file mode 100644
index 00000000..f36c8af9
--- /dev/null
+++ b/app/src/main/kotlin/com/pitchedapps/frost/db/GenericDb.kt
@@ -0,0 +1,71 @@
+/*
+ * Copyright 2018 Allan Wang
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+package com.pitchedapps.frost.db
+
+import androidx.room.Dao
+import androidx.room.Entity
+import androidx.room.Insert
+import androidx.room.OnConflictStrategy
+import androidx.room.PrimaryKey
+import androidx.room.Query
+import com.pitchedapps.frost.facebook.FbItem
+import com.pitchedapps.frost.facebook.defaultTabs
+
+/**
+ * Created by Allan Wang on 2017-05-30.
+ */
+
+/**
+ * Generic cache to store serialized content
+ */
+@Entity(tableName = "frost_generic")
+data class GenericEntity(
+ @PrimaryKey
+ val type: String,
+ val contents: String
+)
+
+@Dao
+interface GenericDao {
+
+ @Query("SELECT contents FROM frost_generic WHERE type = :type")
+ 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()
+}
diff --git a/app/src/main/kotlin/com/pitchedapps/frost/db/NotificationDb.kt b/app/src/main/kotlin/com/pitchedapps/frost/db/NotificationDb.kt
new file mode 100644
index 00000000..d4b51c1e
--- /dev/null
+++ b/app/src/main/kotlin/com/pitchedapps/frost/db/NotificationDb.kt
@@ -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)
diff --git a/app/src/main/kotlin/com/pitchedapps/frost/dbflow/CookiesDb.kt b/app/src/main/kotlin/com/pitchedapps/frost/dbflow/CookiesDb.kt
deleted file mode 100644
index 8411b8d7..00000000
--- a/app/src/main/kotlin/com/pitchedapps/frost/dbflow/CookiesDb.kt
+++ /dev/null
@@ -1,92 +0,0 @@
-/*
- * Copyright 2018 Allan Wang
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- */
-package com.pitchedapps.frost.dbflow
-
-import android.os.Parcelable
-import com.pitchedapps.frost.utils.L
-import com.raizlabs.android.dbflow.annotation.ConflictAction
-import com.raizlabs.android.dbflow.annotation.Database
-import com.raizlabs.android.dbflow.annotation.PrimaryKey
-import com.raizlabs.android.dbflow.annotation.Table
-import com.raizlabs.android.dbflow.kotlinextensions.async
-import com.raizlabs.android.dbflow.kotlinextensions.delete
-import com.raizlabs.android.dbflow.kotlinextensions.eq
-import com.raizlabs.android.dbflow.kotlinextensions.from
-import com.raizlabs.android.dbflow.kotlinextensions.save
-import com.raizlabs.android.dbflow.kotlinextensions.select
-import com.raizlabs.android.dbflow.kotlinextensions.where
-import com.raizlabs.android.dbflow.structure.BaseModel
-import kotlinx.android.parcel.Parcelize
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.withContext
-
-/**
- * Created by Allan Wang on 2017-05-30.
- */
-
-@Database(version = CookiesDb.VERSION)
-object CookiesDb {
- const val NAME = "Cookies"
- const val VERSION = 2
-}
-
-@Parcelize
-@Table(database = CookiesDb::class, allFields = true, primaryKeyConflict = ConflictAction.REPLACE)
-data class CookieModel(@PrimaryKey var id: Long = -1L, var name: String? = null, var cookie: String? = null) :
- BaseModel(), Parcelable {
-
- override fun toString(): String = "CookieModel(${hashCode()})"
-
- fun toSensitiveString(): String = "CookieModel(id=$id, name=$name, cookie=$cookie)"
-}
-
-fun loadFbCookie(id: Long): CookieModel? =
- (select from CookieModel::class where (CookieModel_Table.id eq id)).querySingle()
-
-fun loadFbCookie(name: String): CookieModel? =
- (select from CookieModel::class where (CookieModel_Table.name eq name)).querySingle()
-
-/**
- * Loads cookies sorted by name
- */
-fun loadFbCookiesAsync(callback: (cookies: List<CookieModel>) -> Unit) {
- (select from CookieModel::class).orderBy(CookieModel_Table.name, true).async()
- .queryListResultCallback { _, tResult -> callback(tResult) }.execute()
-}
-
-fun loadFbCookiesSync(): List<CookieModel> =
- (select from CookieModel::class).orderBy(CookieModel_Table.name, true).queryList()
-
-// TODO temp method until dbflow supports coroutines
-suspend fun loadFbCookiesSuspend(): List<CookieModel> = withContext(Dispatchers.IO) {
- loadFbCookiesSync()
-}
-
-inline fun saveFbCookie(cookie: CookieModel, crossinline callback: (() -> Unit) = {}) {
- cookie.async save {
- L.d { "Fb cookie saved" }
- L._d { cookie.toSensitiveString() }
- callback()
- }
-}
-
-fun removeCookie(id: Long) {
- loadFbCookie(id)?.async?.delete {
- L.d { "Fb cookie deleted" }
- L._d { id }
- }
-}
diff --git a/app/src/main/kotlin/com/pitchedapps/frost/dbflow/DbUtils.kt b/app/src/main/kotlin/com/pitchedapps/frost/dbflow/DbUtils.kt
deleted file mode 100644
index 740fef62..00000000
--- a/app/src/main/kotlin/com/pitchedapps/frost/dbflow/DbUtils.kt
+++ /dev/null
@@ -1,39 +0,0 @@
-/*
- * Copyright 2018 Allan Wang
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- */
-package com.pitchedapps.frost.dbflow
-
-import android.content.Context
-import com.pitchedapps.frost.utils.L
-import com.raizlabs.android.dbflow.config.FlowManager
-import com.raizlabs.android.dbflow.structure.database.transaction.FastStoreModelTransaction
-
-/**
- * Created by Allan Wang on 2017-05-30.
- */
-
-object DbUtils {
-
- fun db(name: String) = FlowManager.getDatabase(name)
- fun dbName(name: String) = "$name.db"
- fun deleteDatabase(c: Context, name: String) = c.deleteDatabase(dbName(name))
-}
-
-inline fun <reified T : Any> List<T>.replace(dbName: String) {
- L.d { "Replacing $dbName.db" }
- DbUtils.db(dbName).reset()
- FastStoreModelTransaction.saveBuilder(FlowManager.getModelAdapter(T::class.java)).addAll(this).build()
-}
diff --git a/app/src/main/kotlin/com/pitchedapps/frost/dbflow/NotificationDb.kt b/app/src/main/kotlin/com/pitchedapps/frost/dbflow/NotificationDb.kt
deleted file mode 100644
index a054d95e..00000000
--- a/app/src/main/kotlin/com/pitchedapps/frost/dbflow/NotificationDb.kt
+++ /dev/null
@@ -1,72 +0,0 @@
-/*
- * Copyright 2018 Allan Wang
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- */
-package com.pitchedapps.frost.dbflow
-
-import com.pitchedapps.frost.utils.L
-import com.raizlabs.android.dbflow.annotation.ConflictAction
-import com.raizlabs.android.dbflow.annotation.Database
-import com.raizlabs.android.dbflow.annotation.Migration
-import com.raizlabs.android.dbflow.annotation.PrimaryKey
-import com.raizlabs.android.dbflow.annotation.Table
-import com.raizlabs.android.dbflow.kotlinextensions.async
-import com.raizlabs.android.dbflow.kotlinextensions.eq
-import com.raizlabs.android.dbflow.kotlinextensions.from
-import com.raizlabs.android.dbflow.kotlinextensions.save
-import com.raizlabs.android.dbflow.kotlinextensions.select
-import com.raizlabs.android.dbflow.kotlinextensions.where
-import com.raizlabs.android.dbflow.sql.SQLiteType
-import com.raizlabs.android.dbflow.sql.migration.AlterTableMigration
-import com.raizlabs.android.dbflow.structure.BaseModel
-
-/**
- * Created by Allan Wang on 2017-05-30.
- */
-
-@Database(version = NotificationDb.VERSION)
-object NotificationDb {
- const val NAME = "Notifications"
- const val VERSION = 2
-}
-
-@Migration(version = 2, database = NotificationDb::class)
-class NotificationMigration2(modelClass: Class<NotificationModel>) :
- AlterTableMigration<NotificationModel>(modelClass) {
- override fun onPreMigrate() {
- super.onPreMigrate()
- addColumn(SQLiteType.INTEGER, "epochIm")
- L.d { "Added column" }
- }
-}
-
-@Table(database = NotificationDb::class, allFields = true, primaryKeyConflict = ConflictAction.REPLACE)
-data class NotificationModel(
- @PrimaryKey var id: Long = -1L,
- var epoch: Long = -1L,
- var epochIm: Long = -1L
-) : BaseModel()
-
-fun lastNotificationTime(id: Long): NotificationModel =
- (select from NotificationModel::class where (NotificationModel_Table.id eq id)).querySingle()
- ?: NotificationModel(id = id)
-
-fun saveNotificationTime(notificationModel: NotificationModel, callback: (() -> Unit)? = null) {
- notificationModel.async save {
- L.d { "Fb notification model saved" }
- L._d { notificationModel }
- callback?.invoke()
- }
-}
diff --git a/app/src/main/kotlin/com/pitchedapps/frost/facebook/FbCookie.kt b/app/src/main/kotlin/com/pitchedapps/frost/facebook/FbCookie.kt
index 5683526a..0c1da3a3 100644
--- a/app/src/main/kotlin/com/pitchedapps/frost/facebook/FbCookie.kt
+++ b/app/src/main/kotlin/com/pitchedapps/frost/facebook/FbCookie.kt
@@ -19,10 +19,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(name: String) = switchUser(loadFbCookie(name))
+ suspend fun switchUser(id: Long) {
+ val cookie = cookieDao.selectById(id) ?: return L.e { "No cookie for id" }
+ switchUser(cookie)
+ }
- suspend fun switchUser(cookie: CookieModel?) {
+ suspend fun switchUser(cookie: CookieEntity?) {
if (cookie == null) {
L.d { "Switching User; null cookie" }
return
@@ -114,7 +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()
}
diff --git a/app/src/main/kotlin/com/pitchedapps/frost/facebook/parsers/FrostParser.kt b/app/src/main/kotlin/com/pitchedapps/frost/facebook/parsers/FrostParser.kt
index 5709bb9f..4c494e0b 100644
--- a/app/src/main/kotlin/com/pitchedapps/frost/facebook/parsers/FrostParser.kt
+++ b/app/src/main/kotlin/com/pitchedapps/frost/facebook/parsers/FrostParser.kt
@@ -16,7 +16,7 @@
*/
package com.pitchedapps.frost.facebook.parsers
-import com.pitchedapps.frost.dbflow.CookieModel
+import com.pitchedapps.frost.db.CookieEntity
import com.pitchedapps.frost.facebook.FB_CSS_URL_MATCHER
import com.pitchedapps.frost.facebook.formattedFbUrl
import com.pitchedapps.frost.facebook.get
@@ -81,7 +81,7 @@ data class ParseResponse<out T>(val cookie: String, val data: T) {
}
interface ParseNotification {
- fun getUnreadNotifications(data: CookieModel): List<NotificationContent>
+ fun getUnreadNotifications(data: CookieEntity): List<NotificationContent>
}
internal fun <T> List<T>.toJsonString(tag: String, indent: Int) = StringBuilder().apply {
diff --git a/app/src/main/kotlin/com/pitchedapps/frost/facebook/parsers/MessageParser.kt b/app/src/main/kotlin/com/pitchedapps/frost/facebook/parsers/MessageParser.kt
index f05c42e9..9979cd2b 100644
--- a/app/src/main/kotlin/com/pitchedapps/frost/facebook/parsers/MessageParser.kt
+++ b/app/src/main/kotlin/com/pitchedapps/frost/facebook/parsers/MessageParser.kt
@@ -16,7 +16,7 @@
*/
package com.pitchedapps.frost.facebook.parsers
-import com.pitchedapps.frost.dbflow.CookieModel
+import com.pitchedapps.frost.db.CookieEntity
import com.pitchedapps.frost.facebook.FB_EPOCH_MATCHER
import com.pitchedapps.frost.facebook.FB_MESSAGE_NOTIF_ID_MATCHER
import com.pitchedapps.frost.facebook.FbItem
@@ -54,7 +54,7 @@ data class FrostMessages(
append("}")
}.toString()
- override fun getUnreadNotifications(data: CookieModel) =
+ override fun getUnreadNotifications(data: CookieEntity) =
threads.asSequence().filter(FrostThread::unread).map {
with(it) {
NotificationContent(
@@ -64,7 +64,8 @@ data class FrostMessages(
title = title,
text = content ?: "",
timestamp = time,
- profileUrl = img
+ profileUrl = img,
+ unread = unread
)
}
}.toList()
diff --git a/app/src/main/kotlin/com/pitchedapps/frost/facebook/parsers/NotifParser.kt b/app/src/main/kotlin/com/pitchedapps/frost/facebook/parsers/NotifParser.kt
index b8aa899b..c22524ad 100644
--- a/app/src/main/kotlin/com/pitchedapps/frost/facebook/parsers/NotifParser.kt
+++ b/app/src/main/kotlin/com/pitchedapps/frost/facebook/parsers/NotifParser.kt
@@ -16,7 +16,7 @@
*/
package com.pitchedapps.frost.facebook.parsers
-import com.pitchedapps.frost.dbflow.CookieModel
+import com.pitchedapps.frost.db.CookieEntity
import com.pitchedapps.frost.facebook.FB_EPOCH_MATCHER
import com.pitchedapps.frost.facebook.FB_NOTIF_ID_MATCHER
import com.pitchedapps.frost.facebook.FbItem
@@ -43,7 +43,7 @@ data class FrostNotifs(
append("}")
}.toString()
- override fun getUnreadNotifications(data: CookieModel) =
+ override fun getUnreadNotifications(data: CookieEntity) =
notifs.asSequence().filter(FrostNotif::unread).map {
with(it) {
NotificationContent(
@@ -53,7 +53,8 @@ data class FrostNotifs(
title = null,
text = content,
timestamp = time,
- profileUrl = img
+ profileUrl = img,
+ unread = unread
)
}
}.toList()
diff --git a/app/src/main/kotlin/com/pitchedapps/frost/services/FrostNotifications.kt b/app/src/main/kotlin/com/pitchedapps/frost/services/FrostNotifications.kt
index 54a9f8ae..1c37bc29 100644
--- a/app/src/main/kotlin/com/pitchedapps/frost/services/FrostNotifications.kt
+++ b/app/src/main/kotlin/com/pitchedapps/frost/services/FrostNotifications.kt
@@ -31,9 +31,10 @@ import ca.allanwang.kau.utils.string
import com.pitchedapps.frost.BuildConfig
import com.pitchedapps.frost.R
import com.pitchedapps.frost.activities.FrostWebActivity
-import com.pitchedapps.frost.dbflow.CookieModel
-import com.pitchedapps.frost.dbflow.NotificationModel
-import com.pitchedapps.frost.dbflow.lastNotificationTime
+import com.pitchedapps.frost.db.CookieEntity
+import com.pitchedapps.frost.db.FrostDatabase
+import com.pitchedapps.frost.db.latestEpoch
+import com.pitchedapps.frost.db.saveNotifications
import com.pitchedapps.frost.enums.OverlayContext
import com.pitchedapps.frost.facebook.FbItem
import com.pitchedapps.frost.facebook.parsers.FrostParser
@@ -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())
diff --git a/app/src/main/kotlin/com/pitchedapps/frost/services/NotificationService.kt b/app/src/main/kotlin/com/pitchedapps/frost/services/NotificationService.kt
index b1e0ac8c..0eee5558 100644
--- a/app/src/main/kotlin/com/pitchedapps/frost/services/NotificationService.kt
+++ b/app/src/main/kotlin/com/pitchedapps/frost/services/NotificationService.kt
@@ -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)
diff --git a/app/src/main/kotlin/com/pitchedapps/frost/settings/Notifications.kt b/app/src/main/kotlin/com/pitchedapps/frost/settings/Notifications.kt
index 0200f109..c58710b5 100644
--- a/app/src/main/kotlin/com/pitchedapps/frost/settings/Notifications.kt
+++ b/app/src/main/kotlin/com/pitchedapps/frost/settings/Notifications.kt
@@ -29,14 +29,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()
}
}
}
diff --git a/app/src/main/kotlin/com/pitchedapps/frost/utils/Downloader.kt b/app/src/main/kotlin/com/pitchedapps/frost/utils/Downloader.kt
index a8dc11f4..50863e10 100644
--- a/app/src/main/kotlin/com/pitchedapps/frost/utils/Downloader.kt
+++ b/app/src/main/kotlin/com/pitchedapps/frost/utils/Downloader.kt
@@ -29,7 +29,7 @@ import ca.allanwang.kau.utils.showAppInfo
import ca.allanwang.kau.utils.string
import ca.allanwang.kau.utils.toast
import com.pitchedapps.frost.R
-import com.pitchedapps.frost.dbflow.loadFbCookie
+import com.pitchedapps.frost.db.CookieEntity
import com.pitchedapps.frost.facebook.USER_AGENT_DESKTOP
/**
@@ -38,6 +38,7 @@ import com.pitchedapps.frost.facebook.USER_AGENT_DESKTOP
* With reference to <a href="https://stackoverflow.com/questions/33434532/android-webview-download-files-like-browsers-do">Stack Overflow</a>
*/
fun Context.frostDownload(
+ cookie: CookieEntity,
url: String?,
userAgent: String = USER_AGENT_DESKTOP,
contentDisposition: String? = null,
@@ -45,10 +46,11 @@ fun Context.frostDownload(
contentLength: Long = 0L
) {
url ?: return
- frostDownload(Uri.parse(url), userAgent, contentDisposition, mimeType, contentLength)
+ frostDownload(cookie, Uri.parse(url), userAgent, contentDisposition, mimeType, contentLength)
}
fun Context.frostDownload(
+ cookie: CookieEntity,
uri: Uri?,
userAgent: String = USER_AGENT_DESKTOP,
contentDisposition: String? = null,
@@ -75,7 +77,6 @@ fun Context.frostDownload(
if (!granted) return@kauRequestPermissions
val request = DownloadManager.Request(uri)
request.setMimeType(mimeType)
- val cookie = loadFbCookie(Prefs.userId) ?: return@kauRequestPermissions
val title = URLUtil.guessFileName(uri.toString(), contentDisposition, mimeType)
request.addRequestHeader("Cookie", cookie.cookie)
request.addRequestHeader("User-Agent", userAgent)
diff --git a/app/src/main/kotlin/com/pitchedapps/frost/utils/L.kt b/app/src/main/kotlin/com/pitchedapps/frost/utils/L.kt
index 8364c34e..7c8c1895 100644
--- a/app/src/main/kotlin/com/pitchedapps/frost/utils/L.kt
+++ b/app/src/main/kotlin/com/pitchedapps/frost/utils/L.kt
@@ -50,6 +50,11 @@ object L : KauLogger("Frost", {
d(message)
}
+ inline fun _e(e: Throwable?, message: () -> Any?) {
+ if (BuildConfig.DEBUG)
+ e(e, message)
+ }
+
override fun logImpl(priority: Int, message: String?, t: Throwable?) {
if (BuildConfig.DEBUG)
super.logImpl(priority, message, t)
diff --git a/app/src/main/kotlin/com/pitchedapps/frost/utils/TimeUtils.kt b/app/src/main/kotlin/com/pitchedapps/frost/utils/TimeUtils.kt
new file mode 100644
index 00000000..c0feab1e
--- /dev/null
+++ b/app/src/main/kotlin/com/pitchedapps/frost/utils/TimeUtils.kt
@@ -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)
+}
diff --git a/app/src/main/kotlin/com/pitchedapps/frost/utils/Utils.kt b/app/src/main/kotlin/com/pitchedapps/frost/utils/Utils.kt
index 7f9ac98b..76ffd8cd 100644
--- a/app/src/main/kotlin/com/pitchedapps/frost/utils/Utils.kt
+++ b/app/src/main/kotlin/com/pitchedapps/frost/utils/Utils.kt
@@ -62,7 +62,7 @@ import com.pitchedapps.frost.activities.TabCustomizerActivity
import com.pitchedapps.frost.activities.WebOverlayActivity
import com.pitchedapps.frost.activities.WebOverlayActivityBase
import com.pitchedapps.frost.activities.WebOverlayDesktopActivity
-import com.pitchedapps.frost.dbflow.CookieModel
+import com.pitchedapps.frost.db.CookieEntity
import com.pitchedapps.frost.facebook.FACEBOOK_COM
import com.pitchedapps.frost.facebook.FBCDN_NET
import com.pitchedapps.frost.facebook.FbCookie
@@ -103,7 +103,7 @@ internal inline val Context.ctxCoroutine: CoroutineScope
get() = this as? CoroutineScope ?: GlobalScope
inline fun <reified T : Activity> Context.launchNewTask(
- cookieList: ArrayList<CookieModel> = arrayListOf(),
+ cookieList: ArrayList<CookieEntity> = arrayListOf(),
clearStack: Boolean = false
) {
startActivity<T>(clearStack, intentBuilder = {
@@ -111,13 +111,13 @@ inline fun <reified T : Activity> Context.launchNewTask(
})
}
-fun Context.launchLogin(cookieList: ArrayList<CookieModel>, clearStack: Boolean = true) {
+fun Context.launchLogin(cookieList: ArrayList<CookieEntity>, clearStack: Boolean = true) {
if (cookieList.isNotEmpty()) launchNewTask<SelectorActivity>(cookieList, clearStack)
else launchNewTask<LoginActivity>(clearStack = clearStack)
}
-fun Activity.cookies(): ArrayList<CookieModel> {
- return intent?.getParcelableArrayListExtra<CookieModel>(EXTRA_COOKIES) ?: arrayListOf()
+fun Activity.cookies(): ArrayList<CookieEntity> {
+ return intent?.getParcelableArrayListExtra<CookieEntity>(EXTRA_COOKIES) ?: arrayListOf()
}
/**
@@ -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
diff --git a/app/src/main/kotlin/com/pitchedapps/frost/views/AccountItem.kt b/app/src/main/kotlin/com/pitchedapps/frost/views/AccountItem.kt
index d60ea7ed..0269b1a9 100644
--- a/app/src/main/kotlin/com/pitchedapps/frost/views/AccountItem.kt
+++ b/app/src/main/kotlin/com/pitchedapps/frost/views/AccountItem.kt
@@ -33,7 +33,7 @@ import com.bumptech.glide.request.RequestListener
import com.bumptech.glide.request.target.Target
import com.mikepenz.google_material_typeface_library.GoogleMaterial
import com.pitchedapps.frost.R
-import com.pitchedapps.frost.dbflow.CookieModel
+import com.pitchedapps.frost.db.CookieEntity
import com.pitchedapps.frost.facebook.profilePictureUrl
import com.pitchedapps.frost.glide.FrostGlide
import com.pitchedapps.frost.glide.GlideApp
@@ -42,7 +42,7 @@ import com.pitchedapps.frost.utils.Prefs
/**
* Created by Allan Wang on 2017-06-05.
*/
-class AccountItem(val cookie: CookieModel?) : KauIItem<AccountItem, AccountItem.ViewHolder>
+class AccountItem(val cookie: CookieEntity?) : KauIItem<AccountItem, AccountItem.ViewHolder>
(R.layout.view_account, { ViewHolder(it) }, R.id.item_account) {
override fun bindView(viewHolder: ViewHolder, payloads: MutableList<Any>) {
diff --git a/app/src/main/kotlin/com/pitchedapps/frost/views/FrostVideoViewer.kt b/app/src/main/kotlin/com/pitchedapps/frost/views/FrostVideoViewer.kt
index c2535940..4d88ad3d 100644
--- a/app/src/main/kotlin/com/pitchedapps/frost/views/FrostVideoViewer.kt
+++ b/app/src/main/kotlin/com/pitchedapps/frost/views/FrostVideoViewer.kt
@@ -32,6 +32,7 @@ import ca.allanwang.kau.utils.inflate
import ca.allanwang.kau.utils.isColorDark
import ca.allanwang.kau.utils.isGone
import ca.allanwang.kau.utils.isVisible
+import ca.allanwang.kau.utils.launchMain
import ca.allanwang.kau.utils.setIcon
import ca.allanwang.kau.utils.setMenuIcons
import ca.allanwang.kau.utils.visible
@@ -39,8 +40,11 @@ import ca.allanwang.kau.utils.withMinAlpha
import com.devbrackets.android.exomedia.listener.VideoControlsVisibilityListener
import com.mikepenz.google_material_typeface_library.GoogleMaterial
import com.pitchedapps.frost.R
+import com.pitchedapps.frost.db.FrostDatabase
+import com.pitchedapps.frost.db.currentCookie
import com.pitchedapps.frost.utils.L
import com.pitchedapps.frost.utils.Prefs
+import com.pitchedapps.frost.utils.ctxCoroutine
import com.pitchedapps.frost.utils.frostDownload
import kotlinx.android.synthetic.main.view_video.view.*
@@ -96,7 +100,10 @@ class FrostVideoViewer @JvmOverloads constructor(
video_toolbar.setOnMenuItemClickListener {
when (it.itemId) {
R.id.action_pip -> video.isExpanded = false
- R.id.action_download -> context.frostDownload(video.videoUri)
+ R.id.action_download -> context.ctxCoroutine.launchMain {
+ val cookie = FrostDatabase.get().cookieDao().currentCookie() ?: return@launchMain
+ context.frostDownload(cookie, video.videoUri)
+ }
}
true
}
diff --git a/app/src/main/kotlin/com/pitchedapps/frost/views/FrostWebView.kt b/app/src/main/kotlin/com/pitchedapps/frost/views/FrostWebView.kt
index cc8e3fbc..1dd027fd 100644
--- a/app/src/main/kotlin/com/pitchedapps/frost/views/FrostWebView.kt
+++ b/app/src/main/kotlin/com/pitchedapps/frost/views/FrostWebView.kt
@@ -24,15 +24,19 @@ import android.util.AttributeSet
import android.view.View
import android.view.ViewGroup
import ca.allanwang.kau.utils.AnimHolder
+import ca.allanwang.kau.utils.launchMain
import com.pitchedapps.frost.contracts.FrostContentContainer
import com.pitchedapps.frost.contracts.FrostContentCore
import com.pitchedapps.frost.contracts.FrostContentParent
+import com.pitchedapps.frost.db.FrostDatabase
+import com.pitchedapps.frost.db.currentCookie
import com.pitchedapps.frost.facebook.FB_HOME_URL
import com.pitchedapps.frost.facebook.FbItem
import com.pitchedapps.frost.facebook.USER_AGENT_DESKTOP
import com.pitchedapps.frost.facebook.USER_AGENT_MOBILE
import com.pitchedapps.frost.fragments.WebFragment
import com.pitchedapps.frost.utils.Prefs
+import com.pitchedapps.frost.utils.ctxCoroutine
import com.pitchedapps.frost.utils.frostDownload
import com.pitchedapps.frost.web.FrostChromeClient
import com.pitchedapps.frost.web.FrostJSI
@@ -81,7 +85,13 @@ class FrostWebView @JvmOverloads constructor(
webChromeClient = FrostChromeClient(this)
addJavascriptInterface(FrostJSI(this), "Frost")
setBackgroundColor(Color.TRANSPARENT)
- setDownloadListener(context::frostDownload)
+ val db = FrostDatabase.get()
+ setDownloadListener { url, userAgent, contentDisposition, mimetype, contentLength ->
+ context.ctxCoroutine.launchMain {
+ val cookie = db.cookieDao().currentCookie() ?: return@launchMain
+ context.frostDownload(cookie, url, userAgent, contentDisposition, mimetype, contentLength)
+ }
+ }
return this
}
diff --git a/app/src/main/kotlin/com/pitchedapps/frost/web/DebugWebView.kt b/app/src/main/kotlin/com/pitchedapps/frost/web/DebugWebView.kt
index 6511ef9f..c66180ed 100644
--- a/app/src/main/kotlin/com/pitchedapps/frost/web/DebugWebView.kt
+++ b/app/src/main/kotlin/com/pitchedapps/frost/web/DebugWebView.kt
@@ -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}" }
diff --git a/app/src/main/kotlin/com/pitchedapps/frost/web/FrostJSI.kt b/app/src/main/kotlin/com/pitchedapps/frost/web/FrostJSI.kt
index 50a5e2e1..0d980ba0 100644
--- a/app/src/main/kotlin/com/pitchedapps/frost/web/FrostJSI.kt
+++ b/app/src/main/kotlin/com/pitchedapps/frost/web/FrostJSI.kt
@@ -21,7 +21,7 @@ import android.webkit.JavascriptInterface
import com.pitchedapps.frost.activities.MainActivity
import com.pitchedapps.frost.contracts.MainActivityContract
import com.pitchedapps.frost.contracts.VideoViewHolder
-import com.pitchedapps.frost.dbflow.CookieModel
+import com.pitchedapps.frost.db.CookieEntity
import com.pitchedapps.frost.facebook.FbCookie
import com.pitchedapps.frost.utils.L
import com.pitchedapps.frost.utils.Prefs
@@ -44,7 +44,7 @@ class FrostJSI(val web: FrostWebView) {
private val activity: MainActivity? = context as? MainActivity
private val header: SendChannel<String>? = activity?.headerBadgeChannel
private val refresh: SendChannel<Boolean> = web.parent.refreshChannel
- private val cookies: List<CookieModel> = activity?.cookies() ?: arrayListOf()
+ private val cookies: List<CookieEntity> = activity?.cookies() ?: arrayListOf()
/**
* Attempts to load the url in an overlay
diff --git a/app/src/main/kotlin/com/pitchedapps/frost/web/LoginWebView.kt b/app/src/main/kotlin/com/pitchedapps/frost/web/LoginWebView.kt
index c27385fc..79c6d5ba 100644
--- a/app/src/main/kotlin/com/pitchedapps/frost/web/LoginWebView.kt
+++ b/app/src/main/kotlin/com/pitchedapps/frost/web/LoginWebView.kt
@@ -29,7 +29,7 @@ import android.webkit.WebView
import ca.allanwang.kau.utils.fadeIn
import ca.allanwang.kau.utils.isVisible
import ca.allanwang.kau.utils.launchMain
-import com.pitchedapps.frost.dbflow.CookieModel
+import com.pitchedapps.frost.db.CookieEntity
import com.pitchedapps.frost.facebook.FB_LOGIN_URL
import com.pitchedapps.frost.facebook.FB_USER_MATCHER
import com.pitchedapps.frost.facebook.FbCookie
@@ -51,7 +51,7 @@ class LoginWebView @JvmOverloads constructor(
defStyleAttr: Int = 0
) : WebView(context, attrs, defStyleAttr) {
- private val completable: CompletableDeferred<CookieModel> = CompletableDeferred()
+ private val completable: CompletableDeferred<CookieEntity> = CompletableDeferred()
private lateinit var progressCallback: (Int) -> Unit
@SuppressLint("SetJavaScriptEnabled")
@@ -62,7 +62,7 @@ class LoginWebView @JvmOverloads constructor(
webChromeClient = LoginChromeClient()
}
- suspend fun loadLogin(progressCallback: (Int) -> Unit): CompletableDeferred<CookieModel> = coroutineScope {
+ suspend fun loadLogin(progressCallback: (Int) -> Unit): CompletableDeferred<CookieEntity> = coroutineScope {
this@LoginWebView.progressCallback = progressCallback
L.d { "Begin loading login" }
launchMain {
@@ -77,18 +77,18 @@ class LoginWebView @JvmOverloads constructor(
override fun onPageFinished(view: WebView, url: String?) {
super.onPageFinished(view, url)
- val cookieModel = checkForLogin(url)
- if (cookieModel != null)
- completable.complete(cookieModel)
+ val cookie = checkForLogin(url)
+ if (cookie != null)
+ completable.complete(cookie)
if (!view.isVisible) view.fadeIn()
}
- fun checkForLogin(url: String?): CookieModel? {
+ fun checkForLogin(url: String?): CookieEntity? {
if (!url.isFacebookUrl) return null
val cookie = CookieManager.getInstance().getCookie(url) ?: return null
L.d { "Checking cookie for login" }
val id = FB_USER_MATCHER.find(cookie)[1]?.toLong() ?: return null
- return CookieModel(id, "", cookie)
+ return CookieEntity(id, null, cookie)
}
override fun onPageCommitVisible(view: WebView, url: String?) {
diff --git a/app/src/main/kotlin/com/pitchedapps/frost/widgets/NotificationWidget.kt b/app/src/main/kotlin/com/pitchedapps/frost/widgets/NotificationWidget.kt
new file mode 100644
index 00000000..594da00a
--- /dev/null
+++ b/app/src/main/kotlin/com/pitchedapps/frost/widgets/NotificationWidget.kt
@@ -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() {
+ }
+}
diff --git a/app/src/main/res/drawable/notification_widget_preview.xml b/app/src/main/res/drawable/notification_widget_preview.xml
new file mode 100644
index 00000000..a03fd362
--- /dev/null
+++ b/app/src/main/res/drawable/notification_widget_preview.xml
@@ -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>
diff --git a/app/src/main/res/layout/widget_notification_item.xml b/app/src/main/res/layout/widget_notification_item.xml
new file mode 100644
index 00000000..f36f2766
--- /dev/null
+++ b/app/src/main/res/layout/widget_notification_item.xml
@@ -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> \ No newline at end of file
diff --git a/app/src/main/res/layout/widget_notifications.xml b/app/src/main/res/layout/widget_notifications.xml
new file mode 100644
index 00000000..4c42be85
--- /dev/null
+++ b/app/src/main/res/layout/widget_notifications.xml
@@ -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> \ No newline at end of file
diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml
index 713bd1b4..847e74cb 100644
--- a/app/src/main/res/values/dimens.xml
+++ b/app/src/main/res/values/dimens.xml
@@ -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>
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 5fd35613..0c2e625c 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -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>
diff --git a/app/src/main/res/xml/frost_changelog.xml b/app/src/main/res/xml/frost_changelog.xml
index 1895e46f..560b1111 100644
--- a/app/src/main/res/xml/frost_changelog.xml
+++ b/app/src/main/res/xml/frost_changelog.xml
@@ -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" />
diff --git a/app/src/main/res/xml/notification_widget_info.xml b/app/src/main/res/xml/notification_widget_info.xml
new file mode 100644
index 00000000..c14bbfb2
--- /dev/null
+++ b/app/src/main/res/xml/notification_widget_info.xml
@@ -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" />