165: Allow backup and restore of saved posts r=msfjarvis a=msfjarvis

Very much WIP, lacks essentially everything right now.


Co-authored-by: Aditya Wasan <adityawasan55@gmail.com>
Co-authored-by: Harsh Shandilya <me@msfjarvis.dev>
This commit is contained in:
bors[bot] 2021-04-03 10:03:10 +00:00 committed by GitHub
commit e50a8e8d68
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
25 changed files with 389 additions and 28 deletions

8
.idea/artifacts/database_jvm.xml generated Normal file
View file

@ -0,0 +1,8 @@
<component name="ArtifactManager">
<artifact type="jar" name="database-jvm">
<output-path>$PROJECT_DIR$/database/build/libs</output-path>
<root id="archive" name="database-jvm.jar">
<element id="module-output" name="database.Claw.database.jvmMain" />
</root>
</artifact>
</component>

View file

@ -44,7 +44,6 @@ dependencies {
implementation(Dependencies.ThirdParty.accompanistFlow)
implementation(Dependencies.ThirdParty.Moshi.lib)
implementation(Dependencies.ThirdParty.Retrofit.moshi)
implementation(Dependencies.ThirdParty.SQLDelight.androidDriver)
testImplementation(kotlin("test-junit"))
androidTestImplementation(kotlin("test-junit"))
}

View file

@ -24,6 +24,7 @@ class LobstersTopBarTest : ScreenshotTest {
LobstersTopAppBar(
currentDestination = Destination.Hottest,
toggleSortingOrder = {},
launchSettings = {},
)
}
}
@ -38,6 +39,7 @@ class LobstersTopBarTest : ScreenshotTest {
LobstersTopAppBar(
currentDestination = Destination.Hottest,
toggleSortingOrder = {},
launchSettings = {},
)
}
}
@ -52,6 +54,7 @@ class LobstersTopBarTest : ScreenshotTest {
LobstersTopAppBar(
currentDestination = Destination.Saved,
toggleSortingOrder = {},
launchSettings = {},
)
}
}
@ -66,6 +69,7 @@ class LobstersTopBarTest : ScreenshotTest {
LobstersTopAppBar(
currentDestination = Destination.Saved,
toggleSortingOrder = {},
launchSettings = {},
)
}
}

View file

@ -25,6 +25,14 @@
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<activity
android:name=".ui.settings.SettingsActivity"
android:theme="@style/Platform.Theme.Claw"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
</intent-filter>
</activity>
</application>
</manifest>

View file

@ -0,0 +1,32 @@
package dev.msfjarvis.lobsters.data.backup
import com.squareup.moshi.Moshi
import com.squareup.moshi.adapter
import dev.msfjarvis.lobsters.data.local.SavedPost
import dev.msfjarvis.lobsters.database.LobstersDatabase
import javax.inject.Inject
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@OptIn(ExperimentalStdlibApi::class)
class BackupHandler
@Inject
constructor(
private val database: LobstersDatabase,
moshi: Moshi,
) {
private val adapter = moshi.adapter<List<SavedPost>>()
suspend fun exportSavedPosts(): ByteArray {
val posts =
withContext(Dispatchers.IO) { database.savedPostQueries.selectAllPosts().executeAsList() }
return adapter.toJson(posts).toByteArray(Charsets.UTF_8)
}
suspend fun importSavedPosts(json: ByteArray) {
withContext(Dispatchers.IO) {
val posts = requireNotNull(adapter.fromJson(json.toString(Charsets.UTF_8)))
database.transaction { posts.forEach { database.savedPostQueries.insertOrReplacePost(it) } }
}
}
}

View file

@ -0,0 +1,21 @@
package dev.msfjarvis.lobsters.injection
import com.squareup.moshi.Moshi
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.components.ActivityComponent
import dev.msfjarvis.lobsters.data.backup.BackupHandler
import dev.msfjarvis.lobsters.database.LobstersDatabase
@Module
@InstallIn(ActivityComponent::class)
object BackupModule {
@Provides
fun provideBackupHandler(
database: LobstersDatabase,
moshi: Moshi,
): BackupHandler {
return BackupHandler(database, moshi)
}
}

View file

@ -1,41 +1,29 @@
package dev.msfjarvis.lobsters.injection
import android.content.Context
import com.squareup.sqldelight.android.AndroidSqliteDriver
import com.squareup.sqldelight.db.SqlDriver
import dagger.Module
import dagger.Provides
import dagger.Reusable
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import dev.msfjarvis.lobsters.data.local.SavedPost
import dev.msfjarvis.lobsters.data.local.DriverFactory
import dev.msfjarvis.lobsters.data.local.createDatabase
import dev.msfjarvis.lobsters.database.LobstersDatabase
import dev.msfjarvis.lobsters.model.TagsAdapter
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object DatabaseModule {
@Provides
@Reusable
fun providesTagsAdapter(): TagsAdapter {
return TagsAdapter()
}
@Provides
@Singleton
fun providesSqlDriver(@ApplicationContext context: Context): SqlDriver {
return AndroidSqliteDriver(LobstersDatabase.Schema, context, "SavedPosts.db")
fun providesDriverFactory(@ApplicationContext context: Context): DriverFactory {
return DriverFactory(context)
}
@Provides
@Singleton
fun providesLobstersDatabase(sqlDriver: SqlDriver, tagsAdapter: TagsAdapter): LobstersDatabase {
return LobstersDatabase(
sqlDriver,
SavedPost.Adapter(tagsAdapter),
)
fun providesLobstersDatabase(driverFactory: DriverFactory): LobstersDatabase {
return createDatabase(driverFactory)
}
}

View file

@ -1,5 +1,6 @@
package dev.msfjarvis.lobsters.ui.main
import android.content.Intent
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.BottomNavigation
@ -11,6 +12,7 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.compose.KEY_ROUTE
@ -24,6 +26,7 @@ import androidx.paging.compose.collectAsLazyPagingItems
import dev.msfjarvis.lobsters.ui.navigation.Destination
import dev.msfjarvis.lobsters.ui.posts.NetworkPosts
import dev.msfjarvis.lobsters.ui.posts.SavedPosts
import dev.msfjarvis.lobsters.ui.settings.SettingsActivity
import dev.msfjarvis.lobsters.ui.viewmodel.LobstersViewModel
import dev.msfjarvis.lobsters.util.IconResource
import dev.msfjarvis.lobsters.utils.get
@ -32,6 +35,7 @@ import kotlinx.coroutines.launch
@Composable
fun LobstersApp() {
val viewModel: LobstersViewModel = viewModel()
val context = LocalContext.current
val navController = rememberNavController()
val hottestPosts = viewModel.hottestPosts.collectAsLazyPagingItems()
val newestPosts = viewModel.newestPosts.collectAsLazyPagingItems()
@ -70,7 +74,7 @@ fun LobstersApp() {
LobstersTopAppBar(
currentDestination,
viewModel::toggleSortOrder,
)
) { context.startActivity(Intent(context, SettingsActivity::class.java)) }
},
bottomBar = {
LobstersBottomNav(

View file

@ -22,6 +22,7 @@ import kotlinx.coroutines.launch
fun LobstersTopAppBar(
currentDestination: Destination,
toggleSortingOrder: suspend () -> Unit,
launchSettings: () -> Unit,
) {
val scope = rememberCoroutineScope()
TopAppBar(
@ -42,6 +43,14 @@ fun LobstersTopAppBar(
},
)
}
IconResource(
resourceId = R.drawable.ic_app_settings_24px,
contentDescription = Strings.Settings.get(),
modifier =
Modifier.padding(horizontal = 8.dp, vertical = 8.dp).clickable {
scope.launch { launchSettings() }
},
)
}
)
}

View file

@ -0,0 +1,60 @@
package dev.msfjarvis.lobsters.ui.settings
import android.content.Context
import androidx.activity.compose.registerForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.Composable
import dev.msfjarvis.lobsters.data.backup.BackupHandler
import dev.msfjarvis.lobsters.utils.Strings
import dev.msfjarvis.lobsters.utils.get
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
private const val JSON_MINE = "application/json"
@Composable
fun BackupOption(
context: Context,
backupHandler: BackupHandler,
coroutineScope: CoroutineScope,
) {
val result =
registerForActivityResult(ActivityResultContracts.CreateDocument()) { uri ->
if (uri == null) return@registerForActivityResult
context.contentResolver.openOutputStream(uri)?.let {
coroutineScope.launch(Dispatchers.IO) {
it.write(backupHandler.exportSavedPosts())
it.close()
}
}
}
SettingsActionItem(
Strings.SettingsBackup.get(),
Strings.SettingsBackupDescription.get(),
onClick = { result.launch("Claw-export.json") }
)
}
@Composable
fun RestoreOption(
context: Context,
backupHandler: BackupHandler,
coroutineScope: CoroutineScope,
) {
val result =
registerForActivityResult(ActivityResultContracts.GetContent()) { uri ->
if (uri == null) return@registerForActivityResult
context.contentResolver.openInputStream(uri)?.let {
coroutineScope.launch(Dispatchers.IO) {
backupHandler.importSavedPosts(it.readBytes())
it.close()
}
}
}
SettingsActionItem(
title = Strings.SettingsRestore.get(),
description = Strings.SettingsRestoreDescription.get(),
onClick = { result.launch(JSON_MINE) }
)
}

View file

@ -0,0 +1,95 @@
package dev.msfjarvis.lobsters.ui.settings
import android.content.Context
import androidx.activity.ComponentActivity
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.Icon
import androidx.compose.material.ListItem
import androidx.compose.material.Scaffold
import androidx.compose.material.Text
import androidx.compose.material.TopAppBar
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import dev.msfjarvis.lobsters.data.backup.BackupHandler
import dev.msfjarvis.lobsters.utils.Strings
import dev.msfjarvis.lobsters.utils.get
import kotlinx.coroutines.CoroutineScope
@Composable
fun LobstersSettings(
backupHandler: BackupHandler,
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
Scaffold(
topBar = { SettingsTopBar(context) },
content = { SettingsBody(context, backupHandler, scope) },
)
}
@Composable
fun SettingsTopBar(
context: Context,
) {
TopAppBar(
title = { Text(Strings.Settings.get()) },
navigationIcon = {
Icon(
Icons.Default.ArrowBack,
contentDescription = Strings.Settings.get(),
modifier =
Modifier.padding(start = 16.dp).clickable { (context as ComponentActivity).finish() },
)
},
)
}
@Composable
fun SettingsBody(
context: Context,
backupHandler: BackupHandler,
scope: CoroutineScope,
) {
LazyColumn {
item {
BackupOption(
context,
backupHandler,
scope,
)
}
item {
RestoreOption(
context,
backupHandler,
scope,
)
}
}
}
@Composable
fun SettingsActionItem(
title: String,
description: String? = null,
singleLineDescription: Boolean = true,
icon: ImageVector? = null,
onClick: (() -> Unit)? = null,
) {
ListItem(
text = { Text(title) },
secondaryText = { description?.let { Text(it) } },
icon = { icon?.let { Icon(icon, null, Modifier.height(32.dp)) } },
singleLineSecondaryText = singleLineDescription,
modifier = Modifier.clickable { onClick?.invoke() },
)
}

View file

@ -0,0 +1,20 @@
package dev.msfjarvis.lobsters.ui.settings
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import dagger.hilt.android.AndroidEntryPoint
import dev.msfjarvis.lobsters.data.backup.BackupHandler
import dev.msfjarvis.lobsters.ui.theme.LobstersTheme
import javax.inject.Inject
@AndroidEntryPoint
class SettingsActivity : ComponentActivity() {
@Inject lateinit var backupHandler: BackupHandler
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent { LobstersTheme { LobstersSettings(backupHandler) } }
}
}

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FF000000"
android:pathData="M21.81,12.74l-0.82,-0.63v-0.22l0.8,-0.63c0.16,-0.12 0.2,-0.34 0.1,-0.51l-0.85,-1.48c-0.07,-0.13 -0.21,-0.2 -0.35,-0.2 -0.05,0 -0.1,0.01 -0.15,0.03l-0.95,0.38c-0.08,-0.05 -0.11,-0.07 -0.19,-0.11l-0.15,-1.01c-0.03,-0.21 -0.2,-0.36 -0.4,-0.36h-1.71c-0.2,0 -0.37,0.15 -0.4,0.34l-0.14,1.01c-0.03,0.02 -0.07,0.03 -0.1,0.05l-0.09,0.06 -0.95,-0.38c-0.05,-0.02 -0.1,-0.03 -0.15,-0.03 -0.14,0 -0.27,0.07 -0.35,0.2l-0.85,1.48c-0.1,0.17 -0.06,0.39 0.1,0.51l0.8,0.63v0.23l-0.8,0.63c-0.16,0.12 -0.2,0.34 -0.1,0.51l0.85,1.48c0.07,0.13 0.21,0.2 0.35,0.2 0.05,0 0.1,-0.01 0.15,-0.03l0.95,-0.37c0.08,0.05 0.12,0.07 0.2,0.11l0.15,1.01c0.03,0.2 0.2,0.34 0.4,0.34h1.71c0.2,0 0.37,-0.15 0.4,-0.34l0.15,-1.01c0.03,-0.02 0.07,-0.03 0.1,-0.05l0.09,-0.06 0.95,0.38c0.05,0.02 0.1,0.03 0.15,0.03 0.14,0 0.27,-0.07 0.35,-0.2l0.85,-1.48c0.1,-0.17 0.06,-0.39 -0.1,-0.51zM18,13.5c-0.83,0 -1.5,-0.67 -1.5,-1.5s0.67,-1.5 1.5,-1.5 1.5,0.67 1.5,1.5 -0.67,1.5 -1.5,1.5zM17,17h2v4c0,1.1 -0.9,2 -2,2H7c-1.1,0 -2,-0.9 -2,-2V3c0,-1.1 0.9,-2 2,-2h10c1.1,0 2,0.9 2,2v4h-2V6H7v12h10v-1z" />
</vector>

