Reorganize code into modules and move from square networking to ktor
This commit is contained in:
parent
c165cbfd29
commit
7e243f1853
68 changed files with 1819 additions and 681 deletions
|
@ -11,6 +11,9 @@
|
|||
<set>
|
||||
<option value="$PROJECT_DIR$" />
|
||||
<option value="$PROJECT_DIR$/app" />
|
||||
<option value="$PROJECT_DIR$/common" />
|
||||
<option value="$PROJECT_DIR$/network" />
|
||||
<option value="$PROJECT_DIR$/storage" />
|
||||
</set>
|
||||
</option>
|
||||
<option name="resolveModulePerSourceSet" value="false" />
|
||||
|
|
|
@ -1,5 +1,22 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="DesignSurface">
|
||||
<option name="filePathToZoomLevelMap">
|
||||
<map>
|
||||
<entry key="../../../../layout/compose-model-1622483486038.xml" value="0.3091216216216216" />
|
||||
<entry key="../../../../layout/compose-model-1622488740733.xml" value="2.0" />
|
||||
<entry key="../../../../layout/compose-model-1622489241568.xml" value="0.3091216216216216" />
|
||||
<entry key="../../../../layout/compose-model-1622493422463.xml" value="0.21410472972972974" />
|
||||
<entry key="../../../../layout/compose-model-1622561139451.xml" value="0.26708727655099895" />
|
||||
<entry key="../../../../layout/compose-model-1622568879104.xml" value="0.21494932432432431" />
|
||||
<entry key="../../../../layout/compose-model-1622574662045.xml" value="0.2179054054054054" />
|
||||
<entry key="../../../../layout/compose-model-1622575669307.xml" value="2.0" />
|
||||
<entry key="../../../../layout/compose-model-1622575673269.xml" value="0.2170608108108108" />
|
||||
<entry key="../../../../layout/compose-model-1622578435598.xml" value="0.2170608108108108" />
|
||||
<entry key="../../../../layout/compose-model-1622578490958.xml" value="0.2361111111111111" />
|
||||
</map>
|
||||
</option>
|
||||
</component>
|
||||
<component name="ProjectRootManager" version="2" languageLevel="JDK_11" default="true" project-jdk-name="11" project-jdk-type="JavaSDK">
|
||||
<output url="file://$PROJECT_DIR$/build/classes" />
|
||||
</component>
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
) {
|
||||
|
||||
}
|
|
@ -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()
|
||||
}
|
|
@ -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 Result.success()
|
||||
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())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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<Feed>
|
||||
|
||||
@GET("categories/{categoryId}/feeds")
|
||||
suspend fun getCategoryFeeds(@Path("categoryId") categoryId: Long): List<Feed>
|
||||
|
||||
@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<Entry.Status>? = 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<Entry>
|
||||
|
||||
@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<Entry.Status>? = 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<Entry>
|
||||
|
||||
@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<Category>
|
||||
|
||||
@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<ResponseBody>
|
||||
|
||||
@POST("import")
|
||||
@Streaming
|
||||
suspend fun opmlImport(@Body opml: RequestBody): retrofit2.Response<ResponseBody>
|
||||
|
||||
// @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<User>
|
||||
|
||||
@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<String>
|
||||
|
||||
@GET("/version")
|
||||
suspend fun version(): retrofit2.Response<String>
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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<Category>
|
||||
|
||||
@Query("SELECT * FROM Category WHERE id in (:ids)")
|
||||
fun getAllByIds(vararg ids: Long): List<Category>
|
||||
|
||||
@Insert
|
||||
fun insertAll(vararg categories: Category)
|
||||
|
||||
@Delete
|
||||
fun delete(category: Category)
|
||||
}
|
|
@ -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<List<Entry>>
|
||||
|
||||
@Query("SELECT * FROM Entry WHERE status = \"UNREAD\"")
|
||||
fun getAllUnread(): Flow<List<Entry>>
|
||||
|
||||
@Query("SELECT * FROM Entry WHERE id in (:ids)")
|
||||
fun getAllByIds(vararg ids: Long): List<Entry>
|
||||
|
||||
@Query("SELECT * FROM Entry WHERE feedId in (:ids)")
|
||||
fun getAllByFeedIds(vararg ids: Long): Flow<List<Entry>>
|
||||
|
||||
@Query("SELECT * FROM Entry WHERE status = \"UNREAD\" AND feedId in (:ids)")
|
||||
fun getAllUnreadByFeedIds(vararg ids: Long): Flow<List<Entry>>
|
||||
|
||||
@Insert
|
||||
fun insertAll(entries: List<Entry>)
|
||||
|
||||
@Delete
|
||||
fun delete(entry: Entry)
|
||||
}
|
|
@ -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<Feed.Icon>
|
||||
|
||||
@Query("SELECT * FROM Icon WHERE iconId in (:ids)")
|
||||
fun getAllByIds(vararg ids: Long): List<Feed.Icon>
|
||||
|
||||
@Insert
|
||||
fun insertAll(vararg icons: Feed.Icon)
|
||||
|
||||
@Delete
|
||||
fun delete(icon: Feed.Icon)
|
||||
}
|
|
@ -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,
|
||||
)
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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
|
||||
)
|
|
@ -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,
|
||||
)
|
|
@ -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<List<Entry>> = entryDao.getAll()
|
||||
|
||||
fun getAll(): Flow<List<Entry>> {
|
||||
GlobalScope.launch {
|
||||
entryDao.insertAll(apiService.getEntries())
|
||||
}
|
||||
return entries
|
||||
}
|
||||
}
|
|
@ -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<List<FeedCategoryIcon>> = feedDao.getAll()
|
||||
|
||||
fun getAll(): Flow<List<FeedCategoryIcon>> {
|
||||
GlobalScope.launch {
|
||||
feedDao.insertAll(apiService.getFeeds())
|
||||
}
|
||||
return feeds
|
||||
}
|
||||
}
|
|
@ -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 = "",
|
||||
|
|
|
@ -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>(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<EntryAndFeed>): FeedState()
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
223
app/src/main/java/com/wbrawner/nanoflux/ui/Entries.kt
Normal file
223
app/src/main/java/com/wbrawner/nanoflux/ui/Entries.kt
Normal file
File diff suppressed because one or more lines are too long
|
@ -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<SyncWorker>().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, {}, {}, {}, {}, {}, {})
|
||||
}
|
||||
}
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
1
common/.gitignore
vendored
Normal file
1
common/.gitignore
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
/build
|
46
common/build.gradle.kts
Normal file
46
common/build.gradle.kts
Normal file
|
@ -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)
|
||||
}
|
0
common/consumer-rules.pro
Normal file
0
common/consumer-rules.pro
Normal file
21
common/proguard-rules.pro
vendored
Normal file
21
common/proguard-rules.pro
vendored
Normal file
|
@ -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
|
|
@ -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)
|
||||
}
|
||||
}
|
4
common/src/main/AndroidManifest.xml
Normal file
4
common/src/main/AndroidManifest.xml
Normal file
|
@ -0,0 +1,4 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest package="com.wbrawner.nanoflux.common">
|
||||
|
||||
</manifest>
|
|
@ -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
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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"]
|
||||
|
||||
|
||||
|
|
2
gradle/wrapper/gradle-wrapper.properties
vendored
2
gradle/wrapper/gradle-wrapper.properties
vendored
|
@ -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
|
||||
|
|
0
gradlew
vendored
Normal file → Executable file
0
gradlew
vendored
Normal file → Executable file
1
network/.gitignore
vendored
Normal file
1
network/.gitignore
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
/build
|
49
network/build.gradle.kts
Normal file
49
network/build.gradle.kts
Normal file
|
@ -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)
|
||||
}
|
0
network/consumer-rules.pro
Normal file
0
network/consumer-rules.pro
Normal file
21
network/proguard-rules.pro
vendored
Normal file
21
network/proguard-rules.pro
vendored
Normal file
|
@ -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
|
|
@ -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)
|
||||
}
|
||||
}
|
4
network/src/main/AndroidManifest.xml
Normal file
4
network/src/main/AndroidManifest.xml
Normal file
|
@ -0,0 +1,4 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest package="com.wbrawner.nanoflux.network">
|
||||
|
||||
</manifest>
|
|
@ -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<FeedJson>
|
||||
|
||||
suspend fun getCategoryFeeds(categoryId: Long): List<FeedJson>
|
||||
|
||||
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<Entry.Status>? = 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<Entry.Status>? = 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<Long>, status: Entry.Status): HttpResponse
|
||||
|
||||
suspend fun toggleEntryBookmark(id: Long): HttpResponse
|
||||
|
||||
suspend fun getCategories(): List<Category>
|
||||
|
||||
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<User>
|
||||
|
||||
//@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<Entry>)
|
||||
|
||||
@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<String?> = 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<String, Any?>
|
||||
): 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<FeedJson> = client.get(url("feeds")) {
|
||||
headers {
|
||||
header("Authorization", sharedPreferences.getString(PREF_KEY_AUTH_TOKEN, "").toString())
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getCategoryFeeds(categoryId: Long): List<FeedJson> =
|
||||
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<Entry.Status>?,
|
||||
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<Entry.Status>?,
|
||||
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<Long>, 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<Category> = 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<User> {
|
||||
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")
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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<Category> {
|
||||
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 }
|
||||
}
|
|
@ -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<EntryAndFeed> {
|
||||
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<EntryAndFeed> {
|
||||
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()
|
||||
}
|
||||
}
|
|
@ -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<FeedCategoryIcon> {
|
||||
if (fetch) {
|
||||
feedDao.insertAll(apiService.getFeeds().map { it.asFeed() })
|
||||
}
|
||||
return feedDao.getAll()
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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<User> = MutableSharedFlow(replay = 1)
|
||||
private val _currentUser: MutableSharedFlow<com.wbrawner.nanoflux.storage.model.User> = 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) {
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -8,3 +8,6 @@ dependencyResolutionManagement {
|
|||
}
|
||||
rootProject.name = "Nanoflux"
|
||||
include(":app")
|
||||
include(":storage")
|
||||
include(":network")
|
||||
include(":common")
|
||||
|
|
1
storage/.gitignore
vendored
Normal file
1
storage/.gitignore
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
/build
|
62
storage/build.gradle.kts
Normal file
62
storage/build.gradle.kts
Normal file
|
@ -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)
|
||||
}
|
0
storage/consumer-rules.pro
Normal file
0
storage/consumer-rules.pro
Normal file
40
storage/proguard-rules.pro
vendored
Normal file
40
storage/proguard-rules.pro
vendored
Normal file
|
@ -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(...);
|
||||
}
|
|
@ -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')"
|
||||
]
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
4
storage/src/main/AndroidManifest.xml
Normal file
4
storage/src/main/AndroidManifest.xml
Normal file
|
@ -0,0 +1,4 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest package="com.wbrawner.nanoflux.storage">
|
||||
|
||||
</manifest>
|
|
@ -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
|
||||
}
|
|
@ -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
|
|
@ -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<Category>
|
||||
|
||||
@Query("SELECT * FROM Category WHERE id in (:ids)")
|
||||
fun getAllByIds(vararg ids: Long): List<Category>
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
fun insertAll(vararg categories: Category)
|
||||
|
||||
@Delete
|
||||
fun delete(category: Category)
|
||||
}
|
|
@ -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<EntryAndFeed>
|
||||
|
||||
@Transaction
|
||||
@Query("SELECT * FROM Entry WHERE status = \"UNREAD\"")
|
||||
fun getAllUnread(): List<EntryAndFeed>
|
||||
|
||||
@Transaction
|
||||
@Query("SELECT * FROM Entry WHERE id in (:ids)")
|
||||
fun getAllByIds(vararg ids: Long): List<EntryAndFeed>
|
||||
|
||||
@Transaction
|
||||
@Query("SELECT * FROM Entry WHERE feedId in (:ids)")
|
||||
fun getAllByFeedIds(vararg ids: Long): List<EntryAndFeed>
|
||||
|
||||
@Transaction
|
||||
@Query("SELECT * FROM Entry WHERE status = \"UNREAD\" AND feedId in (:ids)")
|
||||
fun getAllUnreadByFeedIds(vararg ids: Long): List<EntryAndFeed>
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
fun insertAll(entries: List<Entry>)
|
||||
|
||||
@Delete
|
||||
fun delete(entry: Entry)
|
||||
}
|
|
@ -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<List<FeedCategoryIcon>>
|
||||
fun getAll(): List<FeedCategoryIcon>
|
||||
|
||||
@Transaction
|
||||
@Query("SELECT * FROM Feed WHERE id in (:ids)")
|
||||
fun getAllByIds(vararg ids: Long): List<FeedCategoryIcon>
|
||||
|
||||
@Insert
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
fun insertAll(feeds: List<Feed>)
|
||||
|
||||
@Delete
|
|
@ -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<List<Feed.Icon>>
|
||||
|
||||
@Query("SELECT * FROM Icon WHERE id in (:ids)")
|
||||
fun getAllByIds(vararg ids: Long): List<Feed.Icon>
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
fun insertAll(vararg icons: Feed.Icon)
|
||||
|
||||
@Delete
|
||||
fun delete(icon: Feed.Icon)
|
||||
}
|
|
@ -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 {
|
|
@ -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,
|
||||
)
|
|
@ -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<Date> {
|
||||
val dateFormat = object : ThreadLocal<SimpleDateFormat>() {
|
||||
override fun initialValue(): SimpleDateFormat =
|
||||
SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSSSSZ", Locale.ENGLISH)
|
||||
}
|
||||
val altDateFormat = object : ThreadLocal<SimpleDateFormat>() {
|
||||
override fun initialValue(): SimpleDateFormat =
|
||||
SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSSSS'Z'", Locale.ENGLISH)
|
||||
}
|
||||
val altDateFormat2 = object : ThreadLocal<SimpleDateFormat>() {
|
||||
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)!!
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
)
|
||||
}
|
|
@ -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
|
||||
)
|
|
@ -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)
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue