Reorganize code into modules and move from square networking to ktor

This commit is contained in:
William Brawner 2021-06-02 05:21:14 -06:00
parent c165cbfd29
commit 7e243f1853
68 changed files with 1819 additions and 681 deletions

View file

@ -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" />

View file

@ -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>

View file

@ -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)

View file

@ -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
) {
}

View file

@ -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()
}

View file

@ -1,16 +1,14 @@
package com.wbrawner.nanoflux
import android.app.Service
import android.content.Context
import android.content.Intent
import android.os.IBinder
import androidx.hilt.work.HiltWorker
import androidx.work.Data
import androidx.work.Worker
import androidx.work.WorkerParameters
import com.wbrawner.nanoflux.data.MinifluxApiService
import com.wbrawner.nanoflux.data.repository.EntryRepository
import com.wbrawner.nanoflux.data.repository.FeedRepository
import com.wbrawner.nanoflux.data.repository.UserRepository
import com.wbrawner.nanoflux.network.repository.CategoryRepository
import com.wbrawner.nanoflux.network.repository.EntryRepository
import com.wbrawner.nanoflux.network.repository.FeedRepository
import com.wbrawner.nanoflux.network.repository.IconRepository
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import kotlinx.coroutines.runBlocking
@ -21,16 +19,32 @@ class SyncWorker @AssistedInject constructor(
@Assisted context: Context,
@Assisted workerParams: WorkerParameters
) : Worker(context, workerParams) {
@Inject
lateinit var categoryRepository: CategoryRepository
@Inject
lateinit var entryRepository: EntryRepository
@Inject
lateinit var feedRepository: FeedRepository
@Inject
lateinit var iconRepository: IconRepository
override fun doWork(): Result {
runBlocking {
feedRepository.getAll()
entryRepository.getAll()
return runBlocking {
try {
categoryRepository.getAll(true)
feedRepository.getAll(true).forEach {
if (it.feed.iconId != null) {
iconRepository.getFeedIcon(it.feed.id)
}
}
entryRepository.getAll(true)
Result.success()
} catch (e: Exception) {
Result.failure(Data.Builder().putString("message", e.message?: "Unknown failure").build())
}
}
return Result.success()
}
}

View file

@ -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>
}

View file

@ -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
}

View file

@ -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)
}

View file

@ -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)
}

View file

@ -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)
}

View file

@ -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,
)

View file

@ -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
}
}

View file

@ -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
)

View file

@ -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,
)

View file

@ -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
}
}

View file

@ -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
}
}

View file

@ -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 = "",

View file

@ -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()
}
}

View file

@ -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)
}

File diff suppressed because one or more lines are too long

View file

@ -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, {}, {}, {}, {}, {}, {})
}
}

View file

@ -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
View file

@ -0,0 +1 @@
/build

46
common/build.gradle.kts Normal file
View 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)
}

View file

21
common/proguard-rules.pro vendored Normal file
View 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

View file

@ -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)
}
}

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest package="com.wbrawner.nanoflux.common">
</manifest>

View file

@ -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

View file

@ -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)
}
}

View file

@ -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"]

View file

@ -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
View file

1
network/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/build

49
network/build.gradle.kts Normal file
View 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)
}

View file

21
network/proguard-rules.pro vendored Normal file
View 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

View file

@ -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)
}
}

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest package="com.wbrawner.nanoflux.network">
</manifest>

View file

@ -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")
}
}

View file

@ -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)
}

View file

@ -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 }
}

View file

@ -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()
}
}

View file

@ -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()
}
}

View file

@ -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
}
}

View file

@ -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) {

View file

@ -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)
}
}

View file

@ -8,3 +8,6 @@ dependencyResolutionManagement {
}
rootProject.name = "Nanoflux"
include(":app")
include(":storage")
include(":network")
include(":common")

1
storage/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/build

62
storage/build.gradle.kts Normal file
View 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)
}

View file

40
storage/proguard-rules.pro vendored Normal file
View 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(...);
}

View file

@ -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')"
]
}
}

View file

@ -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)
}
}

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest package="com.wbrawner.nanoflux.storage">
</manifest>

View file

@ -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
}

View file

@ -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

View file

@ -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)
}

View file

@ -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)
}

View file

@ -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

View file

@ -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)
}

View file

@ -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 {

View file

@ -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,
)

View file

@ -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)!!
}
}
}
}

View file

@ -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
)
}

View file

@ -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
)

View file

@ -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)
}
}