View file

@ -11,13 +11,18 @@ private fun stringEnumMapper(stringEnum: Strings): Int {
Strings.AvatarContentDescription -> R.string.avatar_content_description
Strings.ChangeSortingOrder -> R.string.change_sorting_order
Strings.HottestPosts -> R.string.hottest_posts
Strings.NewestPosts -> R.string.newest_posts
Strings.NoSavedPost -> R.string.no_saved_posts
Strings.OpenComments -> R.string.open_comments
Strings.RefreshPostsContentDescription -> R.string.refresh_posts_content_description
Strings.RemoveFromSavedPosts -> R.string.remove_from_saved_posts
Strings.SavedPosts -> R.string.saved_posts
Strings.SubmittedBy -> R.string.submitted_by
Strings.NewestPosts -> R.string.newest_posts
Strings.Settings -> R.string.settings
Strings.SettingsBackup -> R.string.settings_backup
Strings.SettingsBackupDescription -> R.string.settings_backup_desc
Strings.SettingsRestore -> R.string.settings_restore
Strings.SettingsRestoreDescription -> R.string.settings_restore_desc
}
}

View file

@ -11,4 +11,9 @@
<string name="open_comments">Open comments</string>
<string name="change_sorting_order">Change sort order</string>
<string name="newest_posts">Newest</string>
<string name="settings">Settings</string>
<string name="settings_backup">Backup saved posts</string>
<string name="settings_backup_desc">Export saved posts in a JSON file that can be restored later</string>
<string name="settings_restore">Restore saved posts</string>
<string name="settings_restore_desc">Import a previously exported copy of saved posts. Existing saved posts are not cleared</string>
</resources>

