diff --git a/.idea/gradle.xml b/.idea/gradle.xml
index 526b4c2..736480f 100644
--- a/.idea/gradle.xml
+++ b/.idea/gradle.xml
@@ -11,6 +11,9 @@
+
+
+
diff --git a/.idea/misc.xml b/.idea/misc.xml
index 6199cc2..35279bc 100644
--- a/.idea/misc.xml
+++ b/.idea/misc.xml
@@ -1,5 +1,22 @@
+
+
+
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 6788c8c..3b11f86 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -6,29 +6,19 @@ plugins {
}
android {
- compileSdk = 30
- buildToolsVersion = "30.0.3"
+ compileSdk = libs.versions.maxSdk.get().toInt()
defaultConfig {
applicationId = "com.wbrawner.nanoflux"
- minSdk = 21
- targetSdk = 30
- versionCode = 1
- versionName = "1.0"
+ minSdk = libs.versions.minSdk.get().toInt()
+ targetSdk = libs.versions.maxSdk.get().toInt()
+ versionCode = libs.versions.versionCode.get().toInt()
+ versionName = libs.versions.versionName.get()
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
useSupportLibrary = true
}
- javaCompileOptions {
- annotationProcessorOptions {
- arguments.putAll(mapOf(
- "room.schemaLocation" to "$projectDir/schemas",
- "room.incremental" to "true",
- "room.expandProjection" to "true"
- ))
- }
- }
}
buildTypes {
@@ -46,39 +36,35 @@ android {
}
kotlinOptions {
jvmTarget = "1.8"
- useIR = true
+ freeCompilerArgs = listOf(
+ "-P",
+ "plugin:androidx.compose.compiler.plugins.kotlin:suppressKotlinVersionCompatibilityCheck=true"
+ )
}
buildFeatures {
compose = true
}
composeOptions {
kotlinCompilerExtensionVersion = libs.versions.compose.get()
- kotlinCompilerVersion = rootProject.extra["kotlinVersion"] as String
}
}
dependencies {
+ implementation(project(":common"))
+ implementation(project(":network"))
+ implementation(project(":storage"))
implementation(libs.kotlin.stdlib)
implementation(libs.androidx.core)
implementation(libs.androidx.appcompat)
implementation(libs.material)
implementation(libs.bundles.compose)
- implementation(libs.preference)
implementation(libs.lifecycle)
- implementation(libs.timber)
- implementation(libs.room.ktx)
- kapt(libs.room.kapt)
- implementation(libs.bundles.networking)
- implementation(libs.bundles.moshi)
- kapt(libs.moshi.kapt)
implementation(libs.hilt.android.core)
kapt(libs.hilt.android.kapt)
implementation(libs.hilt.work.core)
kapt(libs.hilt.work.kapt)
implementation(libs.work.core)
testImplementation(libs.work.test)
- testImplementation(libs.junit)
- testImplementation(libs.room.test)
androidTestImplementation(libs.test.ext)
androidTestImplementation(libs.espresso)
androidTestImplementation(libs.compose.test)
diff --git a/app/src/main/java/com/wbrawner/nanoflux/MainActivity.kt b/app/src/main/java/com/wbrawner/nanoflux/MainActivity.kt
index 2a43975..f4a0ce8 100644
--- a/app/src/main/java/com/wbrawner/nanoflux/MainActivity.kt
+++ b/app/src/main/java/com/wbrawner/nanoflux/MainActivity.kt
@@ -4,17 +4,11 @@ import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.viewModels
-import androidx.compose.foundation.text.BasicText
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Surface
-import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
-import androidx.compose.ui.graphics.vector.ImageVector
-import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.tooling.preview.Preview
-import com.wbrawner.nanoflux.data.model.Entry
-import com.wbrawner.nanoflux.data.model.Feed
import com.wbrawner.nanoflux.data.viewmodel.AuthViewModel
import com.wbrawner.nanoflux.ui.MainScreen
import com.wbrawner.nanoflux.ui.auth.AuthScreen
@@ -56,16 +50,3 @@ fun AppPreview() {
}
}
-
-@Composable
-fun EntryListItem(
- entry: Entry,
- feed: Feed,
- onEntryItemClicked: (entry: Entry) -> Unit,
- onFeedClicked: (feed: Feed) -> Unit,
- onToggleReadClicked: (entry: Entry) -> Unit,
- onStarClicked: (entry: Entry) -> Unit,
- onExternalLinkClicked: (entry: Entry) -> Unit
-) {
-
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/wbrawner/nanoflux/NanofluxApplication.kt b/app/src/main/java/com/wbrawner/nanoflux/NanofluxApplication.kt
index 1de3412..a8b7803 100644
--- a/app/src/main/java/com/wbrawner/nanoflux/NanofluxApplication.kt
+++ b/app/src/main/java/com/wbrawner/nanoflux/NanofluxApplication.kt
@@ -2,9 +2,7 @@ package com.wbrawner.nanoflux
import android.app.Application
import androidx.hilt.work.HiltWorkerFactory
-import androidx.room.Room
import androidx.work.Configuration
-import com.wbrawner.nanoflux.data.NanofluxDatabase
import dagger.hilt.android.HiltAndroidApp
import javax.inject.Inject
@@ -15,5 +13,6 @@ class NanofluxApplication : Application(), Configuration.Provider {
override fun getWorkManagerConfiguration(): Configuration = Configuration.Builder()
.setWorkerFactory(workerFactory)
+ .setMinimumLoggingLevel(android.util.Log.DEBUG)
.build()
}
\ No newline at end of file
diff --git a/app/src/main/java/com/wbrawner/nanoflux/SyncWorker.kt b/app/src/main/java/com/wbrawner/nanoflux/SyncWorker.kt
index a94b46f..b8bd576 100644
--- a/app/src/main/java/com/wbrawner/nanoflux/SyncWorker.kt
+++ b/app/src/main/java/com/wbrawner/nanoflux/SyncWorker.kt
@@ -1,16 +1,14 @@
package com.wbrawner.nanoflux
-import android.app.Service
import android.content.Context
-import android.content.Intent
-import android.os.IBinder
import androidx.hilt.work.HiltWorker
+import androidx.work.Data
import androidx.work.Worker
import androidx.work.WorkerParameters
-import com.wbrawner.nanoflux.data.MinifluxApiService
-import com.wbrawner.nanoflux.data.repository.EntryRepository
-import com.wbrawner.nanoflux.data.repository.FeedRepository
-import com.wbrawner.nanoflux.data.repository.UserRepository
+import com.wbrawner.nanoflux.network.repository.CategoryRepository
+import com.wbrawner.nanoflux.network.repository.EntryRepository
+import com.wbrawner.nanoflux.network.repository.FeedRepository
+import com.wbrawner.nanoflux.network.repository.IconRepository
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import kotlinx.coroutines.runBlocking
@@ -21,16 +19,32 @@ class SyncWorker @AssistedInject constructor(
@Assisted context: Context,
@Assisted workerParams: WorkerParameters
) : Worker(context, workerParams) {
+ @Inject
+ lateinit var categoryRepository: CategoryRepository
+
@Inject
lateinit var entryRepository: EntryRepository
+
@Inject
lateinit var feedRepository: FeedRepository
+ @Inject
+ lateinit var iconRepository: IconRepository
+
override fun doWork(): Result {
- runBlocking {
- feedRepository.getAll()
- entryRepository.getAll()
+ return runBlocking {
+ try {
+ categoryRepository.getAll(true)
+ feedRepository.getAll(true).forEach {
+ if (it.feed.iconId != null) {
+ iconRepository.getFeedIcon(it.feed.id)
+ }
+ }
+ entryRepository.getAll(true)
+ Result.success()
+ } catch (e: Exception) {
+ Result.failure(Data.Builder().putString("message", e.message?: "Unknown failure").build())
+ }
}
- return Result.success()
}
}
diff --git a/app/src/main/java/com/wbrawner/nanoflux/data/MinifluxApiService.kt b/app/src/main/java/com/wbrawner/nanoflux/data/MinifluxApiService.kt
deleted file mode 100644
index ecdbf04..0000000
--- a/app/src/main/java/com/wbrawner/nanoflux/data/MinifluxApiService.kt
+++ /dev/null
@@ -1,147 +0,0 @@
-package com.wbrawner.nanoflux.data
-
-import com.wbrawner.nanoflux.data.model.Category
-import com.wbrawner.nanoflux.data.model.Entry
-import com.wbrawner.nanoflux.data.model.Feed
-import com.wbrawner.nanoflux.data.model.User
-import okhttp3.RequestBody
-import okhttp3.Response
-import okhttp3.ResponseBody
-import retrofit2.http.*
-
-interface MinifluxApiService {
-// @POST("discover")
-// suspend fun discoverSubscriptions(@Body url: String): DiscoverResponse
-
- @GET("feeds")
- suspend fun getFeeds(): List
-
- @GET("categories/{categoryId}/feeds")
- suspend fun getCategoryFeeds(@Path("categoryId") categoryId: Long): List
-
- @GET("feeds/{id}")
- suspend fun getFeed(@Path("id") id: Long): Feed
-
- @GET("feeds/{feedId}/icon")
- suspend fun getFeedIcon(@Path("feedId") feedId: Long): Feed.Icon
-
-// @POST("feeds")
-// suspend fun createFeed(feed: FeedRequest): CreateFeedResponse
-
-// @PUT("feeds/{id}")
-// suspend fun updateFeed(@Path("id") id: Long, @Body request: FeedRequest): Feed
-
- @PUT("feeds/refresh")
- suspend fun refreshFeeds(): Response
-
- @PUT("feeds/{id}/refresh")
- suspend fun refreshFeed(@Path("id") id: Long): Response
-
- @DELETE("feeds/{id}")
- suspend fun deleteFeed(@Path("id") id: Long): Response
-
- @GET("feeds/{feedId}/entries")
- suspend fun getFeedEntries(
- @Path("feedId") feedId: Long,
- @Query("status") status: List? = null,
- @Query("offset") offset: Long? = null,
- @Query("limit") limit: Long? = null,
- @Query("order") order: Entry.Order? = null,
- @Query("direction") direction: Entry.SortDirection? = null,
- @Query("before") before: Long? = null,
- @Query("after") after: Long? = null,
- @Query("before_entry_id") beforeEntryId: Long? = null,
- @Query("after_entry_id") afterEntryId: Long? = null,
- @Query("starred") starred: Boolean? = null,
- @Query("search") search: String? = null,
- @Query("category_id") categoryId: Long? = null,
- ): List
-
- @GET("feeds/{feedId}/entries/{entryId}")
- suspend fun getFeedEntry(@Path("feedId") feedId: Long, @Path("entryId") entryId: Long): Entry
-
- @GET("entries")
- suspend fun getEntries(
- @Query("status") status: List? = null,
- @Query("offset") offset: Long? = null,
- @Query("limit") limit: Long? = null,
- @Query("order") order: Entry.Order? = null,
- @Query("direction") direction: Entry.SortDirection? = null,
- @Query("before") before: Long? = null,
- @Query("after") after: Long? = null,
- @Query("before_entry_id") beforeEntryId: Long? = null,
- @Query("after_entry_id") afterEntryId: Long? = null,
- @Query("starred") starred: Boolean? = null,
- @Query("search") search: String? = null,
- @Query("category_id") categoryId: Long? = null,
- ): List
-
- @GET("entries/{entryId}")
- suspend fun getEntry(@Path("entryId") entryId: Long): Entry
-
- @PUT("feeds/{id}/mark-all-as-read")
- suspend fun markFeedEntriesAsRead(@Path("id") id: Long): Response
-
- //{
- // "entry_ids": [1234, 4567],
- // "status": "read"
- //}
-// @PUT("entries")
-// suspend fun updateEntries(request: UpdateEntriesRequest): Response
-
- @PUT("entries/{id}/bookmark")
- suspend fun toggleEntryBookmark(@Path("id") id: Long): Response
-
- @GET("categories")
- suspend fun getCategories(): List
-
- @POST("categories")
- suspend fun createCategory(@Body title: String): Category
-
- @PUT("categories/{id}")
- suspend fun updateCategory(@Path("id") id: Long, @Body title: String): Category
-
- @DELETE("categories/{id}")
- suspend fun deleteCategory(@Path("id") id: Long): Response
-
- @PUT("categories/{id}/mark-all-as-read")
- suspend fun markCategoryEntriesAsRead(@Path("id") id: Long): Response
-
- @GET("export")
- @Streaming
- suspend fun opmlExport(): retrofit2.Response
-
- @POST("import")
- @Streaming
- suspend fun opmlImport(@Body opml: RequestBody): retrofit2.Response
-
-// @POST("users")
-// suspend fun createUser(@Body user: UserRequest): User
-
- @PUT("users/{id}")
- suspend fun updateUser(@Path("id") id: Long, @Body user: User): User
-
- @GET("me")
- suspend fun getCurrentUser(): User
-
- @GET("users/{id}")
- suspend fun getUser(@Path("id") id: Long): User
-
- @GET("users/{name}")
- suspend fun getUserByName(@Path("name") name: String): User
-
- @GET("users")
- suspend fun getUses(): List
-
- @DELETE("users/{id}")
- suspend fun deleteUser(@Path("id") id: Long)
-
- @PUT("users/{id}/mark-all-as-read")
- suspend fun markUserEntriesAsRead(@Path("id") id: Long): Response
-
- @GET("/healthcheck")
- suspend fun healthcheck(): retrofit2.Response
-
- @GET("/version")
- suspend fun version(): retrofit2.Response
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/wbrawner/nanoflux/data/NanofluxDatabase.kt b/app/src/main/java/com/wbrawner/nanoflux/data/NanofluxDatabase.kt
deleted file mode 100644
index c929518..0000000
--- a/app/src/main/java/com/wbrawner/nanoflux/data/NanofluxDatabase.kt
+++ /dev/null
@@ -1,34 +0,0 @@
-package com.wbrawner.nanoflux.data
-
-import androidx.room.Database
-import androidx.room.RoomDatabase
-import androidx.room.TypeConverter
-import androidx.room.TypeConverters
-import com.wbrawner.nanoflux.data.dao.*
-import com.wbrawner.nanoflux.data.model.Category
-import com.wbrawner.nanoflux.data.model.Entry
-import com.wbrawner.nanoflux.data.model.Feed
-import com.wbrawner.nanoflux.data.model.User
-import java.util.*
-
-@Database(
- entities = [Feed::class, Entry::class, Category::class, Feed.Icon::class, User::class],
- version = 1,
- exportSchema = true,
-)
-@TypeConverters(DateTypeConverter::class)
-abstract class NanofluxDatabase: RoomDatabase() {
- abstract fun feedDao(): FeedDao
- abstract fun entryDao(): EntryDao
- abstract fun categoryDao(): CategoryDao
- abstract fun iconDao(): IconDao
- abstract fun userDao(): UserDao
-}
-
-class DateTypeConverter {
- @TypeConverter
- fun fromTimestamp(timestamp: Long) = Date(timestamp)
-
- @TypeConverter
- fun toTimestamp(date: Date) = date.time
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/wbrawner/nanoflux/data/dao/CategoryDao.kt b/app/src/main/java/com/wbrawner/nanoflux/data/dao/CategoryDao.kt
deleted file mode 100644
index 9ecfdf5..0000000
--- a/app/src/main/java/com/wbrawner/nanoflux/data/dao/CategoryDao.kt
+++ /dev/null
@@ -1,24 +0,0 @@
-package com.wbrawner.nanoflux.data.dao
-
-import androidx.room.Dao
-import androidx.room.Delete
-import androidx.room.Insert
-import androidx.room.Query
-import com.wbrawner.nanoflux.data.model.Category
-import com.wbrawner.nanoflux.data.model.Feed
-import com.wbrawner.nanoflux.data.model.FeedCategoryIcon
-
-@Dao
-interface CategoryDao {
- @Query("SELECT * FROM Category")
- fun getAll(): List
-
- @Query("SELECT * FROM Category WHERE id in (:ids)")
- fun getAllByIds(vararg ids: Long): List
-
- @Insert
- fun insertAll(vararg categories: Category)
-
- @Delete
- fun delete(category: Category)
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/wbrawner/nanoflux/data/dao/EntryDao.kt b/app/src/main/java/com/wbrawner/nanoflux/data/dao/EntryDao.kt
deleted file mode 100644
index 5b4abb7..0000000
--- a/app/src/main/java/com/wbrawner/nanoflux/data/dao/EntryDao.kt
+++ /dev/null
@@ -1,34 +0,0 @@
-package com.wbrawner.nanoflux.data.dao
-
-import androidx.room.Dao
-import androidx.room.Delete
-import androidx.room.Insert
-import androidx.room.Query
-import com.wbrawner.nanoflux.data.model.Entry
-import com.wbrawner.nanoflux.data.model.Feed
-import com.wbrawner.nanoflux.data.model.FeedCategoryIcon
-import kotlinx.coroutines.flow.Flow
-
-@Dao
-interface EntryDao {
- @Query("SELECT * FROM Entry")
- fun getAll(): Flow>
-
- @Query("SELECT * FROM Entry WHERE status = \"UNREAD\"")
- fun getAllUnread(): Flow>
-
- @Query("SELECT * FROM Entry WHERE id in (:ids)")
- fun getAllByIds(vararg ids: Long): List
-
- @Query("SELECT * FROM Entry WHERE feedId in (:ids)")
- fun getAllByFeedIds(vararg ids: Long): Flow>
-
- @Query("SELECT * FROM Entry WHERE status = \"UNREAD\" AND feedId in (:ids)")
- fun getAllUnreadByFeedIds(vararg ids: Long): Flow>
-
- @Insert
- fun insertAll(entries: List)
-
- @Delete
- fun delete(entry: Entry)
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/wbrawner/nanoflux/data/dao/IconDao.kt b/app/src/main/java/com/wbrawner/nanoflux/data/dao/IconDao.kt
deleted file mode 100644
index dffb07b..0000000
--- a/app/src/main/java/com/wbrawner/nanoflux/data/dao/IconDao.kt
+++ /dev/null
@@ -1,22 +0,0 @@
-package com.wbrawner.nanoflux.data.dao
-
-import androidx.room.Dao
-import androidx.room.Delete
-import androidx.room.Insert
-import androidx.room.Query
-import com.wbrawner.nanoflux.data.model.Feed
-
-@Dao
-interface IconDao {
- @Query("SELECT * FROM Icon")
- fun getAll(): List
-
- @Query("SELECT * FROM Icon WHERE iconId in (:ids)")
- fun getAllByIds(vararg ids: Long): List
-
- @Insert
- fun insertAll(vararg icons: Feed.Icon)
-
- @Delete
- fun delete(icon: Feed.Icon)
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/wbrawner/nanoflux/data/model/Category.kt b/app/src/main/java/com/wbrawner/nanoflux/data/model/Category.kt
deleted file mode 100644
index 30ebe1c..0000000
--- a/app/src/main/java/com/wbrawner/nanoflux/data/model/Category.kt
+++ /dev/null
@@ -1,18 +0,0 @@
-package com.wbrawner.nanoflux.data.model
-
-import androidx.room.Entity
-import androidx.room.PrimaryKey
-import com.squareup.moshi.Json
-import com.squareup.moshi.JsonClass
-
-@Entity
-@JsonClass(generateAdapter = true)
-data class Category(
- @PrimaryKey
- @Json(name = "id")
- val id: Long,
- @Json(name = "title")
- val title: String,
- @Json(name = "user_id")
- val userId: Long,
-)
\ No newline at end of file
diff --git a/app/src/main/java/com/wbrawner/nanoflux/data/model/Entry.kt b/app/src/main/java/com/wbrawner/nanoflux/data/model/Entry.kt
deleted file mode 100644
index 6f7fc4e..0000000
--- a/app/src/main/java/com/wbrawner/nanoflux/data/model/Entry.kt
+++ /dev/null
@@ -1,62 +0,0 @@
-package com.wbrawner.nanoflux.data.model
-
-import androidx.room.Entity
-import androidx.room.PrimaryKey
-import com.squareup.moshi.Json
-import com.squareup.moshi.JsonClass
-import java.util.*
-
-@Entity
-@JsonClass(generateAdapter = true)
-data class Entry(
- @Json(name = "id")
- @PrimaryKey
- val id: Long,
- @Json(name = "user_id")
- val userId: Long,
- @Json(name = "feed_id")
- val feedId: Long,
- @Json(name = "status")
- val status: Status,
- @Json(name = "hash")
- val hash: String,
- @Json(name = "title")
- val title: String,
- @Json(name = "url")
- val url: String,
- @Json(name = "comments_url")
- val commentsUrl: String,
- @Json(name = "published_at")
- val publishedAt: Date,
- @Json(name = "created_at")
- val createdAt: Date,
- @Json(name = "content")
- val content: String,
- @Json(name = "author")
- val author: String,
- @Json(name = "share_code")
- val shareCode: String,
- @Json(name = "starred")
- val starred: Boolean,
- @Json(name = "reading_time")
- val readingTime: Int,
-) {
- enum class Status {
- READ,
- UNREAD,
- REMOVED
- }
-
- enum class Order {
- ID,
- STATUS,
- PUBLISHED_AT,
- CATEGORY_TITLE,
- CATEGORY_ID
- }
-
- enum class SortDirection {
- ASC,
- DESC
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/wbrawner/nanoflux/data/model/Feed.kt b/app/src/main/java/com/wbrawner/nanoflux/data/model/Feed.kt
deleted file mode 100644
index b8214c4..0000000
--- a/app/src/main/java/com/wbrawner/nanoflux/data/model/Feed.kt
+++ /dev/null
@@ -1,63 +0,0 @@
-package com.wbrawner.nanoflux.data.model
-
-import androidx.room.Embedded
-import androidx.room.Entity
-import androidx.room.PrimaryKey
-import androidx.room.Relation
-import com.squareup.moshi.Json
-import com.squareup.moshi.JsonClass
-import java.util.*
-
-@Entity
-data class Feed(
- @PrimaryKey
- val id: Long,
- val userId: Long,
- val title: String,
- val siteUrl: String,
- val feedUrl: String,
- val checkedAt: Date,
- val etagHeader: String,
- val lastModifiedHeader: String,
- val parsingErrorMessage: String,
- val parsingErrorCount: Int,
- val scraperRules: String,
- val rewriteRules: String,
- val crawler: Boolean,
- val blocklistRules: String,
- val keeplistRules: String,
- val userAgent: String,
- val username: String,
- val password: String,
- val disabled: Boolean,
- val ignoreHttpCache: Boolean,
- val fetchViaProxy: Boolean,
- val categoryId: Long,
- val iconId: Long?
-) {
- @Entity
- @JsonClass(generateAdapter = true)
- data class Icon(
- @Json(name = "feed_id")
- val feedId: Long,
- @PrimaryKey
- @Json(name = "icon_id")
- val iconId: Long
- )
-}
-
-data class FeedCategoryIcon(
- @Embedded
- val feed: Feed,
- @Relation(
- parentColumn = "id",
- entityColumn = "feedId"
- )
- val icon: Feed.Icon?,
- @Relation(
- parentColumn = "categoryId",
- entityColumn = "id",
- entity = Category::class
- )
- val category: Category
-)
diff --git a/app/src/main/java/com/wbrawner/nanoflux/data/model/User.kt b/app/src/main/java/com/wbrawner/nanoflux/data/model/User.kt
deleted file mode 100644
index 7645527..0000000
--- a/app/src/main/java/com/wbrawner/nanoflux/data/model/User.kt
+++ /dev/null
@@ -1,43 +0,0 @@
-package com.wbrawner.nanoflux.data.model
-
-import androidx.room.Entity
-import androidx.room.PrimaryKey
-import com.squareup.moshi.Json
-import com.squareup.moshi.JsonClass
-import java.util.*
-
-@Entity
-@JsonClass(generateAdapter = true)
-data class User(
- @Json(name = "id")
- @PrimaryKey
- val id: Long,
- @Json(name = "username")
- val username: String,
- @Json(name = "is_admin")
- val admin: Boolean,
- @Json(name = "theme")
- val theme: String,
- @Json(name = "language")
- val language: String,
- @Json(name = "timezone")
- val timezone: String,
- @Json(name = "entry_sorting_direction")
- val entrySortingDirection: String,
- @Json(name = "stylesheet")
- val stylesheet: String,
- @Json(name = "google_id")
- val googleId: String,
- @Json(name = "openid_connect_id")
- val openidConnectId: String,
- @Json(name = "entries_per_page")
- val entriesPerPage: Int,
- @Json(name = "keyboard_shortcuts")
- val keyboardShortcuts: Boolean,
- @Json(name = "show_reading_time")
- val showReadingTime: Boolean,
- @Json(name = "entry_swipe")
- val entrySwipe: Boolean,
- @Json(name = "last_login_at")
- val lastLoginAt: Date,
-)
diff --git a/app/src/main/java/com/wbrawner/nanoflux/data/repository/EntryRepository.kt b/app/src/main/java/com/wbrawner/nanoflux/data/repository/EntryRepository.kt
deleted file mode 100644
index 6be76be..0000000
--- a/app/src/main/java/com/wbrawner/nanoflux/data/repository/EntryRepository.kt
+++ /dev/null
@@ -1,27 +0,0 @@
-package com.wbrawner.nanoflux.data.repository
-
-import com.wbrawner.nanoflux.data.MinifluxApiService
-import com.wbrawner.nanoflux.data.dao.EntryDao
-import com.wbrawner.nanoflux.data.dao.FeedDao
-import com.wbrawner.nanoflux.data.model.Entry
-import com.wbrawner.nanoflux.data.model.FeedCategoryIcon
-import kotlinx.coroutines.GlobalScope
-import kotlinx.coroutines.flow.Flow
-import kotlinx.coroutines.launch
-import timber.log.Timber
-import javax.inject.Inject
-
-class EntryRepository @Inject constructor(
- private val apiService: MinifluxApiService,
- private val entryDao: EntryDao,
- private val logger: Timber.Tree
-) {
- private val entries: Flow> = entryDao.getAll()
-
- fun getAll(): Flow> {
- GlobalScope.launch {
- entryDao.insertAll(apiService.getEntries())
- }
- return entries
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/wbrawner/nanoflux/data/repository/FeedRepository.kt b/app/src/main/java/com/wbrawner/nanoflux/data/repository/FeedRepository.kt
deleted file mode 100644
index 6746fcb..0000000
--- a/app/src/main/java/com/wbrawner/nanoflux/data/repository/FeedRepository.kt
+++ /dev/null
@@ -1,25 +0,0 @@
-package com.wbrawner.nanoflux.data.repository
-
-import com.wbrawner.nanoflux.data.MinifluxApiService
-import com.wbrawner.nanoflux.data.dao.FeedDao
-import com.wbrawner.nanoflux.data.model.FeedCategoryIcon
-import kotlinx.coroutines.GlobalScope
-import kotlinx.coroutines.flow.Flow
-import kotlinx.coroutines.launch
-import timber.log.Timber
-import javax.inject.Inject
-
-class FeedRepository @Inject constructor(
- private val apiService: MinifluxApiService,
- private val feedDao: FeedDao,
- private val logger: Timber.Tree
-) {
- private val feeds: Flow> = feedDao.getAll()
-
- fun getAll(): Flow> {
- GlobalScope.launch {
- feedDao.insertAll(apiService.getFeeds())
- }
- return feeds
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/wbrawner/nanoflux/data/viewmodel/AuthViewModel.kt b/app/src/main/java/com/wbrawner/nanoflux/data/viewmodel/AuthViewModel.kt
index 37aaa48..f0ae4b2 100644
--- a/app/src/main/java/com/wbrawner/nanoflux/data/viewmodel/AuthViewModel.kt
+++ b/app/src/main/java/com/wbrawner/nanoflux/data/viewmodel/AuthViewModel.kt
@@ -2,8 +2,7 @@ package com.wbrawner.nanoflux.data.viewmodel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
-import com.wbrawner.nanoflux.data.model.User
-import com.wbrawner.nanoflux.data.repository.UserRepository
+import com.wbrawner.nanoflux.network.repository.UserRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
@@ -84,7 +83,7 @@ class AuthViewModel @Inject constructor(
sealed class AuthState {
object Loading : AuthState()
- class Authenticated(val user: User) : AuthState()
+ class Authenticated(val user: com.wbrawner.nanoflux.storage.model.User) : AuthState()
class Unauthenticated(
val server: String = "",
val username: String = "",
diff --git a/app/src/main/java/com/wbrawner/nanoflux/data/viewmodel/MainViewModel.kt b/app/src/main/java/com/wbrawner/nanoflux/data/viewmodel/MainViewModel.kt
new file mode 100644
index 0000000..08c054b
--- /dev/null
+++ b/app/src/main/java/com/wbrawner/nanoflux/data/viewmodel/MainViewModel.kt
@@ -0,0 +1,46 @@
+package com.wbrawner.nanoflux.data.viewmodel
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.wbrawner.nanoflux.network.repository.CategoryRepository
+import com.wbrawner.nanoflux.network.repository.EntryRepository
+import com.wbrawner.nanoflux.network.repository.FeedRepository
+import com.wbrawner.nanoflux.storage.model.EntryAndFeed
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.launch
+import timber.log.Timber
+import javax.inject.Inject
+
+@HiltViewModel
+class MainViewModel @Inject constructor(
+ private val feedRepository: FeedRepository,
+ private val categoryRepository: CategoryRepository,
+ private val entryRepository: EntryRepository,
+ private val logger: Timber.Tree
+) : ViewModel() {
+ private val _state = MutableStateFlow(FeedState.Loading)
+ val state = _state.asStateFlow()
+
+ fun loadUnread() {
+ viewModelScope.launch(Dispatchers.IO) {
+ try {
+ var entries = entryRepository.getAllUnread()
+ if (entries.isEmpty()) entries = entryRepository.getAllUnread(true)
+ val state = if (entries.isEmpty()) FeedState.Empty else FeedState.Success(entries)
+ _state.emit(state)
+ } catch (e: Exception) {
+ _state.emit(FeedState.Failed(e.localizedMessage))
+ }
+ }
+ }
+
+ sealed class FeedState {
+ object Loading : FeedState()
+ class Failed(val errorMessage: String?): FeedState()
+ object Empty: FeedState()
+ class Success(val entries: List): FeedState()
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/wbrawner/nanoflux/di/NetworkModule.kt b/app/src/main/java/com/wbrawner/nanoflux/di/NetworkModule.kt
deleted file mode 100644
index 882feb6..0000000
--- a/app/src/main/java/com/wbrawner/nanoflux/di/NetworkModule.kt
+++ /dev/null
@@ -1,66 +0,0 @@
-package com.wbrawner.nanoflux.di
-
-import android.content.SharedPreferences
-import androidx.preference.Preference
-import androidx.preference.PreferenceDataStore
-import com.squareup.moshi.Moshi
-import com.squareup.moshi.adapters.Rfc3339DateJsonAdapter
-import com.wbrawner.nanoflux.BuildConfig
-import com.wbrawner.nanoflux.data.MinifluxApiService
-import com.wbrawner.nanoflux.data.repository.PREF_KEY_AUTH_TOKEN
-import dagger.Module
-import dagger.Provides
-import dagger.hilt.InstallIn
-import dagger.hilt.components.SingletonComponent
-import okhttp3.HttpUrl
-import okhttp3.HttpUrl.Companion.toHttpUrl
-import okhttp3.OkHttpClient
-import okhttp3.logging.HttpLoggingInterceptor
-import retrofit2.Retrofit
-import retrofit2.converter.moshi.MoshiConverterFactory
-import java.util.*
-
-const val PREF_KEY_BASE_URL = "BASE_URL"
-private const val DEFAULT_BASE_URL = "https://base_url"
-
-@Module
-@InstallIn(SingletonComponent::class)
-object NetworkModule {
- @Provides
- fun providesOkHttpClient(prefs: SharedPreferences): OkHttpClient {
- val client = OkHttpClient.Builder()
- .addInterceptor {
- val old = it.request()
- val newUrl = prefs.getString(PREF_KEY_BASE_URL, null)
- if (old.url.host != "base_url" || newUrl == null) {
- return@addInterceptor it.proceed(it.request())
- }
- val new = old.newBuilder()
- val url = old.url.toString().replace(DEFAULT_BASE_URL, newUrl).toHttpUrl()
- new.header("Authorization", prefs.getString(PREF_KEY_AUTH_TOKEN, null) ?: "")
- it.proceed(new.url(url).build())
- }
- if (BuildConfig.DEBUG) {
- client.addInterceptor(HttpLoggingInterceptor(logger = HttpLoggingInterceptor.Logger.DEFAULT).also {
- it.setLevel(HttpLoggingInterceptor.Level.BODY)
- })
- }
- return client.build()
- }
-
- @Provides
- fun providesMoshi(): Moshi = Moshi.Builder()
- .add(Date::class.java, Rfc3339DateJsonAdapter())
- .build()
-
- @Provides
- fun providesRetrofit(moshi: Moshi, okHttpClient: OkHttpClient) = Retrofit.Builder()
- .baseUrl(DEFAULT_BASE_URL)
- .client(okHttpClient)
- .addConverterFactory(MoshiConverterFactory.create(moshi))
- .build()
-
- @Provides
- fun provideMinifluxApiService(retrofit: Retrofit) =
- retrofit.create(MinifluxApiService::class.java)
-}
diff --git a/app/src/main/java/com/wbrawner/nanoflux/ui/Entries.kt b/app/src/main/java/com/wbrawner/nanoflux/ui/Entries.kt
new file mode 100644
index 0000000..bfdf86f
--- /dev/null
+++ b/app/src/main/java/com/wbrawner/nanoflux/ui/Entries.kt
@@ -0,0 +1,223 @@
+package com.wbrawner.nanoflux.ui
+
+import android.content.Intent
+import android.graphics.BitmapFactory
+import android.util.Base64
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.material.*
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Share
+import androidx.compose.material.icons.filled.Star
+import androidx.compose.material.icons.outlined.Star
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.asImageBitmap
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import com.wbrawner.nanoflux.NanofluxApp
+import com.wbrawner.nanoflux.storage.model.*
+import java.util.*
+import kotlin.math.roundToInt
+
+@Composable
+fun EntryList(
+ entries: List,
+ onEntryItemClicked: (entry: Entry) -> Unit,
+ onFeedClicked: (feed: Feed) -> Unit,
+ onToggleReadClicked: (entry: Entry) -> Unit,
+ onStarClicked: (entry: Entry) -> Unit,
+ onExternalLinkClicked: (entry: Entry) -> Unit
+) {
+ LazyColumn {
+ items(entries) { entry ->
+ EntryListItem(
+ entry.entry,
+ entry.feed,
+ onEntryItemClicked,
+ onFeedClicked,
+ onToggleReadClicked,
+ onStarClicked,
+ onExternalLinkClicked
+ )
+ }
+ }
+}
+
+@Composable
+fun EntryListItem(
+ entry: Entry,
+ feed: FeedCategoryIcon,
+ onEntryItemClicked: (entry: Entry) -> Unit,
+ onFeedClicked: (feed: Feed) -> Unit,
+ onToggleReadClicked: (entry: Entry) -> Unit,
+ onStarClicked: (entry: Entry) -> Unit,
+ onExternalLinkClicked: (entry: Entry) -> Unit
+) {
+// val swipeState = rememberSwipeableState(initialValue = entry.status)
+ Column(
+ modifier = Modifier
+ .padding(vertical = 8.dp, horizontal = 16.dp)
+ .clickable { onEntryItemClicked(entry) }
+// .swipeable(state = swipeState, orientation = Orientation.Horizontal)
+ ) {
+ Row {
+ Text(
+ modifier = Modifier.weight(1f),
+ text = entry.title,
+ style = MaterialTheme.typography.h6
+ )
+ }
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier
+ .fillMaxWidth()
+ ) {
+ TextButton(
+ onClick = { onFeedClicked(feed.feed) },
+ modifier = Modifier.padding(start = 0.dp),
+ ) {
+ feed.icon?.let {
+ val bytes = Base64.decode(it.data.substringAfter(","), Base64.DEFAULT)
+ val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
+ .asImageBitmap()
+ Image(
+ modifier = Modifier
+ .width(16.dp)
+ .height(16.dp),
+ bitmap = bitmap,
+ contentDescription = null,
+ )
+ Spacer(modifier = Modifier.width(8.dp))
+ }
+ Text(
+ text = feed.feed.title,
+ style = MaterialTheme.typography.body2,
+ color = MaterialTheme.colors.onSurface
+ )
+ }
+// Text(text = feed.category.title)
+ }
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.SpaceBetween,
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Text(text = entry.publishedAt.timeSince(), style = MaterialTheme.typography.body2)
+ Spacer(modifier = Modifier.width(8.dp))
+ Text(text = "${entry.readingTime}m read", style = MaterialTheme.typography.body2)
+ IconButton(onClick = { onStarClicked(entry) }) {
+ Icon(
+ imageVector = if (entry.starred) Icons.Filled.Star else Icons.Outlined.Star,
+ contentDescription = if (entry.starred) "Starred" else "Not starred"
+ )
+ }
+ val context = LocalContext.current
+ IconButton(onClick = {
+ context.startActivity(Intent(Intent.ACTION_SEND).apply {
+ // TODO: Get base url from viewmodel or something
+ putExtra(
+ Intent.EXTRA_TEXT,
+ "https://wbrawner.com/entry/share/${entry.shareCode}"
+ )
+ })
+ }) {
+ Icon(
+ imageVector = Icons.Default.Share,
+ contentDescription = "Share"
+ )
+ }
+ }
+ }
+}
+
+@Composable
+@Preview
+fun EntryListItem_Preview() {
+ NanofluxApp {
+ EntryListItem(
+ entry = Entry(
+ id = 0,
+ userId = 0,
+ feedId = 0,
+ status = Entry.Status.UNREAD,
+ hash = "",
+ title = "Entry title",
+ url = "https://example.com/entries/1",
+ commentsUrl = "",
+ publishedAt = GregorianCalendar(2019, 11, 1).time,
+ createdAt = Date(),
+ content = "Entry content here",
+ author = "Jane Doe",
+ shareCode = "shareCode",
+ starred = false,
+ readingTime = 12,
+ enclosures = null
+ ),
+ feed = FeedCategoryIcon(
+ feed = Feed(
+ id = 0,
+ userId = 0,
+ title = "Feed Title",
+ siteUrl = "",
+ feedUrl = "",
+ checkedAt = Date(),
+ etagHeader = "",
+ lastModifiedHeader = "",
+ parsingErrorMessage = "",
+ parsingErrorCount = 0,
+ scraperRules = "",
+ rewriteRules = "",
+ crawler = false,
+ blocklistRules = "",
+ keeplistRules = "",
+ userAgent = "",
+ username = "",
+ password = "",
+ disabled = false,
+ ignoreHttpCache = false,
+ fetchViaProxy = false,
+ categoryId = 0,
+ iconId = 0
+ ),
+ category = Category(
+ id = 2,
+ title = "Category Title",
+ userId = 0
+ ),
+ icon = Feed.Icon(
+ id = 0,
+ data = "image/png;base64,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mqn1+gmHMxozqxKVH9R8xeGXNnMXNAq8mcovnOVnP48/lUpcnu94xgnnHc97rVawH0Vjfzr7SHP/fRwUekrquGJ2PHtagJ+7Tb93cXmb49MOzusCDpzntGeXbi1+xw+2DSr/rq4e9r+nhcOf/4FzPk78wWHLbnb1Azs5DwQSf9yjee5ezc+9EMATAeIec7nj1t8D+Mdbdrb71/N0PALoC5A8z32B1b7fX5ywROYFZSCAtgAJ78hU2HCuzwJMlHtFQxBAV4AJciXe8XcyOEDyFeW0RQA9AQbK1pjgZ/7XZEtf6V4PAXQEuFZ6x/28x/yS/fKv6Zs0BFAXoPZB+SLZf/ZNgPdVXtQ0BFAXQOlhGptL+ZR/W6UXlVMXAVQFULyNrr9P5/7XqbUzHwFUBVijVmbXOb4IcKfqy2qBAGoC3Kxa569+5F9G+WceViCAkgClvlStc9iPO4z6qr+ulgigIsD16oX8OC20QL2dpxFARYDJ6oU+8OE/wE/q7XyBACoCbFMvdKK85wK013lhFyOAvACNdCp19VyADJ12hiKAvADpOpVmeS7ANp12liCAvADLdSrtifM4/wpaL2wfAsgLoPcDyxU9FqChVjd5iQggK4DmE1UbeyzAdXrtVEMAWQGq65Vq47EAPfXauRwBZAXQfKBmL48FGK7XTkcEkBVA83F6IzwW4Fm9dvojgKwAmg/TetZjAWbqtTMSAWQFGKlXaqbHAszSaycdAWQFSNcrNQsBEAABEAABEAABEAABEAABEAABEAABEAABEAABEAABEAABEAABEAABEAABEAABEAABEAABEAABEAABEAABEAABEAABEAABEAABEAABEAABEAABEAABEAABEAABEAABEAABEAABEAABEAABEAABEAABEAABEAABEAABEAABEAABEAABEAABEAABEAABEAABEAABEAABEAABEAABEAAQABAAEAAQABAAEAAQABAAARAAARAAARAAARAAARAAARAAARAAARAAARAAARAAARAAARAAARAAARAAARAAARAAARAAARAAARAAARAAARAAARAAARAAARAAARAAARAAARAAARAAARAAARAAARAAARAAARAAARAAARAAARAAARAAARAAARAAARAAARAAARAAARAAARAAARAAARAAARAAARAAARAAARAAARAAARAAARAAARAAARAAARAA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",
+ mimeType = "image/png"
+ )
+ ),
+ {}, {}, {}, {}, {}
+ )
+ }
+}
+
+private const val MILLIS_IN_SECOND = 1000.0
+private const val MILLIS_IN_MINUTE = 60000.0
+private const val MILLIS_IN_HOUR = 3_600_000.0
+private const val MILLIS_IN_DAY = 86_400_000.0
+private const val MILLIS_IN_WEEK = 604_800_000.0
+private const val MILLIS_IN_MONTH = 2_628_000_000.0
+private const val MILLIS_IN_YEAR = 31_540_000_000.0
+
+fun Date.timeSince(): String {
+ val difference = System.currentTimeMillis() - time
+ return when {
+ difference >= MILLIS_IN_YEAR -> "${(difference / MILLIS_IN_YEAR).roundToInt()} years ago"
+ difference >= MILLIS_IN_MONTH -> "${(difference / MILLIS_IN_MONTH).roundToInt()} months ago"
+ difference >= MILLIS_IN_WEEK -> "${(difference / MILLIS_IN_WEEK).roundToInt()} weeks ago"
+ difference >= MILLIS_IN_DAY -> "${(difference / MILLIS_IN_DAY).roundToInt()} days ago"
+ difference >= MILLIS_IN_HOUR -> "${(difference / MILLIS_IN_HOUR).roundToInt()} hours ago"
+ difference >= MILLIS_IN_MINUTE -> "${(difference / MILLIS_IN_MINUTE).roundToInt()} minutes ago"
+ else -> "${(difference / MILLIS_IN_SECOND).roundToInt()} seconds ago"
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/wbrawner/nanoflux/ui/MainScreen.kt b/app/src/main/java/com/wbrawner/nanoflux/ui/MainScreen.kt
index df28b24..e8d7dea 100644
--- a/app/src/main/java/com/wbrawner/nanoflux/ui/MainScreen.kt
+++ b/app/src/main/java/com/wbrawner/nanoflux/ui/MainScreen.kt
@@ -6,20 +6,45 @@ import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
+import androidx.lifecycle.viewmodel.compose.viewModel
+import androidx.navigation.NavHostController
+import androidx.navigation.compose.rememberNavController
+import androidx.work.OneTimeWorkRequestBuilder
+import androidx.work.WorkManager
import com.wbrawner.nanoflux.NanofluxApp
+import com.wbrawner.nanoflux.SyncWorker
import com.wbrawner.nanoflux.data.viewmodel.AuthViewModel
+import com.wbrawner.nanoflux.data.viewmodel.MainViewModel
import kotlinx.coroutines.launch
@Composable
-fun MainScreen(authViewModel: AuthViewModel) {
+fun MainScreen(
+ authViewModel: AuthViewModel = viewModel(),
+ mainViewModel: MainViewModel = viewModel()
+) {
+ val context = LocalContext.current
+ LaunchedEffect(key1 = authViewModel.state) {
+ val workRequest = OneTimeWorkRequestBuilder().build()
+ WorkManager.getInstance(context).enqueue(workRequest)
+ mainViewModel.loadUnread()
+ }
+ val state = mainViewModel.state.collectAsState()
+ val navController = rememberNavController()
MainScaffold(
- {},
+ state.value,
+ navController,
+ {
+
+ },
{},
{},
{},
@@ -30,6 +55,8 @@ fun MainScreen(authViewModel: AuthViewModel) {
@Composable
fun MainScaffold(
+ state: MainViewModel.FeedState,
+ navController: NavHostController,
onUnreadClicked: () -> Unit,
onStarredClicked: () -> Unit,
onHistoryClicked: () -> Unit,
@@ -60,7 +87,18 @@ fun MainScaffold(
DrawerButton(onClick = onSettingsClicked, icon = Icons.Default.Settings, text = "Settings")
}
) {
-
+ when (state) {
+ is MainViewModel.FeedState.Loading -> CircularProgressIndicator()
+ is MainViewModel.FeedState.Failed -> Text("TODO: Failed to load entries: ${state.errorMessage}")
+ is MainViewModel.FeedState.Success -> EntryList(
+ entries = state.entries,
+ onEntryItemClicked = { /*TODO*/ },
+ onFeedClicked = { /*TODO*/ },
+ onToggleReadClicked = { /*TODO*/ },
+ onStarClicked = { /*TODO*/ }) {
+ }
+ is MainViewModel.FeedState.Empty -> Text("TODO: No entries")
+ }
}
}
@@ -93,6 +131,7 @@ fun DrawerButton(onClick: () -> Unit, icon: ImageVector, text: String) {
@Preview
fun MainScaffold_Preview() {
NanofluxApp {
- MainScaffold({}, {}, {}, {}, {}, {})
+ val navController = rememberNavController()
+ MainScaffold(MainViewModel.FeedState.Loading, navController, {}, {}, {}, {}, {}, {})
}
}
\ No newline at end of file
diff --git a/build.gradle.kts b/build.gradle.kts
index e86a3cc..bdc389f 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -4,11 +4,11 @@ buildscript {
google()
mavenCentral()
}
- val hiltVersion by extra("2.35")
+ val hiltVersion by extra("2.36")
val kotlinVersion by extra("1.4.32")
dependencies {
- classpath("com.android.tools.build:gradle:7.0.0-alpha15")
- classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion")
+ classpath("com.android.tools.build:gradle:7.0.0-beta03")
+ classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:${kotlinVersion}")
classpath("com.google.dagger:hilt-android-gradle-plugin:$hiltVersion")
}
}
diff --git a/common/.gitignore b/common/.gitignore
new file mode 100644
index 0000000..42afabf
--- /dev/null
+++ b/common/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/common/build.gradle.kts b/common/build.gradle.kts
new file mode 100644
index 0000000..fa74eb5
--- /dev/null
+++ b/common/build.gradle.kts
@@ -0,0 +1,46 @@
+plugins {
+ id("com.android.library")
+ id("kotlin-android")
+ id("kotlin-kapt")
+ id("dagger.hilt.android.plugin")
+}
+
+android {
+ compileSdk = libs.versions.maxSdk.get().toInt()
+
+ defaultConfig {
+ minSdk = libs.versions.minSdk.get().toInt()
+ targetSdk = libs.versions.maxSdk.get().toInt()
+
+ testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+ consumerProguardFiles("consumer-rules.pro")
+ }
+
+ buildTypes {
+ release {
+ isMinifyEnabled = false
+ proguardFiles(
+ getDefaultProguardFile("proguard-android-optimize.txt"),
+ "proguard-rules.pro"
+ )
+ }
+ }
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_1_8
+ targetCompatibility = JavaVersion.VERSION_1_8
+ }
+ kotlinOptions {
+ jvmTarget = "1.8"
+ }
+}
+
+dependencies {
+ api(libs.kotlin.stdlib)
+ api(libs.androidx.core)
+ api(libs.androidx.appcompat)
+ implementation(libs.hilt.android.core)
+ kapt(libs.hilt.android.kapt)
+ api(libs.timber)
+ api(libs.bundles.coroutines)
+ testApi(libs.junit)
+}
\ No newline at end of file
diff --git a/common/consumer-rules.pro b/common/consumer-rules.pro
new file mode 100644
index 0000000..e69de29
diff --git a/common/proguard-rules.pro b/common/proguard-rules.pro
new file mode 100644
index 0000000..481bb43
--- /dev/null
+++ b/common/proguard-rules.pro
@@ -0,0 +1,21 @@
+# Add project specific ProGuard rules here.
+# You can control the set of applied configuration files using the
+# proguardFiles setting in build.gradle.
+#
+# For more details, see
+# http://developer.android.com/guide/developing/tools/proguard.html
+
+# If your project uses WebView with JS, uncomment the following
+# and specify the fully qualified class name to the JavaScript interface
+# class:
+#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
+# public *;
+#}
+
+# Uncomment this to preserve the line number information for
+# debugging stack traces.
+#-keepattributes SourceFile,LineNumberTable
+
+# If you keep the line number information, uncomment this to
+# hide the original source file name.
+#-renamesourcefileattribute SourceFile
\ No newline at end of file
diff --git a/common/src/androidTest/java/com/wbrawner/nanoflux/common/ExampleInstrumentedTest.kt b/common/src/androidTest/java/com/wbrawner/nanoflux/common/ExampleInstrumentedTest.kt
new file mode 100644
index 0000000..7d1d927
--- /dev/null
+++ b/common/src/androidTest/java/com/wbrawner/nanoflux/common/ExampleInstrumentedTest.kt
@@ -0,0 +1,22 @@
+package com.wbrawner.nanoflux.common
+
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.platform.app.InstrumentationRegistry
+import org.junit.Assert.*
+import org.junit.Test
+import org.junit.runner.RunWith
+
+/**
+ * Instrumented test, which will execute on an Android device.
+ *
+ * See [testing documentation](http://d.android.com/tools/testing).
+ */
+@RunWith(AndroidJUnit4::class)
+class ExampleInstrumentedTest {
+ @Test
+ fun useAppContext() {
+ // Context of the app under test.
+ val appContext = InstrumentationRegistry.getInstrumentation().targetContext
+ assertEquals("com.wbrawner.nanoflux.common.test", appContext.packageName)
+ }
+}
\ No newline at end of file
diff --git a/common/src/main/AndroidManifest.xml b/common/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..db644f2
--- /dev/null
+++ b/common/src/main/AndroidManifest.xml
@@ -0,0 +1,4 @@
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/java/com/wbrawner/nanoflux/di/UtilityModule.kt b/common/src/main/java/com/wbrawner/nanoflux/common/UtilityModule.kt
similarity index 84%
rename from app/src/main/java/com/wbrawner/nanoflux/di/UtilityModule.kt
rename to common/src/main/java/com/wbrawner/nanoflux/common/UtilityModule.kt
index 52398c6..d60a5c8 100644
--- a/app/src/main/java/com/wbrawner/nanoflux/di/UtilityModule.kt
+++ b/common/src/main/java/com/wbrawner/nanoflux/common/UtilityModule.kt
@@ -1,6 +1,5 @@
-package com.wbrawner.nanoflux.di
+package com.wbrawner.nanoflux.common
-import com.wbrawner.nanoflux.BuildConfig
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
diff --git a/common/src/test/java/com/wbrawner/nanoflux/common/ExampleUnitTest.kt b/common/src/test/java/com/wbrawner/nanoflux/common/ExampleUnitTest.kt
new file mode 100644
index 0000000..8c925b7
--- /dev/null
+++ b/common/src/test/java/com/wbrawner/nanoflux/common/ExampleUnitTest.kt
@@ -0,0 +1,16 @@
+package com.wbrawner.nanoflux.common
+
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+/**
+ * Example local unit test, which will execute on the development machine (host).
+ *
+ * See [testing documentation](http://d.android.com/tools/testing).
+ */
+class ExampleUnitTest {
+ @Test
+ fun addition_isCorrect() {
+ assertEquals(4, 2 + 2)
+ }
+}
\ No newline at end of file
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 9ba7aa8..27807ab 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -2,15 +2,18 @@
androidx-core = "1.3.2"
androidx-appcompat = "1.2.0"
compose = "1.0.0-beta07"
+coroutines = "1.4.3"
espresso = "3.3.0"
-hilt-android = "2.35"
+hilt-android = "2.36"
hilt-work = "1.0.0"
kotlin = "1.4.32"
+ktor = "1.6.0"
material = "1.3.0"
-moshi = "1.12.0"
-okhttp = "4.9.1"
-retrofit = "2.9.0"
+maxSdk = "30"
+minSdk = "21"
room = "2.3.0"
+versionCode = "1"
+versionName = "1.0"
work = "2.5.0"
[libraries]
@@ -22,31 +25,36 @@ compose-material = { module = "androidx.compose.material:material", version.ref
compose-tooling = { module = "androidx.compose.ui:ui-tooling", version.ref = "compose" }
compose-activity = { module = "androidx.activity:activity-compose", version = "1.3.0-alpha07" }
compose-test = { module = "androidx.compose.ui:ui-test-junit4", version.ref = "compose" }
+navigation-compose = { module = "androidx.navigation:navigation-compose", version = "2.4.0-alpha01" }
+coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" }
+coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" }
preference = { module = "androidx.preference:preference-ktx", version = "1.1.1" }
lifecycle = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version = "2.3.1" }
hilt-android-core = { module = "com.google.dagger:hilt-android", version.ref = "hilt-android" }
hilt-android-kapt = { module = "com.google.dagger:hilt-compiler", version.ref = "hilt-android" }
hilt-work-core = { module = "androidx.hilt:hilt-work", version.ref = "hilt-work" }
hilt-work-kapt = { module = "androidx.hilt:hilt-compiler", version.ref = "hilt-work" }
-moshi-core = { module = "com.squareup.moshi:moshi", version.ref = "moshi" }
-moshi-adapters = { module = "com.squareup.moshi:moshi-adapters", version.ref = "moshi" }
-moshi-kapt = { module = "com.squareup.moshi:moshi-kotlin-codegen", version.ref = "moshi" }
junit = { module = "junit:junit", version = "4.12" }
kotlin-gradle = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" }
kotlin-stdlib = { module = "org.jetbrains.kotlin:kotlin-stdlib", version.ref = "kotlin" }
+kotlin-reflection = { module = "org.jetbrains.kotlin:kotlin-reflect", version.ref = "kotlin" }
+kotlinx-serialization = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version = "1.2.1"}
+ktor-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor"}
+ktor-cio = { module = "io.ktor:ktor-client-cio", version.ref = "ktor"}
+ktor-logging = { module = "io.ktor:ktor-client-logging-jvm", version.ref = "ktor"}
+ktor-serialization = { module = "io.ktor:ktor-client-serialization", version.ref = "ktor"}
room-ktx = { module = "androidx.room:room-ktx", version.ref = "room" }
room-kapt = { module = "androidx.room:room-compiler", version.ref = "room" }
room-test = { module = "androidx.room:room-testing", version.ref = "room" }
-retrofit-core = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" }
-retrofit-moshi = { module = "com.squareup.retrofit2:converter-moshi", version.ref = "retrofit" }
-okhttp-logging = { module = "com.squareup.okhttp3:logging-interceptor", version.ref = "okhttp" }
timber = { module = "com.jakewharton.timber:timber", version = "4.7.1" }
-test-ext = { module = "androidx.text.ext:junit", version = "1.1.2" }
+test-ext = { module = "androidx.test.ext:junit", version = "1.1.2" }
espresso = { module = "androidx.test.espresso:espresso-core", version.ref = "espresso" }
work-core = { module = "androidx.work:work-runtime-ktx", version.ref = "work" }
work-test = { module = "androidx.work:work-testing", version.ref = "work" }
[bundles]
-compose = ["compose-ui", "compose-material", "compose-tooling", "compose-activity"]
-moshi = ["moshi-core", "moshi-adapters"]
-networking = ["retrofit-core", "retrofit-moshi", "okhttp-logging"]
+compose = ["compose-ui", "compose-material", "compose-tooling", "compose-activity", "navigation-compose"]
+coroutines = ["coroutines-core", "coroutines-android"]
+networking = ["kotlin-reflection", "kotlinx-serialization", "ktor-core", "ktor-cio", "ktor-logging", "ktor-serialization"]
+
+
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
index 12907bf..f289df8 100644
--- a/gradle/wrapper/gradle-wrapper.properties
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -1,6 +1,6 @@
#Sun May 09 12:06:51 MDT 2021
distributionBase=GRADLE_USER_HOME
-distributionUrl=https\://services.gradle.org/distributions/gradle-7.0-bin.zip
+distributionUrl=https\://services.gradle.org/distributions/gradle-7.0.2-all.zip
distributionPath=wrapper/dists
zipStorePath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
diff --git a/gradlew b/gradlew
old mode 100644
new mode 100755
diff --git a/network/.gitignore b/network/.gitignore
new file mode 100644
index 0000000..42afabf
--- /dev/null
+++ b/network/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/network/build.gradle.kts b/network/build.gradle.kts
new file mode 100644
index 0000000..7a4454e
--- /dev/null
+++ b/network/build.gradle.kts
@@ -0,0 +1,49 @@
+plugins {
+ id("com.android.library")
+ id("kotlin-android")
+ id("kotlin-kapt")
+ kotlin("plugin.serialization") version "1.4.32"
+ id("dagger.hilt.android.plugin")
+}
+
+android {
+ compileSdk = libs.versions.maxSdk.get().toInt()
+
+ defaultConfig {
+ minSdk = libs.versions.minSdk.get().toInt()
+ targetSdk = libs.versions.maxSdk.get().toInt()
+
+ testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+ consumerProguardFiles("consumer-rules.pro")
+ }
+
+ buildTypes {
+ release {
+ isMinifyEnabled = false
+ proguardFiles(
+ getDefaultProguardFile("proguard-android-optimize.txt"),
+ "proguard-rules.pro"
+ )
+ }
+ }
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_1_8
+ targetCompatibility = JavaVersion.VERSION_1_8
+ }
+ kotlinOptions {
+ jvmTarget = "1.8"
+ }
+}
+
+dependencies {
+ implementation(project(":common"))
+ implementation(project(":storage"))
+ implementation(libs.kotlin.stdlib)
+ implementation(libs.androidx.core)
+ implementation(libs.bundles.networking)
+ implementation(libs.hilt.android.core)
+ kapt(libs.hilt.android.kapt)
+ testImplementation(libs.junit)
+ androidTestImplementation(libs.test.ext)
+ androidTestImplementation(libs.espresso)
+}
\ No newline at end of file
diff --git a/network/consumer-rules.pro b/network/consumer-rules.pro
new file mode 100644
index 0000000..e69de29
diff --git a/network/proguard-rules.pro b/network/proguard-rules.pro
new file mode 100644
index 0000000..481bb43
--- /dev/null
+++ b/network/proguard-rules.pro
@@ -0,0 +1,21 @@
+# Add project specific ProGuard rules here.
+# You can control the set of applied configuration files using the
+# proguardFiles setting in build.gradle.
+#
+# For more details, see
+# http://developer.android.com/guide/developing/tools/proguard.html
+
+# If your project uses WebView with JS, uncomment the following
+# and specify the fully qualified class name to the JavaScript interface
+# class:
+#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
+# public *;
+#}
+
+# Uncomment this to preserve the line number information for
+# debugging stack traces.
+#-keepattributes SourceFile,LineNumberTable
+
+# If you keep the line number information, uncomment this to
+# hide the original source file name.
+#-renamesourcefileattribute SourceFile
\ No newline at end of file
diff --git a/network/src/androidTest/java/com/wbrawner/nanoflux/network/ExampleInstrumentedTest.kt b/network/src/androidTest/java/com/wbrawner/nanoflux/network/ExampleInstrumentedTest.kt
new file mode 100644
index 0000000..0a6357d
--- /dev/null
+++ b/network/src/androidTest/java/com/wbrawner/nanoflux/network/ExampleInstrumentedTest.kt
@@ -0,0 +1,22 @@
+package com.wbrawner.nanoflux.network
+
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.platform.app.InstrumentationRegistry
+import org.junit.Assert.assertEquals
+import org.junit.Test
+import org.junit.runner.RunWith
+
+/**
+ * Instrumented test, which will execute on an Android device.
+ *
+ * See [testing documentation](http://d.android.com/tools/testing).
+ */
+@RunWith(AndroidJUnit4::class)
+class ExampleInstrumentedTest {
+ @Test
+ fun useAppContext() {
+ // Context of the app under test.
+ val appContext = InstrumentationRegistry.getInstrumentation().targetContext
+ assertEquals("com.wbrawner.nanoflux.network.test", appContext.packageName)
+ }
+}
\ No newline at end of file
diff --git a/network/src/main/AndroidManifest.xml b/network/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..afe7a00
--- /dev/null
+++ b/network/src/main/AndroidManifest.xml
@@ -0,0 +1,4 @@
+
+
+
+
\ No newline at end of file
diff --git a/network/src/main/java/com/wbrawner/nanoflux/network/MinifluxApiService.kt b/network/src/main/java/com/wbrawner/nanoflux/network/MinifluxApiService.kt
new file mode 100644
index 0000000..8c26d5a
--- /dev/null
+++ b/network/src/main/java/com/wbrawner/nanoflux/network/MinifluxApiService.kt
@@ -0,0 +1,440 @@
+package com.wbrawner.nanoflux.network
+
+import android.content.SharedPreferences
+import com.wbrawner.nanoflux.network.repository.PREF_KEY_AUTH_TOKEN
+import com.wbrawner.nanoflux.storage.model.*
+import io.ktor.client.*
+import io.ktor.client.engine.cio.*
+import io.ktor.client.features.json.*
+import io.ktor.client.features.json.serializer.*
+import io.ktor.client.features.logging.*
+import io.ktor.client.request.*
+import io.ktor.client.statement.*
+import io.ktor.http.*
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+import timber.log.Timber
+import java.io.File
+import java.util.concurrent.atomic.AtomicReference
+import javax.inject.Inject
+
+
+interface MinifluxApiService {
+ fun setBaseUrl(url: String)
+
+ suspend fun discoverSubscriptions(
+ url: String,
+ username: String? = null,
+ password: String? = null,
+ userAgent: String? = null,
+ fetchViaProxy: Boolean? = null
+ ): DiscoverResponse
+
+ suspend fun getFeeds(): List
+
+ suspend fun getCategoryFeeds(categoryId: Long): List
+
+ suspend fun getFeed(id: Long): FeedJson
+
+ suspend fun getFeedIcon(feedId: Long): Feed.Icon
+
+ suspend fun createFeed(url: String, categoryId: Long?): CreateFeedResponse
+
+ suspend fun updateFeed(id: Long, feed: FeedJson): Feed
+
+ suspend fun refreshFeeds(): HttpResponse
+
+ suspend fun refreshFeed(id: Long): HttpResponse
+
+ suspend fun deleteFeed(id: Long): HttpResponse
+
+ suspend fun getFeedEntries(
+ feedId: Long,
+ status: List? = null,
+ offset: Long? = null,
+ limit: Long? = null,
+ order: Entry.Order? = null,
+ direction: Entry.SortDirection? = null,
+ before: Long? = null,
+ after: Long? = null,
+ beforeEntryId: Long? = null,
+ afterEntryId: Long? = null,
+ starred: Boolean? = null,
+ search: String? = null,
+ categoryId: Long? = null,
+ ): EntryResponse
+
+ suspend fun getFeedEntry(feedId: Long, entryId: Long): Entry
+
+ suspend fun getEntries(
+ status: List? = null,
+ offset: Long? = null,
+ limit: Long? = null,
+ order: Entry.Order? = null,
+ direction: Entry.SortDirection? = null,
+ before: Long? = null,
+ after: Long? = null,
+ beforeEntryId: Long? = null,
+ afterEntryId: Long? = null,
+ starred: Boolean? = null,
+ search: String? = null,
+ categoryId: Long? = null,
+ ): EntryResponse
+
+ suspend fun getEntry(entryId: Long): Entry
+
+ suspend fun markFeedEntriesAsRead(id: Long): HttpResponse
+
+ suspend fun updateEntries(entryIds: List, status: Entry.Status): HttpResponse
+
+ suspend fun toggleEntryBookmark(id: Long): HttpResponse
+
+ suspend fun getCategories(): List
+
+ suspend fun createCategory(title: String): Category
+
+ suspend fun updateCategory(id: Long, title: String): Category
+
+ suspend fun deleteCategory(id: Long): HttpResponse
+
+ suspend fun markCategoryEntriesAsRead(id: Long): HttpResponse
+
+ //@GET("export")
+ suspend fun opmlExport(): HttpResponse
+
+ //@POST("import")
+ suspend fun opmlImport(opml: File): HttpResponse
+
+// //@POST("users")
+// suspend fun createUser(user: UserRequest): User
+
+ //@PUT("users/{id}")
+ suspend fun updateUser(id: Long, user: User): User
+
+ //@GET("me")
+ suspend fun getCurrentUser(): User
+
+ //@GET("users/{id}")
+ suspend fun getUser(id: Long): User
+
+ //@GET("users/{name}")
+ suspend fun getUserByName(name: String): User
+
+ //@GET("users")
+ suspend fun getUses(): List
+
+ //@DELETE("users/{id}")
+ suspend fun deleteUser(id: Long)
+
+ //@PUT("users/{id}/mark-all-as-read")
+ suspend fun markUserEntriesAsRead(id: Long): HttpResponse
+
+ //@GET("/healthcheck")
+ suspend fun healthcheck(): String
+
+ //@GET("/version")
+ suspend fun version(): String
+}
+
+@Serializable
+data class DiscoverRequest(
+ val url: String,
+ val username: String? = null,
+ val password: String? = null,
+ val userAgent: String? = null,
+ @SerialName("fetch_via_proxy")
+ val fetchViaProxy: Boolean? = null
+)
+
+@Serializable
+data class DiscoverResponse(val url: String, val title: String, val type: String)
+
+@Serializable
+data class ErrorResponse(@SerialName("error_message") val errorMessage: String)
+
+@Serializable
+data class FeedRequest(
+ @SerialName("feed_url")
+ val feedUrl: String,
+ @SerialName("category_id")
+ val categoryId: Long? = null
+)
+
+@Serializable
+data class CreateFeedResponse(@SerialName("feed_id") val feedId: Long)
+
+@Serializable
+data class EntryResponse(val total: Long, val entries: List)
+
+@Suppress("unused")
+class KtorMinifluxApiService @Inject constructor(
+ private val sharedPreferences: SharedPreferences,
+ private val _logger: Timber.Tree
+) : MinifluxApiService {
+ private val client = HttpClient(CIO) {
+ install(JsonFeature) {
+ serializer = KotlinxSerializer(kotlinx.serialization.json.Json {
+ isLenient = true
+ ignoreUnknownKeys = true
+ })
+ }
+ install(Logging) {
+ logger = object : Logger {
+ override fun log(message: String) {
+ _logger.v(message)
+ }
+
+ }
+ level = LogLevel.ALL
+ }
+ }
+ private val _baseUrl: AtomicReference = AtomicReference()
+ private val baseUrl: String
+ get() = _baseUrl.get() ?: run {
+ sharedPreferences.getString(PREF_KEY_BASE_URL, null)?.let {
+ _baseUrl.set(it)
+ it
+ } ?: ""
+ }
+
+ private fun url(
+ path: String,
+ vararg queryParams: Pair
+ ): Url {
+ val url = URLBuilder(baseUrl)
+ if (path.startsWith('/')) {
+ url.path(path)
+ } else {
+ url.encodedPath += "/$path"
+ }
+ for (param in queryParams) {
+ param.second?.let {
+ url.parameters.append(param.first, it.toString())
+ }
+ }
+ return url.build()
+ }
+
+ override fun setBaseUrl(url: String) {
+ _baseUrl.set(url)
+ }
+
+ override suspend fun discoverSubscriptions(
+ url: String,
+ username: String?,
+ password: String?,
+ userAgent: String?,
+ fetchViaProxy: Boolean?
+ ): DiscoverResponse = client.post(url("discover")) {
+ body = DiscoverRequest(url, username, password, userAgent, fetchViaProxy)
+ headers {
+ header("Authorization", sharedPreferences.getString(PREF_KEY_AUTH_TOKEN, "").toString())
+ }
+ }
+
+ override suspend fun getFeeds(): List = client.get(url("feeds")) {
+ headers {
+ header("Authorization", sharedPreferences.getString(PREF_KEY_AUTH_TOKEN, "").toString())
+ }
+ }
+
+ override suspend fun getCategoryFeeds(categoryId: Long): List =
+ client.get(baseUrl + "categories/$categoryId/feeds") {
+ headers {
+ header(
+ "Authorization",
+ sharedPreferences.getString(PREF_KEY_AUTH_TOKEN, "").toString()
+ )
+ }
+ }
+
+ override suspend fun getFeed(id: Long): FeedJson = client.get(url("feeds/$id")) {
+ headers {
+ header("Authorization", sharedPreferences.getString(PREF_KEY_AUTH_TOKEN, "").toString())
+ }
+ }
+
+ override suspend fun getFeedIcon(feedId: Long): Feed.Icon =
+ client.get(url("feeds/$feedId/icon")) {
+ headers {
+ header(
+ "Authorization",
+ sharedPreferences.getString(PREF_KEY_AUTH_TOKEN, "").toString()
+ )
+ }
+ }
+
+ override suspend fun createFeed(url: String, categoryId: Long?): CreateFeedResponse =
+ client.post(url("feeds")) {
+ body = FeedRequest(url, categoryId)
+ headers {
+ header(
+ "Authorization",
+ sharedPreferences.getString(PREF_KEY_AUTH_TOKEN, "").toString()
+ )
+ }
+ }
+
+ override suspend fun updateFeed(id: Long, feed: FeedJson): Feed = client.put(url("feeds/$id")) {
+ body = feed
+ }
+
+ override suspend fun refreshFeeds(): HttpResponse = client.put(url("feeds/refresh"))
+
+ override suspend fun refreshFeed(id: Long): HttpResponse = client.put(url("feeds/$id/refresh"))
+
+ override suspend fun deleteFeed(id: Long): HttpResponse = client.delete(url("feeds/$id"))
+
+ override suspend fun getFeedEntries(
+ feedId: Long,
+ status: List?,
+ offset: Long?,
+ limit: Long?,
+ order: Entry.Order?,
+ direction: Entry.SortDirection?,
+ before: Long?,
+ after: Long?,
+ beforeEntryId: Long?,
+ afterEntryId: Long?,
+ starred: Boolean?,
+ search: String?,
+ categoryId: Long?
+ ): EntryResponse = client.get(
+ url(
+ "feeds/$feedId/entries",
+ "status" to status?.joinToString(","),
+ "offset" to offset,
+ "limit" to limit,
+ "order" to order,
+ "direction" to direction,
+ "before" to before,
+ "after" to after,
+ "before_entry_id" to beforeEntryId,
+ "after_entry_id" to afterEntryId,
+ "starred" to starred,
+ "search" to search,
+ "category_id" to categoryId,
+ )
+ )
+
+ override suspend fun getFeedEntry(feedId: Long, entryId: Long): Entry =
+ client.get(url("feeds/$feedId/entries/$entryId"))
+
+ override suspend fun getEntries(
+ status: List?,
+ offset: Long?,
+ limit: Long?,
+ order: Entry.Order?,
+ direction: Entry.SortDirection?,
+ before: Long?,
+ after: Long?,
+ beforeEntryId: Long?,
+ afterEntryId: Long?,
+ starred: Boolean?,
+ search: String?,
+ categoryId: Long?
+ ): EntryResponse = client.get(
+ url(
+ "entries",
+ "status" to status?.joinToString(",") { it.name.toLowerCase() },
+ "offset" to offset,
+ "limit" to limit,
+ "order" to order,
+ "direction" to direction,
+ "before" to before,
+ "after" to after,
+ "before_entry_id" to beforeEntryId,
+ "after_entry_id" to afterEntryId,
+ "starred" to starred,
+ "search" to search,
+ "category_id" to categoryId,
+ )
+ ) {
+ headers {
+ header("Authorization", sharedPreferences.getString(PREF_KEY_AUTH_TOKEN, "").toString())
+ }
+ }
+
+ override suspend fun getEntry(entryId: Long): Entry = client.get(url("entries/$entryId"))
+
+ override suspend fun markFeedEntriesAsRead(id: Long): HttpResponse =
+ client.put(url("feeds/$id/mark-all-as-read"))
+
+ override suspend fun updateEntries(entryIds: List, status: Entry.Status): HttpResponse =
+ client.put(url("entries")) {
+// body = @Serializable object {
+// val entry_ids = entryIds
+// val status = status
+// }
+ }
+
+ override suspend fun toggleEntryBookmark(id: Long): HttpResponse =
+ client.put(url("entries/$id/bookmark"))
+
+ override suspend fun getCategories(): List = client.get(url("categories")) {
+ headers {
+ header("Authorization", sharedPreferences.getString(PREF_KEY_AUTH_TOKEN, "").toString())
+ }
+ }
+
+ override suspend fun createCategory(title: String): Category = client.post(url("categories")) {
+// body = @Serializable object {
+// val title = title
+// }
+ }
+
+ override suspend fun updateCategory(id: Long, title: String): Category =
+ client.put(url("categories/$id")) {
+// body = @Serializable object {
+// val title = title
+// }
+ }
+
+ override suspend fun deleteCategory(id: Long): HttpResponse =
+ client.delete(url("categories/$id"))
+
+ override suspend fun markCategoryEntriesAsRead(id: Long): HttpResponse =
+ client.put(url("categories/$id/mark-all-as-read"))
+
+ override suspend fun opmlExport(): HttpResponse {
+ TODO("Not yet implemented")
+ }
+
+ override suspend fun opmlImport(opml: File): HttpResponse {
+ TODO("Not yet implemented")
+ }
+
+ override suspend fun updateUser(id: Long, user: User): User {
+ TODO("Not yet implemented")
+ }
+
+ override suspend fun getCurrentUser(): User = client.get(url("me")) {
+ headers {
+ header("Authorization", sharedPreferences.getString(PREF_KEY_AUTH_TOKEN, "").toString())
+ }
+ }
+
+ override suspend fun getUser(id: Long): User = client.get(url("users/$id"))
+
+ override suspend fun getUserByName(name: String): User = client.get(url("users/$name"))
+
+ override suspend fun getUses(): List {
+ TODO("Not yet implemented")
+ }
+
+ override suspend fun deleteUser(id: Long) {
+ TODO("Not yet implemented")
+ }
+
+ override suspend fun markUserEntriesAsRead(id: Long): HttpResponse {
+ TODO("Not yet implemented")
+ }
+
+ override suspend fun healthcheck(): String {
+ TODO("Not yet implemented")
+ }
+
+ override suspend fun version(): String {
+ TODO("Not yet implemented")
+ }
+}
\ No newline at end of file
diff --git a/network/src/main/java/com/wbrawner/nanoflux/network/NetworkModule.kt b/network/src/main/java/com/wbrawner/nanoflux/network/NetworkModule.kt
new file mode 100644
index 0000000..51854d3
--- /dev/null
+++ b/network/src/main/java/com/wbrawner/nanoflux/network/NetworkModule.kt
@@ -0,0 +1,21 @@
+package com.wbrawner.nanoflux.network
+
+import android.content.SharedPreferences
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+import timber.log.Timber
+
+const val PREF_KEY_BASE_URL = "BASE_URL"
+
+@Module
+@InstallIn(SingletonComponent::class)
+object NetworkModule {
+ @Provides
+ fun provideMinifluxApiService(
+ sharedPreferences: SharedPreferences,
+ logger: Timber.Tree
+ ): MinifluxApiService =
+ KtorMinifluxApiService(sharedPreferences, logger)
+}
diff --git a/network/src/main/java/com/wbrawner/nanoflux/network/repository/CategoryRepository.kt b/network/src/main/java/com/wbrawner/nanoflux/network/repository/CategoryRepository.kt
new file mode 100644
index 0000000..accfbb1
--- /dev/null
+++ b/network/src/main/java/com/wbrawner/nanoflux/network/repository/CategoryRepository.kt
@@ -0,0 +1,25 @@
+package com.wbrawner.nanoflux.network.repository
+
+import com.wbrawner.nanoflux.network.MinifluxApiService
+import com.wbrawner.nanoflux.storage.dao.CategoryDao
+import com.wbrawner.nanoflux.storage.model.Category
+import timber.log.Timber
+import javax.inject.Inject
+
+class CategoryRepository @Inject constructor(
+ private val apiService: MinifluxApiService,
+ private val categoryDao: CategoryDao,
+ private val logger: Timber.Tree
+) {
+
+ suspend fun getAll(fetch: Boolean = true): List {
+ if (fetch) {
+ categoryDao.insertAll(*apiService.getCategories().toTypedArray())
+ }
+ return categoryDao.getAll()
+ }
+
+ suspend fun getById(id: Long): Category? = categoryDao.getAllByIds(id).firstOrNull()
+ ?: getAll(true)
+ .firstOrNull { it.id == id }
+}
\ No newline at end of file
diff --git a/network/src/main/java/com/wbrawner/nanoflux/network/repository/EntryRepository.kt b/network/src/main/java/com/wbrawner/nanoflux/network/repository/EntryRepository.kt
new file mode 100644
index 0000000..6cbf35f
--- /dev/null
+++ b/network/src/main/java/com/wbrawner/nanoflux/network/repository/EntryRepository.kt
@@ -0,0 +1,50 @@
+package com.wbrawner.nanoflux.network.repository
+
+import com.wbrawner.nanoflux.network.MinifluxApiService
+import com.wbrawner.nanoflux.storage.dao.EntryDao
+import com.wbrawner.nanoflux.storage.model.Entry
+import com.wbrawner.nanoflux.storage.model.EntryAndFeed
+import timber.log.Timber
+import javax.inject.Inject
+
+class EntryRepository @Inject constructor(
+ private val apiService: MinifluxApiService,
+ private val entryDao: EntryDao,
+ private val logger: Timber.Tree
+) {
+ suspend fun getAll(fetch: Boolean = false): List {
+ if (fetch) {
+ var page = 0
+ while (true) {
+ try {
+ entryDao.insertAll(apiService.getEntries(offset = 1000L * page++, limit = 1000).entries)
+ } catch (e: Exception) {
+ break
+ }
+ }
+ }
+ return entryDao.getAll()
+ }
+
+ suspend fun getAllUnread(fetch: Boolean = false): List {
+ if (fetch) {
+ var page = 0
+ while (true) {
+// try {
+ val response = apiService.getEntries(
+ offset = 10000000000L,// * page++,
+ limit = 1000,
+ status = listOf(Entry.Status.UNREAD)
+ )
+ entryDao.insertAll(
+ response.entries
+ )
+// } catch (e: Exception) {
+// Log.e("EntryRepository", "Error", e)
+// break
+// }
+ }
+ }
+ return entryDao.getAllUnread()
+ }
+}
\ No newline at end of file
diff --git a/network/src/main/java/com/wbrawner/nanoflux/network/repository/FeedRepository.kt b/network/src/main/java/com/wbrawner/nanoflux/network/repository/FeedRepository.kt
new file mode 100644
index 0000000..e61f626
--- /dev/null
+++ b/network/src/main/java/com/wbrawner/nanoflux/network/repository/FeedRepository.kt
@@ -0,0 +1,22 @@
+package com.wbrawner.nanoflux.network.repository
+
+import com.wbrawner.nanoflux.network.MinifluxApiService
+import com.wbrawner.nanoflux.storage.dao.FeedDao
+import com.wbrawner.nanoflux.storage.model.FeedCategoryIcon
+import timber.log.Timber
+import javax.inject.Inject
+
+class FeedRepository @Inject constructor(
+ private val apiService: MinifluxApiService,
+ private val feedDao: FeedDao,
+ private val iconRepository: IconRepository,
+ private val categoryRepository: CategoryRepository,
+ private val logger: Timber.Tree
+) {
+ suspend fun getAll(fetch: Boolean = false): List {
+ if (fetch) {
+ feedDao.insertAll(apiService.getFeeds().map { it.asFeed() })
+ }
+ return feedDao.getAll()
+ }
+}
\ No newline at end of file
diff --git a/network/src/main/java/com/wbrawner/nanoflux/network/repository/IconRepository.kt b/network/src/main/java/com/wbrawner/nanoflux/network/repository/IconRepository.kt
new file mode 100644
index 0000000..ce62cb1
--- /dev/null
+++ b/network/src/main/java/com/wbrawner/nanoflux/network/repository/IconRepository.kt
@@ -0,0 +1,30 @@
+package com.wbrawner.nanoflux.network.repository
+
+import com.wbrawner.nanoflux.network.MinifluxApiService
+import com.wbrawner.nanoflux.storage.dao.IconDao
+import com.wbrawner.nanoflux.storage.model.Feed
+import timber.log.Timber
+import javax.inject.Inject
+
+class IconRepository @Inject constructor(
+ private val apiService: MinifluxApiService,
+ private val iconDao: IconDao,
+ private val logger: Timber.Tree
+) {
+ suspend fun getIcon(id: Long, feedId: Long? = null): Feed.Icon? =
+ iconDao.getAllByIds(id).firstOrNull() ?: feedId?.let { feed ->
+ apiService.getFeedIcon(feed).also { icon ->
+ iconDao.insertAll(icon)
+ }
+ }
+
+ suspend fun getFeedIcon(feedId: Long): Feed.Icon? = try {
+ apiService.getFeedIcon(feedId).also { icon ->
+ iconDao.insertAll(icon)
+ }
+ } catch (e: Exception) {
+ // TODO: Check if network exception or 404
+ null
+ }
+}
+
diff --git a/app/src/main/java/com/wbrawner/nanoflux/data/repository/UserRepository.kt b/network/src/main/java/com/wbrawner/nanoflux/network/repository/UserRepository.kt
similarity index 86%
rename from app/src/main/java/com/wbrawner/nanoflux/data/repository/UserRepository.kt
rename to network/src/main/java/com/wbrawner/nanoflux/network/repository/UserRepository.kt
index 8129599..fe1be70 100644
--- a/app/src/main/java/com/wbrawner/nanoflux/data/repository/UserRepository.kt
+++ b/network/src/main/java/com/wbrawner/nanoflux/network/repository/UserRepository.kt
@@ -1,16 +1,14 @@
-package com.wbrawner.nanoflux.data.repository
+package com.wbrawner.nanoflux.network.repository
import android.content.SharedPreferences
import android.util.Base64
import androidx.core.content.edit
-import com.wbrawner.nanoflux.data.MinifluxApiService
-import com.wbrawner.nanoflux.data.dao.UserDao
-import com.wbrawner.nanoflux.data.model.User
-import com.wbrawner.nanoflux.di.PREF_KEY_BASE_URL
+import com.wbrawner.nanoflux.network.MinifluxApiService
+import com.wbrawner.nanoflux.network.PREF_KEY_BASE_URL
+import com.wbrawner.nanoflux.storage.dao.UserDao
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.flow.MutableSharedFlow
-import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
@@ -24,7 +22,7 @@ class UserRepository @Inject constructor(
private val userDao: UserDao,
private val logger: Timber.Tree
) {
- private val _currentUser: MutableSharedFlow = MutableSharedFlow(replay = 1)
+ private val _currentUser: MutableSharedFlow = MutableSharedFlow(replay = 1)
init {
GlobalScope.launch(Dispatchers.IO) {
@@ -54,6 +52,7 @@ class UserRepository @Inject constructor(
Base64.DEFAULT or Base64.NO_WRAP
)
.decodeToString()
+ apiService.setBaseUrl(correctedServer)
sharedPreferences.edit {
putString(PREF_KEY_BASE_URL, correctedServer)
putString(PREF_KEY_AUTH_TOKEN, "Basic $credentials")
@@ -84,7 +83,7 @@ class UserRepository @Inject constructor(
}
}
- suspend fun getCurrentUser(): User {
+ suspend fun getCurrentUser(): com.wbrawner.nanoflux.storage.model.User {
return _currentUser.replayCache.firstOrNull()
?: sharedPreferences.getLong(PREF_KEY_CURRENT_USER, -1L).let {
if (it == -1L) {
diff --git a/network/src/test/java/com/wbrawner/nanoflux/network/ExampleUnitTest.kt b/network/src/test/java/com/wbrawner/nanoflux/network/ExampleUnitTest.kt
new file mode 100644
index 0000000..95320b0
--- /dev/null
+++ b/network/src/test/java/com/wbrawner/nanoflux/network/ExampleUnitTest.kt
@@ -0,0 +1,16 @@
+package com.wbrawner.nanoflux.network
+
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+/**
+ * Example local unit test, which will execute on the development machine (host).
+ *
+ * See [testing documentation](http://d.android.com/tools/testing).
+ */
+class ExampleUnitTest {
+ @Test
+ fun addition_isCorrect() {
+ assertEquals(4, 2 + 2)
+ }
+}
\ No newline at end of file
diff --git a/settings.gradle.kts b/settings.gradle.kts
index d65e951..5f50bb8 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -8,3 +8,6 @@ dependencyResolutionManagement {
}
rootProject.name = "Nanoflux"
include(":app")
+include(":storage")
+include(":network")
+include(":common")
diff --git a/storage/.gitignore b/storage/.gitignore
new file mode 100644
index 0000000..42afabf
--- /dev/null
+++ b/storage/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/storage/build.gradle.kts b/storage/build.gradle.kts
new file mode 100644
index 0000000..35212af
--- /dev/null
+++ b/storage/build.gradle.kts
@@ -0,0 +1,62 @@
+plugins {
+ id("com.android.library")
+ id("kotlin-android")
+ id("kotlin-kapt")
+ kotlin("plugin.serialization") version "1.4.32"
+ id("dagger.hilt.android.plugin")
+}
+
+android {
+ compileSdk = libs.versions.maxSdk.get().toInt()
+
+ defaultConfig {
+ minSdk = libs.versions.minSdk.get().toInt()
+ targetSdk = libs.versions.maxSdk.get().toInt()
+
+ testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+ consumerProguardFiles("consumer-rules.pro")
+ javaCompileOptions {
+ annotationProcessorOptions {
+ arguments.putAll(mapOf(
+ "room.schemaLocation" to "$projectDir/schemas",
+ "room.incremental" to "true",
+ "room.expandProjection" to "true"
+ ))
+ }
+ }
+ }
+
+ buildTypes {
+ release {
+ isMinifyEnabled = false
+ proguardFiles(
+ getDefaultProguardFile("proguard-android-optimize.txt"),
+ "proguard-rules.pro"
+ )
+ }
+ }
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_1_8
+ targetCompatibility = JavaVersion.VERSION_1_8
+ }
+ kotlinOptions {
+ jvmTarget = "1.8"
+ }
+}
+
+dependencies {
+ implementation(project(":common"))
+ implementation(libs.kotlin.stdlib)
+ implementation(libs.androidx.core)
+ implementation(libs.preference)
+ implementation(libs.room.ktx)
+ kapt(libs.room.kapt)
+ implementation(libs.bundles.coroutines)
+ implementation(libs.kotlinx.serialization)
+ implementation(libs.hilt.android.core)
+ kapt(libs.hilt.android.kapt)
+ testImplementation(libs.junit)
+ testImplementation(libs.room.test)
+ androidTestImplementation(libs.test.ext)
+ androidTestImplementation(libs.espresso)
+}
\ No newline at end of file
diff --git a/storage/consumer-rules.pro b/storage/consumer-rules.pro
new file mode 100644
index 0000000..e69de29
diff --git a/storage/proguard-rules.pro b/storage/proguard-rules.pro
new file mode 100644
index 0000000..dfd2ffd
--- /dev/null
+++ b/storage/proguard-rules.pro
@@ -0,0 +1,40 @@
+# Add project specific ProGuard rules here.
+# You can control the set of applied configuration files using the
+# proguardFiles setting in build.gradle.
+#
+# For more details, see
+# http://developer.android.com/guide/developing/tools/proguard.html
+
+# If your project uses WebView with JS, uncomment the following
+# and specify the fully qualified class name to the JavaScript interface
+# class:
+#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
+# public *;
+#}
+
+# Uncomment this to preserve the line number information for
+# debugging stack traces.
+#-keepattributes SourceFile,LineNumberTable
+
+# If you keep the line number information, uncomment this to
+# hide the original source file name.
+#-renamesourcefileattribute SourceFile
+
+-keepattributes *Annotation*, InnerClasses
+-dontnote kotlinx.serialization.AnnotationsKt # core serialization annotations
+
+# kotlinx-serialization-json specific. Add this if you have java.lang.NoClassDefFoundError kotlinx.serialization.json.JsonObjectSerializer
+-keepclassmembers class kotlinx.serialization.json.** {
+ *** Companion;
+}
+-keepclasseswithmembers class kotlinx.serialization.json.** {
+ kotlinx.serialization.KSerializer serializer(...);
+}
+
+-keep,includedescriptorclasses class com.wbrawner.nanoflux.**$$serializer { *; } # <-- change package name to your app's
+-keepclassmembers class com.wbrawner.nanoflux.** { # <-- change package name to your app's
+ *** Companion;
+}
+-keepclasseswithmembers class com.wbrawner.nanoflux.** { # <-- change package name to your app's
+ kotlinx.serialization.KSerializer serializer(...);
+}
\ No newline at end of file
diff --git a/app/schemas/com.wbrawner.nanoflux.data.NanofluxDatabase/1.json b/storage/schemas/com.wbrawner.nanoflux.storage.NanofluxDatabase/1.json
similarity index 92%
rename from app/schemas/com.wbrawner.nanoflux.data.NanofluxDatabase/1.json
rename to storage/schemas/com.wbrawner.nanoflux.storage.NanofluxDatabase/1.json
index dbbea86..309bf1b 100644
--- a/app/schemas/com.wbrawner.nanoflux.data.NanofluxDatabase/1.json
+++ b/storage/schemas/com.wbrawner.nanoflux.storage.NanofluxDatabase/1.json
@@ -2,7 +2,7 @@
"formatVersion": 1,
"database": {
"version": 1,
- "identityHash": "a3ba65cc1674ea74c169d8f134ff3c42",
+ "identityHash": "afcf22cfdfd6aec5ea5cb19ceaa9b3bc",
"entities": [
{
"tableName": "Feed",
@@ -158,7 +158,7 @@
},
{
"tableName": "Entry",
- "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `userId` INTEGER NOT NULL, `feedId` INTEGER NOT NULL, `status` TEXT NOT NULL, `hash` TEXT NOT NULL, `title` TEXT NOT NULL, `url` TEXT NOT NULL, `commentsUrl` TEXT NOT NULL, `publishedAt` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `content` TEXT NOT NULL, `author` TEXT NOT NULL, `shareCode` TEXT NOT NULL, `starred` INTEGER NOT NULL, `readingTime` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `userId` INTEGER NOT NULL, `feedId` INTEGER NOT NULL, `status` TEXT NOT NULL, `hash` TEXT NOT NULL, `title` TEXT NOT NULL, `url` TEXT NOT NULL, `commentsUrl` TEXT NOT NULL, `publishedAt` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `content` TEXT NOT NULL, `author` TEXT NOT NULL, `shareCode` TEXT NOT NULL, `starred` INTEGER NOT NULL, `readingTime` INTEGER NOT NULL, `enclosures` TEXT, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
@@ -249,6 +249,12 @@
"columnName": "readingTime",
"affinity": "INTEGER",
"notNull": true
+ },
+ {
+ "fieldPath": "enclosures",
+ "columnName": "enclosures",
+ "affinity": "TEXT",
+ "notNull": false
}
],
"primaryKey": {
@@ -294,24 +300,30 @@
},
{
"tableName": "Icon",
- "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`feedId` INTEGER NOT NULL, `iconId` INTEGER NOT NULL, PRIMARY KEY(`iconId`))",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `data` TEXT NOT NULL, `mimeType` TEXT NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
- "fieldPath": "feedId",
- "columnName": "feedId",
+ "fieldPath": "id",
+ "columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
- "fieldPath": "iconId",
- "columnName": "iconId",
- "affinity": "INTEGER",
+ "fieldPath": "data",
+ "columnName": "data",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "mimeType",
+ "columnName": "mimeType",
+ "affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
- "iconId"
+ "id"
],
"autoGenerate": false
},
@@ -320,7 +332,7 @@
},
{
"tableName": "User",
- "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `username` TEXT NOT NULL, `admin` INTEGER NOT NULL, `theme` TEXT NOT NULL, `language` TEXT NOT NULL, `timezone` TEXT NOT NULL, `entrySortingDirection` TEXT NOT NULL, `stylesheet` TEXT NOT NULL, `googleId` TEXT NOT NULL, `openidConnectId` TEXT NOT NULL, `entriesPerPage` INTEGER NOT NULL, `keyboardShortcuts` INTEGER NOT NULL, `showReadingTime` INTEGER NOT NULL, `entrySwipe` INTEGER NOT NULL, `lastLoginAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `username` TEXT NOT NULL, `admin` INTEGER NOT NULL, `theme` TEXT NOT NULL, `language` TEXT NOT NULL, `timezone` TEXT NOT NULL, `entrySortingDirection` TEXT NOT NULL, `stylesheet` TEXT NOT NULL, `googleId` TEXT NOT NULL, `openidConnectId` TEXT NOT NULL, `entriesPerPage` INTEGER NOT NULL, `keyboardShortcuts` INTEGER NOT NULL, `showReadingTime` INTEGER NOT NULL, `entrySwipe` INTEGER NOT NULL, `lastLoginAt` INTEGER NOT NULL, `displayMode` TEXT NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
@@ -411,6 +423,12 @@
"columnName": "lastLoginAt",
"affinity": "INTEGER",
"notNull": true
+ },
+ {
+ "fieldPath": "displayMode",
+ "columnName": "displayMode",
+ "affinity": "TEXT",
+ "notNull": true
}
],
"primaryKey": {
@@ -426,7 +444,7 @@
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
- "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'a3ba65cc1674ea74c169d8f134ff3c42')"
+ "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'afcf22cfdfd6aec5ea5cb19ceaa9b3bc')"
]
}
}
\ No newline at end of file
diff --git a/storage/src/androidTest/java/com/wbrawner/nanoflux/storage/ExampleInstrumentedTest.kt b/storage/src/androidTest/java/com/wbrawner/nanoflux/storage/ExampleInstrumentedTest.kt
new file mode 100644
index 0000000..0562435
--- /dev/null
+++ b/storage/src/androidTest/java/com/wbrawner/nanoflux/storage/ExampleInstrumentedTest.kt
@@ -0,0 +1,22 @@
+package com.wbrawner.nanoflux.storage
+
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.platform.app.InstrumentationRegistry
+import org.junit.Assert.assertEquals
+import org.junit.Test
+import org.junit.runner.RunWith
+
+/**
+ * Instrumented test, which will execute on an Android device.
+ *
+ * See [testing documentation](http://d.android.com/tools/testing).
+ */
+@RunWith(AndroidJUnit4::class)
+class ExampleInstrumentedTest {
+ @Test
+ fun useAppContext() {
+ // Context of the app under test.
+ val appContext = InstrumentationRegistry.getInstrumentation().targetContext
+ assertEquals("com.wbrawner.nanoflux.storage.test", appContext.packageName)
+ }
+}
\ No newline at end of file
diff --git a/storage/src/main/AndroidManifest.xml b/storage/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..d1fa647
--- /dev/null
+++ b/storage/src/main/AndroidManifest.xml
@@ -0,0 +1,4 @@
+
+
+
+
\ No newline at end of file
diff --git a/storage/src/main/java/com/wbrawner/nanoflux/storage/NanofluxDatabase.kt b/storage/src/main/java/com/wbrawner/nanoflux/storage/NanofluxDatabase.kt
new file mode 100644
index 0000000..7ed5eea
--- /dev/null
+++ b/storage/src/main/java/com/wbrawner/nanoflux/storage/NanofluxDatabase.kt
@@ -0,0 +1,29 @@
+package com.wbrawner.nanoflux.storage
+
+import androidx.room.Database
+import androidx.room.RoomDatabase
+import androidx.room.TypeConverter
+import androidx.room.TypeConverters
+import java.util.*
+
+@Database(
+ entities = [com.wbrawner.nanoflux.storage.model.Feed::class, com.wbrawner.nanoflux.storage.model.Entry::class, com.wbrawner.nanoflux.storage.model.Category::class, com.wbrawner.nanoflux.storage.model.Feed.Icon::class, com.wbrawner.nanoflux.storage.model.User::class],
+ version = 1,
+ exportSchema = true,
+)
+@TypeConverters(DateTypeConverter::class)
+abstract class NanofluxDatabase: RoomDatabase() {
+ abstract fun feedDao(): com.wbrawner.nanoflux.storage.dao.FeedDao
+ abstract fun entryDao(): com.wbrawner.nanoflux.storage.dao.EntryDao
+ abstract fun categoryDao(): com.wbrawner.nanoflux.storage.dao.CategoryDao
+ abstract fun iconDao(): com.wbrawner.nanoflux.storage.dao.IconDao
+ abstract fun userDao(): com.wbrawner.nanoflux.storage.dao.UserDao
+}
+
+class DateTypeConverter {
+ @TypeConverter
+ fun fromTimestamp(timestamp: Long) = Date(timestamp)
+
+ @TypeConverter
+ fun toTimestamp(date: Date) = date.time
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/wbrawner/nanoflux/di/StorageModule.kt b/storage/src/main/java/com/wbrawner/nanoflux/storage/StorageModule.kt
similarity index 92%
rename from app/src/main/java/com/wbrawner/nanoflux/di/StorageModule.kt
rename to storage/src/main/java/com/wbrawner/nanoflux/storage/StorageModule.kt
index f9e98fe..6be558f 100644
--- a/app/src/main/java/com/wbrawner/nanoflux/di/StorageModule.kt
+++ b/storage/src/main/java/com/wbrawner/nanoflux/storage/StorageModule.kt
@@ -1,11 +1,10 @@
-package com.wbrawner.nanoflux.di
+package com.wbrawner.nanoflux.storage
import android.content.Context
import android.content.SharedPreferences
import androidx.preference.PreferenceManager
import androidx.room.Room
-import com.wbrawner.nanoflux.data.NanofluxDatabase
-import com.wbrawner.nanoflux.data.dao.*
+import com.wbrawner.nanoflux.storage.dao.*
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
diff --git a/storage/src/main/java/com/wbrawner/nanoflux/storage/dao/CategoryDao.kt b/storage/src/main/java/com/wbrawner/nanoflux/storage/dao/CategoryDao.kt
new file mode 100644
index 0000000..639be5b
--- /dev/null
+++ b/storage/src/main/java/com/wbrawner/nanoflux/storage/dao/CategoryDao.kt
@@ -0,0 +1,19 @@
+package com.wbrawner.nanoflux.storage.dao
+
+import androidx.room.*
+import com.wbrawner.nanoflux.storage.model.Category
+
+@Dao
+interface CategoryDao {
+ @Query("SELECT * FROM Category")
+ fun getAll(): List
+
+ @Query("SELECT * FROM Category WHERE id in (:ids)")
+ fun getAllByIds(vararg ids: Long): List
+
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ fun insertAll(vararg categories: Category)
+
+ @Delete
+ fun delete(category: Category)
+}
\ No newline at end of file
diff --git a/storage/src/main/java/com/wbrawner/nanoflux/storage/dao/EntryDao.kt b/storage/src/main/java/com/wbrawner/nanoflux/storage/dao/EntryDao.kt
new file mode 100644
index 0000000..d4cbc35
--- /dev/null
+++ b/storage/src/main/java/com/wbrawner/nanoflux/storage/dao/EntryDao.kt
@@ -0,0 +1,34 @@
+package com.wbrawner.nanoflux.storage.dao
+
+import androidx.room.*
+import com.wbrawner.nanoflux.storage.model.Entry
+import com.wbrawner.nanoflux.storage.model.EntryAndFeed
+
+@Dao
+interface EntryDao {
+ @Transaction
+ @Query("SELECT * FROM Entry")
+ fun getAll(): List
+
+ @Transaction
+ @Query("SELECT * FROM Entry WHERE status = \"UNREAD\"")
+ fun getAllUnread(): List
+
+ @Transaction
+ @Query("SELECT * FROM Entry WHERE id in (:ids)")
+ fun getAllByIds(vararg ids: Long): List
+
+ @Transaction
+ @Query("SELECT * FROM Entry WHERE feedId in (:ids)")
+ fun getAllByFeedIds(vararg ids: Long): List
+
+ @Transaction
+ @Query("SELECT * FROM Entry WHERE status = \"UNREAD\" AND feedId in (:ids)")
+ fun getAllUnreadByFeedIds(vararg ids: Long): List
+
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ fun insertAll(entries: List)
+
+ @Delete
+ fun delete(entry: Entry)
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/wbrawner/nanoflux/data/dao/FeedDao.kt b/storage/src/main/java/com/wbrawner/nanoflux/storage/dao/FeedDao.kt
similarity index 56%
rename from app/src/main/java/com/wbrawner/nanoflux/data/dao/FeedDao.kt
rename to storage/src/main/java/com/wbrawner/nanoflux/storage/dao/FeedDao.kt
index 4c6b5b5..9ee945b 100644
--- a/app/src/main/java/com/wbrawner/nanoflux/data/dao/FeedDao.kt
+++ b/storage/src/main/java/com/wbrawner/nanoflux/storage/dao/FeedDao.kt
@@ -1,21 +1,20 @@
-package com.wbrawner.nanoflux.data.dao
+package com.wbrawner.nanoflux.storage.dao
import androidx.room.*
-import com.wbrawner.nanoflux.data.model.Feed
-import com.wbrawner.nanoflux.data.model.FeedCategoryIcon
-import kotlinx.coroutines.flow.Flow
+import com.wbrawner.nanoflux.storage.model.Feed
+import com.wbrawner.nanoflux.storage.model.FeedCategoryIcon
@Dao
interface FeedDao {
@Transaction
@Query("SELECT * FROM Feed")
- fun getAll(): Flow>
+ fun getAll(): List
@Transaction
@Query("SELECT * FROM Feed WHERE id in (:ids)")
fun getAllByIds(vararg ids: Long): List
- @Insert
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
fun insertAll(feeds: List)
@Delete
diff --git a/storage/src/main/java/com/wbrawner/nanoflux/storage/dao/IconDao.kt b/storage/src/main/java/com/wbrawner/nanoflux/storage/dao/IconDao.kt
new file mode 100644
index 0000000..e9cb231
--- /dev/null
+++ b/storage/src/main/java/com/wbrawner/nanoflux/storage/dao/IconDao.kt
@@ -0,0 +1,20 @@
+package com.wbrawner.nanoflux.storage.dao
+
+import androidx.room.*
+import com.wbrawner.nanoflux.storage.model.Feed
+import kotlinx.coroutines.flow.Flow
+
+@Dao
+interface IconDao {
+ @Query("SELECT * FROM Icon")
+ fun getAll(): Flow>
+
+ @Query("SELECT * FROM Icon WHERE id in (:ids)")
+ fun getAllByIds(vararg ids: Long): List
+
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ fun insertAll(vararg icons: Feed.Icon)
+
+ @Delete
+ fun delete(icon: Feed.Icon)
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/wbrawner/nanoflux/data/dao/UserDao.kt b/storage/src/main/java/com/wbrawner/nanoflux/storage/dao/UserDao.kt
similarity index 78%
rename from app/src/main/java/com/wbrawner/nanoflux/data/dao/UserDao.kt
rename to storage/src/main/java/com/wbrawner/nanoflux/storage/dao/UserDao.kt
index e4e9f3b..8386172 100644
--- a/app/src/main/java/com/wbrawner/nanoflux/data/dao/UserDao.kt
+++ b/storage/src/main/java/com/wbrawner/nanoflux/storage/dao/UserDao.kt
@@ -1,11 +1,10 @@
-package com.wbrawner.nanoflux.data.dao
+package com.wbrawner.nanoflux.storage.dao
import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Insert
import androidx.room.Query
-import com.wbrawner.nanoflux.data.model.Feed
-import com.wbrawner.nanoflux.data.model.User
+import com.wbrawner.nanoflux.storage.model.User
@Dao
interface UserDao {
diff --git a/storage/src/main/java/com/wbrawner/nanoflux/storage/model/Category.kt b/storage/src/main/java/com/wbrawner/nanoflux/storage/model/Category.kt
new file mode 100644
index 0000000..6a3ba65
--- /dev/null
+++ b/storage/src/main/java/com/wbrawner/nanoflux/storage/model/Category.kt
@@ -0,0 +1,16 @@
+package com.wbrawner.nanoflux.storage.model
+
+import androidx.room.Entity
+import androidx.room.PrimaryKey
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+
+@Entity
+@Serializable
+data class Category(
+ @PrimaryKey
+ val id: Long,
+ val title: String,
+ @SerialName("user_id")
+ val userId: Long,
+)
\ No newline at end of file
diff --git a/storage/src/main/java/com/wbrawner/nanoflux/storage/model/Entry.kt b/storage/src/main/java/com/wbrawner/nanoflux/storage/model/Entry.kt
new file mode 100644
index 0000000..929dd79
--- /dev/null
+++ b/storage/src/main/java/com/wbrawner/nanoflux/storage/model/Entry.kt
@@ -0,0 +1,116 @@
+package com.wbrawner.nanoflux.storage.model
+
+import androidx.room.Embedded
+import androidx.room.Entity
+import androidx.room.PrimaryKey
+import androidx.room.Relation
+import kotlinx.serialization.KSerializer
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.descriptors.PrimitiveKind
+import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
+import kotlinx.serialization.descriptors.SerialDescriptor
+import kotlinx.serialization.encoding.Decoder
+import kotlinx.serialization.encoding.Encoder
+import java.text.SimpleDateFormat
+import java.util.*
+
+@Entity
+@Serializable
+data class Entry(
+ @PrimaryKey
+ val id: Long,
+ @SerialName("user_id")
+ val userId: Long,
+ @SerialName("feed_id")
+ val feedId: Long,
+ val status: Status,
+ val hash: String,
+ val title: String,
+ val url: String,
+ @SerialName("comments_url")
+ val commentsUrl: String,
+ @Serializable(with = ISODateSerializer::class)
+ @SerialName("published_at")
+ val publishedAt: Date,
+ @Serializable(with = ISODateSerializer::class)
+ @SerialName("created_at")
+ val createdAt: Date,
+ val content: String,
+ val author: String,
+ @SerialName("share_code")
+ val shareCode: String,
+ val starred: Boolean,
+ @SerialName("reading_time")
+ val readingTime: Int,
+ val enclosures: String?
+) {
+ @Serializable
+ enum class Status {
+ @SerialName("read")
+ READ,
+ @SerialName("unread")
+ UNREAD,
+ @SerialName("removed")
+ REMOVED
+ }
+
+ @Serializable
+ enum class Order {
+ ID,
+ STATUS,
+ PUBLISHED_AT,
+ CATEGORY_TITLE,
+ CATEGORY_ID
+ }
+
+ @Serializable
+ enum class SortDirection {
+ ASC,
+ DESC
+ }
+}
+
+data class EntryAndFeed(
+ @Embedded
+ val entry: Entry,
+ @Relation(
+ parentColumn = "feedId",
+ entityColumn = "id",
+ entity = Feed::class
+ )
+ val feed: FeedCategoryIcon,
+)
+
+object ISODateSerializer : KSerializer {
+ val dateFormat = object : ThreadLocal() {
+ override fun initialValue(): SimpleDateFormat =
+ SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSSSSZ", Locale.ENGLISH)
+ }
+ val altDateFormat = object : ThreadLocal() {
+ override fun initialValue(): SimpleDateFormat =
+ SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSSSS'Z'", Locale.ENGLISH)
+ }
+ val altDateFormat2 = object : ThreadLocal() {
+ override fun initialValue(): SimpleDateFormat =
+ SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.ENGLISH)
+ }
+ override val descriptor: SerialDescriptor =
+ PrimitiveSerialDescriptor("Date", PrimitiveKind.STRING)
+
+ override fun serialize(encoder: Encoder, value: Date) =
+ encoder.encodeString(dateFormat.get()!!.format(value))
+
+ override fun deserialize(decoder: Decoder): Date {
+ val dateString = decoder.decodeString()
+ return try {
+ dateFormat.get()!!.parse(dateString)!!
+ } catch (e: Exception) {
+ try {
+ altDateFormat.get()!!.parse(dateString)!!
+ } catch (e: Exception) {
+ altDateFormat2.get()!!.parse(dateString)!!
+ }
+ }
+ }
+}
diff --git a/storage/src/main/java/com/wbrawner/nanoflux/storage/model/Feed.kt b/storage/src/main/java/com/wbrawner/nanoflux/storage/model/Feed.kt
new file mode 100644
index 0000000..f0606d4
--- /dev/null
+++ b/storage/src/main/java/com/wbrawner/nanoflux/storage/model/Feed.kt
@@ -0,0 +1,142 @@
+package com.wbrawner.nanoflux.storage.model
+
+import androidx.room.Embedded
+import androidx.room.Entity
+import androidx.room.PrimaryKey
+import androidx.room.Relation
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+import java.util.*
+
+@Entity
+data class Feed(
+ @PrimaryKey
+ val id: Long,
+ val userId: Long,
+ val title: String,
+ val siteUrl: String,
+ val feedUrl: String,
+ val checkedAt: Date,
+ val etagHeader: String,
+ val lastModifiedHeader: String,
+ val parsingErrorMessage: String,
+ val parsingErrorCount: Int,
+ val scraperRules: String,
+ val rewriteRules: String,
+ val crawler: Boolean,
+ val blocklistRules: String,
+ val keeplistRules: String,
+ val userAgent: String,
+ val username: String,
+ val password: String,
+ val disabled: Boolean,
+ val ignoreHttpCache: Boolean,
+ val fetchViaProxy: Boolean,
+ val categoryId: Long,
+ val iconId: Long?
+) {
+ @Entity
+ @Serializable
+ data class Icon(
+ @PrimaryKey
+ val id: Long,
+ val data: String,
+ @SerialName("mime_type")
+ val mimeType: String
+ )
+}
+
+data class FeedCategoryIcon(
+ @Embedded
+ val feed: Feed,
+ @Relation(
+ parentColumn = "iconId",
+ entityColumn = "id",
+ entity = Feed.Icon::class
+ )
+ val icon: Feed.Icon?,
+ @Relation(
+ parentColumn = "categoryId",
+ entityColumn = "id",
+ entity = Category::class
+ )
+ val category: Category
+)
+
+@Serializable
+data class FeedJson(
+ val id: Long,
+ @SerialName("user_id")
+ val userId: Long,
+ val title: String,
+ @SerialName("site_url")
+ val siteUrl: String,
+ @SerialName("feed_url")
+ val feedUrl: String,
+ @Serializable(with = ISODateSerializer::class)
+ @SerialName("checked_at")
+ val checkedAt: Date,
+ @SerialName("etag_header")
+ val etagHeader: String,
+ @SerialName("last_modified_header")
+ val lastModifiedHeader: String,
+ @SerialName("parsing_error_message")
+ val parsingErrorMessage: String,
+ @SerialName("parsing_error_count")
+ val parsingErrorCount: Int,
+ @SerialName("scraper_rules")
+ val scraperRules: String,
+ @SerialName("rewrite_rules")
+ val rewriteRules: String,
+ val crawler: Boolean,
+ @SerialName("blocklist_rules")
+ val blocklistRules: String,
+ @SerialName("keeplist_rules")
+ val keeplistRules: String,
+ @SerialName("user_agent")
+ val userAgent: String,
+ val username: String,
+ val password: String,
+ val disabled: Boolean,
+ @SerialName("ignore_http_cache")
+ val ignoreHttpCache: Boolean,
+ @SerialName("fetch_via_proxy")
+ val fetchViaProxy: Boolean,
+ val category: Category,
+ @SerialName("icon")
+ val icon: IconJson?
+) {
+ @Serializable
+ data class IconJson(
+ @SerialName("feed_id")
+ val feedId: Long,
+ @SerialName("icon_id")
+ val iconId: Long
+ )
+
+ fun asFeed(): Feed = Feed(
+ id,
+ userId,
+ title,
+ siteUrl,
+ feedUrl,
+ checkedAt,
+ etagHeader,
+ lastModifiedHeader,
+ parsingErrorMessage,
+ parsingErrorCount,
+ scraperRules,
+ rewriteRules,
+ crawler,
+ blocklistRules,
+ keeplistRules,
+ userAgent,
+ username,
+ password,
+ disabled,
+ ignoreHttpCache,
+ fetchViaProxy,
+ category.id,
+ icon?.iconId
+ )
+}
\ No newline at end of file
diff --git a/storage/src/main/java/com/wbrawner/nanoflux/storage/model/User.kt b/storage/src/main/java/com/wbrawner/nanoflux/storage/model/User.kt
new file mode 100644
index 0000000..1ad6834
--- /dev/null
+++ b/storage/src/main/java/com/wbrawner/nanoflux/storage/model/User.kt
@@ -0,0 +1,40 @@
+package com.wbrawner.nanoflux.storage.model
+
+import androidx.room.Entity
+import androidx.room.PrimaryKey
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+import java.util.*
+
+@Entity
+@Serializable
+data class User(
+ @PrimaryKey
+ val id: Long,
+ val username: String,
+ @SerialName("is_admin")
+ val admin: Boolean,
+ val theme: String,
+ val language: String,
+ val timezone: String,
+ @SerialName("entry_sorting_direction")
+ val entrySortingDirection: String,
+ val stylesheet: String,
+ @SerialName("google_id")
+ val googleId: String,
+ @SerialName("openid_connect_id")
+ val openidConnectId: String,
+ @SerialName("entries_per_page")
+ val entriesPerPage: Int,
+ @SerialName("keyboard_shortcuts")
+ val keyboardShortcuts: Boolean,
+ @SerialName("show_reading_time")
+ val showReadingTime: Boolean,
+ @SerialName("entry_swipe")
+ val entrySwipe: Boolean,
+ @Serializable(with = ISODateSerializer::class)
+ @SerialName("last_login_at")
+ val lastLoginAt: Date,
+ @SerialName("display_mode")
+ val displayMode: String
+)
diff --git a/storage/src/test/java/com/wbrawner/nanoflux/storage/ExampleUnitTest.kt b/storage/src/test/java/com/wbrawner/nanoflux/storage/ExampleUnitTest.kt
new file mode 100644
index 0000000..e7ff817
--- /dev/null
+++ b/storage/src/test/java/com/wbrawner/nanoflux/storage/ExampleUnitTest.kt
@@ -0,0 +1,16 @@
+package com.wbrawner.nanoflux.storage
+
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+/**
+ * Example local unit test, which will execute on the development machine (host).
+ *
+ * See [testing documentation](http://d.android.com/tools/testing).
+ */
+class ExampleUnitTest {
+ @Test
+ fun addition_isCorrect() {
+ assertEquals(4, 2 + 2)
+ }
+}
\ No newline at end of file