View file

@ -13,4 +13,9 @@ enum class Strings {
SavedPosts,
SubmittedBy,
NewestPosts,
Settings,
SettingsBackup,
SettingsBackupDescription,
SettingsRestore,
SettingsRestoreDescription,
}

View file

@ -16,6 +16,13 @@ private fun stringEnumMapper(stringEnum: Strings): String {
Strings.SavedPosts -> "Saved"
Strings.SubmittedBy -> "submitted by %1s"
Strings.NewestPosts -> "Newest"
Strings.Settings -> "Settings"
Strings.SettingsBackup -> "Backup saved posts"
Strings.SettingsBackupDescription ->
"Export saved posts in a JSON file that can be restored later"
Strings.SettingsRestore -> "Restore saved posts"
Strings.SettingsRestoreDescription ->
"Import a previously exported copy of saved posts. Existing saved posts are not cleared"
}
}

View file

@ -1,11 +1,48 @@
plugins {
kotlin("jvm")
kotlin("multiplatform")
id("com.android.library")
id("com.squareup.sqldelight")
`lobsters-plugin`
}
dependencies {
testImplementation(Dependencies.Kotlin.Coroutines.core)
testImplementation(Dependencies.ThirdParty.SQLDelight.jvmDriver)
testImplementation(kotlin("test-junit"))
// workaround for https://youtrack.jetbrains.com/issue/KT-43944
android {
configurations {
create("androidTestApi")
create("androidTestDebugApi")
create("androidTestReleaseApi")
create("testApi")
create("testDebugApi")
create("testReleaseApi")
}
}
kotlin {
jvm()
android()
sourceSets {
val commonMain by getting {
}
val jvmTest by getting {
dependencies {
implementation(Dependencies.Kotlin.Coroutines.core)
implementation(kotlin("test-junit"))
}
}
val jvmMain by getting {
dependencies {
implementation(Dependencies.ThirdParty.SQLDelight.jvmDriver)
}
}
val androidMain by getting {
dependencies {
implementation(Dependencies.ThirdParty.SQLDelight.androidDriver)
}
}
}
}
android {
sourceSets["main"].manifest.srcFile("src/androidMain/AndroidManifest.xml")
}

View file

@ -0,0 +1,2 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="dev.msfjarvis.lobsters.database" />

View file

@ -0,0 +1,12 @@
package dev.msfjarvis.lobsters.data.local
import android.content.Context
import com.squareup.sqldelight.android.AndroidSqliteDriver
import com.squareup.sqldelight.db.SqlDriver
import dev.msfjarvis.lobsters.database.LobstersDatabase
actual class DriverFactory(private val context: Context) {
actual fun createDriver(): SqlDriver {
return AndroidSqliteDriver(LobstersDatabase.Schema, context, LobstersDatabaseName)
}
}

View file

@ -0,0 +1,18 @@
package dev.msfjarvis.lobsters.data.local
import com.squareup.sqldelight.db.SqlDriver
import dev.msfjarvis.lobsters.data.model.TagsAdapter
import dev.msfjarvis.lobsters.database.LobstersDatabase
internal const val LobstersDatabaseName = "SavedPosts.db"
expect class DriverFactory {
fun createDriver(): SqlDriver
}
private fun getTagsAdapter() = TagsAdapter()
fun createDatabase(driverFactory: DriverFactory): LobstersDatabase {
val driver = driverFactory.createDriver()
return LobstersDatabase(driver, SavedPost.Adapter(getTagsAdapter()))
}

View file

@ -1,4 +1,4 @@
package dev.msfjarvis.lobsters.model
package dev.msfjarvis.lobsters.data.model
import com.squareup.sqldelight.ColumnAdapter

View file

@ -0,0 +1,13 @@
package dev.msfjarvis.lobsters.data.local
import com.squareup.sqldelight.db.SqlDriver
import com.squareup.sqldelight.sqlite.driver.JdbcSqliteDriver
import dev.msfjarvis.lobsters.database.LobstersDatabase
actual class DriverFactory {
actual fun createDriver(): SqlDriver {
val driver: SqlDriver = JdbcSqliteDriver(JdbcSqliteDriver.IN_MEMORY)
LobstersDatabase.Schema.create(driver)
return driver
}
}

View file

@ -1,8 +1,8 @@
package dev.msfjarvis.lobsters.data.local
import com.squareup.sqldelight.sqlite.driver.JdbcSqliteDriver
import dev.msfjarvis.lobsters.data.model.TagsAdapter
import dev.msfjarvis.lobsters.database.LobstersDatabase
import dev.msfjarvis.lobsters.model.TagsAdapter
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlinx.coroutines.runBlocking