chore: setup project
@@ -0,0 +1,24 @@
|
||||
package dev.achmad.ledgerr
|
||||
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
import org.junit.Assert.*
|
||||
|
||||
/**
|
||||
* 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("dev.achmad.ledgerr", appContext.packageName)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
android:fullBackupContent="@xml/backup_rules"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.Ledgerr">
|
||||
<activity
|
||||
android:name=".ui.base.MainActivity"
|
||||
android:exported="true"
|
||||
android:label="@string/app_name"
|
||||
android:theme="@style/Theme.Ledgerr"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
@@ -0,0 +1,54 @@
|
||||
package dev.achmad.ledgerr.core.network
|
||||
|
||||
import android.webkit.CookieManager
|
||||
import okhttp3.Cookie
|
||||
import okhttp3.CookieJar
|
||||
import okhttp3.HttpUrl
|
||||
|
||||
class AndroidCookieJar : CookieJar {
|
||||
|
||||
private val manager = CookieManager.getInstance()
|
||||
|
||||
override fun saveFromResponse(url: HttpUrl, cookies: List<Cookie>) {
|
||||
val urlString = url.toString()
|
||||
|
||||
cookies.forEach { manager.setCookie(urlString, it.toString()) }
|
||||
}
|
||||
|
||||
override fun loadForRequest(url: HttpUrl): List<Cookie> {
|
||||
return get(url)
|
||||
}
|
||||
|
||||
fun get(url: HttpUrl): List<Cookie> {
|
||||
val cookies = manager.getCookie(url.toString())
|
||||
|
||||
return if (cookies != null && cookies.isNotEmpty()) {
|
||||
cookies.split(";").mapNotNull { Cookie.parse(url, it) }
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
fun remove(url: HttpUrl, cookieNames: List<String>? = null, maxAge: Int = -1): Int {
|
||||
val urlString = url.toString()
|
||||
val cookies = manager.getCookie(urlString) ?: return 0
|
||||
|
||||
fun List<String>.filterNames(): List<String> {
|
||||
return if (cookieNames != null) {
|
||||
this.filter { it in cookieNames }
|
||||
} else {
|
||||
this
|
||||
}
|
||||
}
|
||||
|
||||
return cookies.split(";")
|
||||
.map { it.substringBefore("=") }
|
||||
.filterNames()
|
||||
.onEach { manager.setCookie(urlString, "$it=;Max-Age=$maxAge") }
|
||||
.count()
|
||||
}
|
||||
|
||||
fun removeAll() {
|
||||
manager.removeAllCookies {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package dev.achmad.ledgerr.core.network
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.NetworkCapabilities
|
||||
import dev.achmad.ledgerr.core.network.interceptor.IgnoreGzipInterceptor
|
||||
import dev.achmad.ledgerr.core.network.interceptor.UncaughtExceptionInterceptor
|
||||
import okhttp3.Cache
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.brotli.BrotliInterceptor
|
||||
import okhttp3.logging.HttpLoggingInterceptor
|
||||
import java.io.File
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
class NetworkHelper(
|
||||
private val context: Context,
|
||||
private val isDebugBuild: Boolean,
|
||||
) {
|
||||
|
||||
private val connectivityManager = context
|
||||
.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||
|
||||
val client: OkHttpClient = run {
|
||||
val builder = OkHttpClient.Builder()
|
||||
.connectTimeout(30, TimeUnit.SECONDS)
|
||||
.readTimeout(30, TimeUnit.SECONDS)
|
||||
.callTimeout(2, TimeUnit.MINUTES)
|
||||
.cache(
|
||||
Cache(
|
||||
directory = File(context.cacheDir, "network_cache"),
|
||||
maxSize = 5L * 1024 * 1024, // 5 MiB
|
||||
),
|
||||
)
|
||||
.addInterceptor(UncaughtExceptionInterceptor())
|
||||
.addNetworkInterceptor(IgnoreGzipInterceptor())
|
||||
.addNetworkInterceptor(BrotliInterceptor)
|
||||
|
||||
if (isDebugBuild) {
|
||||
val httpLoggingInterceptor = HttpLoggingInterceptor().apply {
|
||||
level = HttpLoggingInterceptor.Level.BODY
|
||||
}
|
||||
builder.addNetworkInterceptor(httpLoggingInterceptor)
|
||||
}
|
||||
|
||||
builder.build()
|
||||
}
|
||||
|
||||
fun isNetworkAvailable(): Boolean {
|
||||
val network = connectivityManager.activeNetwork ?: return false
|
||||
val capabilities = connectivityManager.getNetworkCapabilities(network) ?: return false
|
||||
return capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package dev.achmad.ledgerr.core.network
|
||||
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlinx.serialization.DeserializationStrategy
|
||||
import kotlinx.serialization.ExperimentalSerializationApi
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.okio.decodeFromBufferedSource
|
||||
import kotlinx.serialization.serializer
|
||||
import okhttp3.Call
|
||||
import okhttp3.Callback
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import java.io.IOException
|
||||
import kotlin.coroutines.resumeWithException
|
||||
|
||||
val jsonMime = "application/json; charset=utf-8".toMediaType()
|
||||
val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
// Based on https://github.com/gildor/kotlin-coroutines-okhttp
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
private suspend fun Call.await(callStack: Array<StackTraceElement>): Response {
|
||||
return suspendCancellableCoroutine { continuation ->
|
||||
val callback =
|
||||
object : Callback {
|
||||
override fun onResponse(call: Call, response: Response) {
|
||||
continuation.resume(response) { _, _, _ ->
|
||||
response.body.close()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onFailure(call: Call, e: IOException) {
|
||||
// Don't bother with resuming the continuation if it is already cancelled.
|
||||
if (continuation.isCancelled) return
|
||||
val exception = IOException(e.message, e).apply { stackTrace = callStack }
|
||||
continuation.resumeWithException(exception)
|
||||
}
|
||||
}
|
||||
|
||||
enqueue(callback)
|
||||
|
||||
continuation.invokeOnCancellation {
|
||||
try {
|
||||
cancel()
|
||||
} catch (ex: Throwable) {
|
||||
// Ignore cancel exception
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun Call.await(): Response {
|
||||
val callStack = Exception().stackTrace.run { copyOfRange(1, size) }
|
||||
return await(callStack)
|
||||
}
|
||||
|
||||
/**
|
||||
* @since extensions-lib 1.5
|
||||
*/
|
||||
suspend fun Call.awaitSuccess(): Response {
|
||||
val callStack = Exception().stackTrace.run { copyOfRange(1, size) }
|
||||
val response = await(callStack)
|
||||
if (!response.isSuccessful) {
|
||||
response.close()
|
||||
throw HttpException(response.code).apply { stackTrace = callStack }
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
fun OkHttpClient.newCachelessCallWithProgress(request: Request, listener: ProgressListener): Call {
|
||||
val progressClient = newBuilder()
|
||||
.cache(null)
|
||||
.addNetworkInterceptor { chain ->
|
||||
val originalResponse = chain.proceed(chain.request())
|
||||
originalResponse.newBuilder()
|
||||
.body(ProgressResponseBody(originalResponse.body, listener))
|
||||
.build()
|
||||
}
|
||||
.build()
|
||||
|
||||
return progressClient.newCall(request)
|
||||
}
|
||||
|
||||
context(_: Json)
|
||||
inline fun <reified T> Response.parseAs(): T {
|
||||
return decodeFromJsonResponse(serializer(), this)
|
||||
}
|
||||
|
||||
context(json: Json)
|
||||
fun <T> decodeFromJsonResponse(
|
||||
deserializer: DeserializationStrategy<T>,
|
||||
response: Response,
|
||||
): T {
|
||||
return response.body.source().use {
|
||||
json.decodeFromBufferedSource(deserializer, it)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Exception that handles HTTP codes considered not successful by OkHttp.
|
||||
* Use it to have a standardized error message in the app across the extensions.
|
||||
*
|
||||
* @since extensions-lib 1.5
|
||||
* @param code [Int] the HTTP status code
|
||||
*/
|
||||
class HttpException(val code: Int) : IllegalStateException("HTTP error $code")
|
||||
@@ -0,0 +1,5 @@
|
||||
package dev.achmad.ledgerr.core.network
|
||||
|
||||
interface ProgressListener {
|
||||
fun update(bytesRead: Long, contentLength: Long, done: Boolean)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package dev.achmad.ledgerr.core.network
|
||||
|
||||
import okhttp3.MediaType
|
||||
import okhttp3.ResponseBody
|
||||
import okio.Buffer
|
||||
import okio.BufferedSource
|
||||
import okio.ForwardingSource
|
||||
import okio.Source
|
||||
import okio.buffer
|
||||
import java.io.IOException
|
||||
|
||||
class ProgressResponseBody(
|
||||
private val responseBody: ResponseBody,
|
||||
private val progressListener: ProgressListener,
|
||||
) : ResponseBody() {
|
||||
|
||||
private val bufferedSource: BufferedSource by lazy {
|
||||
source(responseBody.source()).buffer()
|
||||
}
|
||||
|
||||
override fun contentType(): MediaType? {
|
||||
return responseBody.contentType()
|
||||
}
|
||||
|
||||
override fun contentLength(): Long {
|
||||
return responseBody.contentLength()
|
||||
}
|
||||
|
||||
override fun source(): BufferedSource {
|
||||
return bufferedSource
|
||||
}
|
||||
|
||||
private fun source(source: Source): Source {
|
||||
return object : ForwardingSource(source) {
|
||||
var totalBytesRead = 0L
|
||||
|
||||
@Throws(IOException::class)
|
||||
override fun read(sink: Buffer, byteCount: Long): Long {
|
||||
val bytesRead = super.read(sink, byteCount)
|
||||
// read() returns the number of bytes read, or -1 if this source is exhausted.
|
||||
totalBytesRead += if (bytesRead != -1L) bytesRead else 0
|
||||
progressListener.update(
|
||||
totalBytesRead,
|
||||
responseBody.contentLength(),
|
||||
bytesRead == -1L,
|
||||
)
|
||||
return bytesRead
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package dev.achmad.ledgerr.core.network
|
||||
|
||||
import okhttp3.CacheControl
|
||||
import okhttp3.FormBody
|
||||
import okhttp3.Headers
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody
|
||||
import java.util.concurrent.TimeUnit.MINUTES
|
||||
|
||||
private val DEFAULT_CACHE_CONTROL = CacheControl.Builder().maxAge(10, MINUTES).build()
|
||||
private val DEFAULT_HEADERS = Headers.Builder().build()
|
||||
private val DEFAULT_BODY: RequestBody = FormBody.Builder().build()
|
||||
|
||||
fun GET(
|
||||
url: String,
|
||||
headers: Headers = DEFAULT_HEADERS,
|
||||
cache: CacheControl = DEFAULT_CACHE_CONTROL,
|
||||
): Request {
|
||||
return GET(url.toHttpUrl(), headers, cache)
|
||||
}
|
||||
|
||||
/**
|
||||
* @since extensions-lib 1.4
|
||||
*/
|
||||
fun GET(
|
||||
url: HttpUrl,
|
||||
headers: Headers = DEFAULT_HEADERS,
|
||||
cache: CacheControl = DEFAULT_CACHE_CONTROL,
|
||||
): Request {
|
||||
return Request.Builder()
|
||||
.url(url)
|
||||
.headers(headers)
|
||||
.cacheControl(cache)
|
||||
.build()
|
||||
}
|
||||
|
||||
fun POST(
|
||||
url: String,
|
||||
headers: Headers = DEFAULT_HEADERS,
|
||||
body: RequestBody = DEFAULT_BODY,
|
||||
cache: CacheControl = DEFAULT_CACHE_CONTROL,
|
||||
): Request {
|
||||
return Request.Builder()
|
||||
.url(url)
|
||||
.post(body)
|
||||
.headers(headers)
|
||||
.cacheControl(cache)
|
||||
.build()
|
||||
}
|
||||
|
||||
fun PUT(
|
||||
url: String,
|
||||
headers: Headers = DEFAULT_HEADERS,
|
||||
body: RequestBody = DEFAULT_BODY,
|
||||
cache: CacheControl = DEFAULT_CACHE_CONTROL,
|
||||
): Request {
|
||||
return Request.Builder()
|
||||
.url(url)
|
||||
.put(body)
|
||||
.headers(headers)
|
||||
.cacheControl(cache)
|
||||
.build()
|
||||
}
|
||||
fun PATCH(
|
||||
url: String,
|
||||
headers: Headers = DEFAULT_HEADERS,
|
||||
body: RequestBody = DEFAULT_BODY,
|
||||
cache: CacheControl = DEFAULT_CACHE_CONTROL,
|
||||
): Request {
|
||||
return Request.Builder()
|
||||
.url(url)
|
||||
.patch(body)
|
||||
.headers(headers)
|
||||
.cacheControl(cache)
|
||||
.build()
|
||||
}
|
||||
|
||||
fun DELETE(
|
||||
url: String,
|
||||
headers: Headers = DEFAULT_HEADERS,
|
||||
body: RequestBody = DEFAULT_BODY,
|
||||
cache: CacheControl = DEFAULT_CACHE_CONTROL,
|
||||
): Request {
|
||||
return Request.Builder()
|
||||
.url(url)
|
||||
.delete(body)
|
||||
.headers(headers)
|
||||
.cacheControl(cache)
|
||||
.build()
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package dev.achmad.ledgerr.core.network.interceptor
|
||||
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Response
|
||||
|
||||
/**
|
||||
* To use [okhttp3.brotli.BrotliInterceptor] as a network interceptor,
|
||||
* add [IgnoreGzipInterceptor] right before it.
|
||||
*
|
||||
* This nullifies the transparent gzip of [okhttp3.internal.http.BridgeInterceptor]
|
||||
* so gzip and Brotli are explicitly handled by the [okhttp3.brotli.BrotliInterceptor].
|
||||
*/
|
||||
class IgnoreGzipInterceptor : Interceptor {
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
var request = chain.request()
|
||||
if (request.header("Accept-Encoding")?.contains("gzip") == true) {
|
||||
request = request.newBuilder().removeHeader("Accept-Encoding").build()
|
||||
}
|
||||
return chain.proceed(request)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package dev.achmad.ledgerr.core.network.interceptor
|
||||
|
||||
import android.os.SystemClock
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Response
|
||||
import java.io.IOException
|
||||
import java.util.ArrayDeque
|
||||
import java.util.concurrent.Semaphore
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
import kotlin.time.toDuration
|
||||
import kotlin.time.toDurationUnit
|
||||
|
||||
/**
|
||||
* An OkHttp interceptor that handles rate limiting.
|
||||
*
|
||||
* This uses `java.time` APIs and is the legacy method, kept
|
||||
* for compatibility reasons with existing extensions.
|
||||
*
|
||||
* Examples:
|
||||
*
|
||||
* permits = 5, period = 1, unit = seconds => 5 requests per second
|
||||
* permits = 10, period = 2, unit = minutes => 10 requests per 2 minutes
|
||||
*
|
||||
* @since extension-lib 1.3
|
||||
*
|
||||
* @param permits [Int] Number of requests allowed within a period of units.
|
||||
* @param period [Long] The limiting duration. Defaults to 1.
|
||||
* @param unit [TimeUnit] The unit of time for the period. Defaults to seconds.
|
||||
*/
|
||||
@Deprecated("Use the version with kotlin.time APIs instead.")
|
||||
fun OkHttpClient.Builder.rateLimit(
|
||||
permits: Int,
|
||||
period: Long = 1,
|
||||
unit: TimeUnit = TimeUnit.SECONDS,
|
||||
) = addInterceptor(RateLimitInterceptor(null, permits, period.toDuration(unit.toDurationUnit())))
|
||||
|
||||
/**
|
||||
* An OkHttp interceptor that handles rate limiting.
|
||||
*
|
||||
* Examples:
|
||||
*
|
||||
* permits = 5, period = 1.seconds => 5 requests per second
|
||||
* permits = 10, period = 2.minutes => 10 requests per 2 minutes
|
||||
*
|
||||
* @since extension-lib 1.5
|
||||
*
|
||||
* @param permits [Int] Number of requests allowed within a period of units.
|
||||
* @param period [Duration] The limiting duration. Defaults to 1.seconds.
|
||||
*/
|
||||
fun OkHttpClient.Builder.rateLimit(permits: Int, period: Duration = 1.seconds) =
|
||||
addInterceptor(RateLimitInterceptor(null, permits, period))
|
||||
|
||||
/** We can probably accept domains or wildcards by comparing with [endsWith], etc. */
|
||||
@Suppress("PLATFORM_CLASS_MAPPED_TO_KOTLIN")
|
||||
internal class RateLimitInterceptor(
|
||||
private val host: String?,
|
||||
private val permits: Int,
|
||||
period: Duration,
|
||||
) : Interceptor {
|
||||
|
||||
private val requestQueue = ArrayDeque<Long>(permits)
|
||||
private val rateLimitMillis = period.inWholeMilliseconds
|
||||
private val fairLock = Semaphore(1, true)
|
||||
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val call = chain.call()
|
||||
if (call.isCanceled()) throw IOException("Canceled")
|
||||
|
||||
val request = chain.request()
|
||||
when (host) {
|
||||
null, request.url.host -> {} // need rate limit
|
||||
else -> return chain.proceed(request)
|
||||
}
|
||||
|
||||
try {
|
||||
fairLock.acquire()
|
||||
} catch (e: InterruptedException) {
|
||||
throw IOException(e)
|
||||
}
|
||||
|
||||
val requestQueue = this.requestQueue
|
||||
val timestamp: Long
|
||||
|
||||
try {
|
||||
synchronized(requestQueue) {
|
||||
while (requestQueue.size >= permits) { // queue is full, remove expired entries
|
||||
val periodStart = SystemClock.elapsedRealtime() - rateLimitMillis
|
||||
var hasRemovedExpired = false
|
||||
while (!requestQueue.isEmpty() && requestQueue.first <= periodStart) {
|
||||
requestQueue.removeFirst()
|
||||
hasRemovedExpired = true
|
||||
}
|
||||
if (call.isCanceled()) {
|
||||
throw IOException("Canceled")
|
||||
} else if (hasRemovedExpired) {
|
||||
break
|
||||
} else {
|
||||
try { // wait for the first entry to expire, or notified by cached response
|
||||
(requestQueue as Object).wait(requestQueue.first - periodStart)
|
||||
} catch (_: InterruptedException) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// add request to queue
|
||||
timestamp = SystemClock.elapsedRealtime()
|
||||
requestQueue.addLast(timestamp)
|
||||
}
|
||||
} finally {
|
||||
fairLock.release()
|
||||
}
|
||||
|
||||
val response = chain.proceed(request)
|
||||
if (response.networkResponse == null) { // response is cached, remove it from queue
|
||||
synchronized(requestQueue) {
|
||||
if (requestQueue.isEmpty() || timestamp < requestQueue.first) return@synchronized
|
||||
requestQueue.removeFirstOccurrence(timestamp)
|
||||
(requestQueue as Object).notifyAll()
|
||||
}
|
||||
}
|
||||
|
||||
return response
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package dev.achmad.ledgerr.core.network.interceptor
|
||||
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import okhttp3.OkHttpClient
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
import kotlin.time.toDuration
|
||||
import kotlin.time.toDurationUnit
|
||||
|
||||
/**
|
||||
* An OkHttp interceptor that handles given url host's rate limiting.
|
||||
*
|
||||
* This uses Java Time APIs and is the legacy method, kept
|
||||
* for compatibility reasons with existing extensions.
|
||||
*
|
||||
* Examples:
|
||||
*
|
||||
* httpUrl = "api.manga.com".toHttpUrlOrNull(), permits = 5, period = 1, unit = seconds => 5 requests per second to api.manga.com
|
||||
* httpUrl = "imagecdn.manga.com".toHttpUrlOrNull(), permits = 10, period = 2, unit = minutes => 10 requests per 2 minutes to imagecdn.manga.com
|
||||
*
|
||||
* @since extension-lib 1.3
|
||||
*
|
||||
* @param httpUrl [HttpUrl] The url host that this interceptor should handle. Will get url's host by using HttpUrl.host()
|
||||
* @param permits [Int] Number of requests allowed within a period of units.
|
||||
* @param period [Long] The limiting duration. Defaults to 1.
|
||||
* @param unit [TimeUnit] The unit of time for the period. Defaults to seconds.
|
||||
*/
|
||||
@Deprecated("Use the version with kotlin.time APIs instead.")
|
||||
fun OkHttpClient.Builder.rateLimitHost(
|
||||
httpUrl: HttpUrl,
|
||||
permits: Int,
|
||||
period: Long = 1,
|
||||
unit: TimeUnit = TimeUnit.SECONDS,
|
||||
) = addInterceptor(
|
||||
RateLimitInterceptor(httpUrl.host, permits, period.toDuration(unit.toDurationUnit())),
|
||||
)
|
||||
|
||||
/**
|
||||
* An OkHttp interceptor that handles given url host's rate limiting.
|
||||
*
|
||||
* Examples:
|
||||
*
|
||||
* httpUrl = "https://api.manga.com".toHttpUrlOrNull(), permits = 5, period = 1.seconds => 5 requests per second to api.manga.com
|
||||
* httpUrl = "https://imagecdn.manga.com".toHttpUrlOrNull(), permits = 10, period = 2.minutes => 10 requests per 2 minutes to imagecdn.manga.com
|
||||
*
|
||||
* @since extension-lib 1.5
|
||||
*
|
||||
* @param httpUrl [HttpUrl] The url host that this interceptor should handle. Will get url's host by using HttpUrl.host()
|
||||
* @param permits [Int] Number of requests allowed within a period of units.
|
||||
* @param period [Duration] The limiting duration. Defaults to 1.seconds.
|
||||
*/
|
||||
@Suppress("UNUSED")
|
||||
fun OkHttpClient.Builder.rateLimitHost(
|
||||
httpUrl: HttpUrl,
|
||||
permits: Int,
|
||||
period: Duration = 1.seconds,
|
||||
) = addInterceptor(RateLimitInterceptor(httpUrl.host, permits, period))
|
||||
|
||||
/**
|
||||
* An OkHttp interceptor that handles given url host's rate limiting.
|
||||
*
|
||||
* Examples:
|
||||
*
|
||||
* url = "https://api.manga.com", permits = 5, period = 1.seconds => 5 requests per second to api.manga.com
|
||||
* url = "https://imagecdn.manga.com", permits = 10, period = 2.minutes => 10 requests per 2 minutes to imagecdn.manga.com
|
||||
*
|
||||
* @since extension-lib 1.5
|
||||
*
|
||||
* @param url [String] The url host that this interceptor should handle. Will get url's host by using HttpUrl.host()
|
||||
* @param permits [Int] Number of requests allowed within a period of units.
|
||||
* @param period [Duration] The limiting duration. Defaults to 1.seconds.
|
||||
*/
|
||||
@Suppress("UNUSED")
|
||||
fun OkHttpClient.Builder.rateLimitHost(url: String, permits: Int, period: Duration = 1.seconds) =
|
||||
addInterceptor(RateLimitInterceptor(url.toHttpUrlOrNull()?.host, permits, period))
|
||||
@@ -0,0 +1,28 @@
|
||||
package dev.achmad.ledgerr.core.network.interceptor
|
||||
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Response
|
||||
import java.io.IOException
|
||||
|
||||
/**
|
||||
* Catches any uncaught exceptions from later in the chain and rethrows as a non-fatal
|
||||
* IOException to avoid catastrophic failure.
|
||||
*
|
||||
* This should be the first interceptor in the client.
|
||||
*
|
||||
* See https://square.github.io/okhttp/4.x/okhttp/okhttp3/-interceptor/
|
||||
*/
|
||||
class UncaughtExceptionInterceptor : Interceptor {
|
||||
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
return try {
|
||||
chain.proceed(chain.request())
|
||||
} catch (e: Exception) {
|
||||
if (e is IOException) {
|
||||
throw e
|
||||
} else {
|
||||
throw IOException(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package dev.achmad.ledgerr.core.network.interceptor
|
||||
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Response
|
||||
|
||||
class UserAgentInterceptor(
|
||||
private val defaultUserAgentProvider: () -> String,
|
||||
) : Interceptor {
|
||||
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val originalRequest = chain.request()
|
||||
|
||||
return if (originalRequest.header("User-Agent").isNullOrEmpty()) {
|
||||
val newRequest = originalRequest
|
||||
.newBuilder()
|
||||
.removeHeader("User-Agent")
|
||||
.addHeader("User-Agent", defaultUserAgentProvider())
|
||||
.build()
|
||||
chain.proceed(newRequest)
|
||||
} else {
|
||||
chain.proceed(originalRequest)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
package dev.achmad.ledgerr.core.preference
|
||||
|
||||
import android.content.SharedPreferences
|
||||
import android.content.SharedPreferences.Editor
|
||||
import android.util.Log
|
||||
import androidx.core.content.edit
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.conflate
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
|
||||
sealed class AndroidPreference<T>(
|
||||
private val preferences: SharedPreferences,
|
||||
private val keyFlow: Flow<String?>,
|
||||
private val key: String,
|
||||
private val defaultValue: T,
|
||||
) : Preference<T> {
|
||||
|
||||
abstract fun read(preferences: SharedPreferences, key: String, defaultValue: T): T
|
||||
|
||||
abstract fun write(key: String, value: T): Editor.() -> Unit
|
||||
|
||||
override fun key(): String {
|
||||
return key
|
||||
}
|
||||
|
||||
override fun get(): T {
|
||||
return try {
|
||||
read(preferences, key, defaultValue)
|
||||
} catch (e: ClassCastException) {
|
||||
Log.e("AndroidPreference", "Invalid value for $key; deleting")
|
||||
delete()
|
||||
defaultValue
|
||||
}
|
||||
}
|
||||
|
||||
override fun set(value: T) {
|
||||
preferences.edit(action = write(key, value))
|
||||
}
|
||||
|
||||
override fun isSet(): Boolean {
|
||||
return preferences.contains(key)
|
||||
}
|
||||
|
||||
override fun delete() {
|
||||
preferences.edit {
|
||||
remove(key)
|
||||
}
|
||||
}
|
||||
|
||||
override fun defaultValue(): T {
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
override fun changes(): Flow<T> {
|
||||
return keyFlow
|
||||
.filter { it == key || it == null }
|
||||
.onStart { emit("ignition") }
|
||||
.map { get() }
|
||||
.conflate()
|
||||
}
|
||||
|
||||
override fun stateIn(scope: CoroutineScope): StateFlow<T> {
|
||||
return changes().stateIn(scope, SharingStarted.Eagerly, get())
|
||||
}
|
||||
|
||||
class StringPrimitive(
|
||||
preferences: SharedPreferences,
|
||||
keyFlow: Flow<String?>,
|
||||
key: String,
|
||||
defaultValue: String,
|
||||
) : AndroidPreference<String>(preferences, keyFlow, key, defaultValue) {
|
||||
override fun read(
|
||||
preferences: SharedPreferences,
|
||||
key: String,
|
||||
defaultValue: String,
|
||||
): String {
|
||||
return preferences.getString(key, defaultValue) ?: defaultValue
|
||||
}
|
||||
|
||||
override fun write(key: String, value: String): Editor.() -> Unit = {
|
||||
putString(key, value)
|
||||
}
|
||||
}
|
||||
|
||||
class LongPrimitive(
|
||||
preferences: SharedPreferences,
|
||||
keyFlow: Flow<String?>,
|
||||
key: String,
|
||||
defaultValue: Long,
|
||||
) : AndroidPreference<Long>(preferences, keyFlow, key, defaultValue) {
|
||||
override fun read(preferences: SharedPreferences, key: String, defaultValue: Long): Long {
|
||||
return preferences.getLong(key, defaultValue)
|
||||
}
|
||||
|
||||
override fun write(key: String, value: Long): Editor.() -> Unit = {
|
||||
putLong(key, value)
|
||||
}
|
||||
}
|
||||
|
||||
class IntPrimitive(
|
||||
preferences: SharedPreferences,
|
||||
keyFlow: Flow<String?>,
|
||||
key: String,
|
||||
defaultValue: Int,
|
||||
) : AndroidPreference<Int>(preferences, keyFlow, key, defaultValue) {
|
||||
override fun read(preferences: SharedPreferences, key: String, defaultValue: Int): Int {
|
||||
return preferences.getInt(key, defaultValue)
|
||||
}
|
||||
|
||||
override fun write(key: String, value: Int): Editor.() -> Unit = {
|
||||
putInt(key, value)
|
||||
}
|
||||
}
|
||||
|
||||
class FloatPrimitive(
|
||||
preferences: SharedPreferences,
|
||||
keyFlow: Flow<String?>,
|
||||
key: String,
|
||||
defaultValue: Float,
|
||||
) : AndroidPreference<Float>(preferences, keyFlow, key, defaultValue) {
|
||||
override fun read(preferences: SharedPreferences, key: String, defaultValue: Float): Float {
|
||||
return preferences.getFloat(key, defaultValue)
|
||||
}
|
||||
|
||||
override fun write(key: String, value: Float): Editor.() -> Unit = {
|
||||
putFloat(key, value)
|
||||
}
|
||||
}
|
||||
|
||||
class BooleanPrimitive(
|
||||
preferences: SharedPreferences,
|
||||
keyFlow: Flow<String?>,
|
||||
key: String,
|
||||
defaultValue: Boolean,
|
||||
) : AndroidPreference<Boolean>(preferences, keyFlow, key, defaultValue) {
|
||||
override fun read(
|
||||
preferences: SharedPreferences,
|
||||
key: String,
|
||||
defaultValue: Boolean,
|
||||
): Boolean {
|
||||
return preferences.getBoolean(key, defaultValue)
|
||||
}
|
||||
|
||||
override fun write(key: String, value: Boolean): Editor.() -> Unit = {
|
||||
putBoolean(key, value)
|
||||
}
|
||||
}
|
||||
|
||||
class StringSetPrimitive(
|
||||
preferences: SharedPreferences,
|
||||
keyFlow: Flow<String?>,
|
||||
key: String,
|
||||
defaultValue: Set<String>,
|
||||
) : AndroidPreference<Set<String>>(preferences, keyFlow, key, defaultValue) {
|
||||
override fun read(
|
||||
preferences: SharedPreferences,
|
||||
key: String,
|
||||
defaultValue: Set<String>,
|
||||
): Set<String> {
|
||||
return preferences.getStringSet(key, defaultValue) ?: defaultValue
|
||||
}
|
||||
|
||||
override fun write(key: String, value: Set<String>): Editor.() -> Unit = {
|
||||
putStringSet(key, value)
|
||||
}
|
||||
}
|
||||
|
||||
class ObjectAsString<T>(
|
||||
preferences: SharedPreferences,
|
||||
keyFlow: Flow<String?>,
|
||||
key: String,
|
||||
defaultValue: T,
|
||||
private val serializer: (T) -> String,
|
||||
private val deserializer: (String) -> T,
|
||||
) : AndroidPreference<T>(preferences, keyFlow, key, defaultValue) {
|
||||
override fun read(preferences: SharedPreferences, key: String, defaultValue: T): T {
|
||||
return try {
|
||||
preferences.getString(key, null)?.let(deserializer) ?: defaultValue
|
||||
} catch (e: Exception) {
|
||||
defaultValue
|
||||
}
|
||||
}
|
||||
|
||||
override fun write(key: String, value: T): Editor.() -> Unit = {
|
||||
putString(key, serializer(value))
|
||||
}
|
||||
}
|
||||
|
||||
class ObjectAsInt<T>(
|
||||
preferences: SharedPreferences,
|
||||
keyFlow: Flow<String?>,
|
||||
key: String,
|
||||
defaultValue: T,
|
||||
private val serializer: (T) -> Int,
|
||||
private val deserializer: (Int) -> T,
|
||||
) : AndroidPreference<T>(preferences, keyFlow, key, defaultValue) {
|
||||
override fun read(preferences: SharedPreferences, key: String, defaultValue: T): T {
|
||||
return try {
|
||||
if (preferences.contains(key)) preferences.getInt(key, 0).let(deserializer) else defaultValue
|
||||
} catch (e: Exception) {
|
||||
defaultValue
|
||||
}
|
||||
}
|
||||
|
||||
override fun write(key: String, value: T): Editor.() -> Unit = {
|
||||
putInt(key, serializer(value))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package dev.achmad.ledgerr.core.preference
|
||||
|
||||
import android.content.SharedPreferences
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.flow.callbackFlow
|
||||
|
||||
class AndroidPreferenceStore(
|
||||
private val sharedPreferences: SharedPreferences,
|
||||
) : PreferenceStore {
|
||||
|
||||
private val keyFlow = sharedPreferences.keyFlow
|
||||
|
||||
override fun getString(key: String, defaultValue: String): Preference<String> {
|
||||
return AndroidPreference.StringPrimitive(sharedPreferences, keyFlow, key, defaultValue)
|
||||
}
|
||||
|
||||
override fun getLong(key: String, defaultValue: Long): Preference<Long> {
|
||||
return AndroidPreference.LongPrimitive(sharedPreferences, keyFlow, key, defaultValue)
|
||||
}
|
||||
|
||||
override fun getInt(key: String, defaultValue: Int): Preference<Int> {
|
||||
return AndroidPreference.IntPrimitive(sharedPreferences, keyFlow, key, defaultValue)
|
||||
}
|
||||
|
||||
override fun getFloat(key: String, defaultValue: Float): Preference<Float> {
|
||||
return AndroidPreference.FloatPrimitive(sharedPreferences, keyFlow, key, defaultValue)
|
||||
}
|
||||
|
||||
override fun getBoolean(key: String, defaultValue: Boolean): Preference<Boolean> {
|
||||
return AndroidPreference.BooleanPrimitive(sharedPreferences, keyFlow, key, defaultValue)
|
||||
}
|
||||
|
||||
override fun getStringSet(key: String, defaultValue: Set<String>): Preference<Set<String>> {
|
||||
return AndroidPreference.StringSetPrimitive(sharedPreferences, keyFlow, key, defaultValue)
|
||||
}
|
||||
|
||||
override fun <T> getObjectFromString(
|
||||
key: String,
|
||||
defaultValue: T,
|
||||
serializer: (T) -> String,
|
||||
deserializer: (String) -> T,
|
||||
): Preference<T> {
|
||||
return AndroidPreference.ObjectAsString(
|
||||
preferences = sharedPreferences,
|
||||
keyFlow = keyFlow,
|
||||
key = key,
|
||||
defaultValue = defaultValue,
|
||||
serializer = serializer,
|
||||
deserializer = deserializer,
|
||||
)
|
||||
}
|
||||
|
||||
override fun <T> getObjectFromInt(
|
||||
key: String,
|
||||
defaultValue: T,
|
||||
serializer: (T) -> Int,
|
||||
deserializer: (Int) -> T,
|
||||
): Preference<T> {
|
||||
return AndroidPreference.ObjectAsInt(
|
||||
preferences = sharedPreferences,
|
||||
keyFlow = keyFlow,
|
||||
key = key,
|
||||
defaultValue = defaultValue,
|
||||
serializer = serializer,
|
||||
deserializer = deserializer,
|
||||
)
|
||||
}
|
||||
|
||||
override fun getAll(): Map<String, *> {
|
||||
return sharedPreferences.all ?: emptyMap<String, Any>()
|
||||
}
|
||||
}
|
||||
|
||||
private val SharedPreferences.keyFlow
|
||||
get() = callbackFlow {
|
||||
val listener = SharedPreferences.OnSharedPreferenceChangeListener { _, key: String? ->
|
||||
trySend(
|
||||
key,
|
||||
)
|
||||
}
|
||||
registerOnSharedPreferenceChangeListener(listener)
|
||||
awaitClose {
|
||||
unregisterOnSharedPreferenceChangeListener(listener)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package dev.achmad.ledgerr.core.preference
|
||||
|
||||
sealed class CheckboxState<T>(open val value: T) {
|
||||
|
||||
abstract fun next(): CheckboxState<T>
|
||||
|
||||
sealed class State<T>(override val value: T) : CheckboxState<T>(value) {
|
||||
data class Checked<T>(override val value: T) : State<T>(value)
|
||||
data class None<T>(override val value: T) : State<T>(value)
|
||||
|
||||
val isChecked: Boolean
|
||||
get() = this is Checked
|
||||
|
||||
override fun next(): CheckboxState<T> {
|
||||
return when (this) {
|
||||
is Checked -> None(value)
|
||||
is None -> Checked(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sealed class TriState<T>(override val value: T) : CheckboxState<T>(value) {
|
||||
data class Include<T>(override val value: T) : TriState<T>(value)
|
||||
data class Exclude<T>(override val value: T) : TriState<T>(value)
|
||||
data class None<T>(override val value: T) : TriState<T>(value)
|
||||
|
||||
override fun next(): CheckboxState<T> {
|
||||
return when (this) {
|
||||
is Exclude -> None(value)
|
||||
is Include -> Exclude(value)
|
||||
is None -> Include(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
inline fun <T> T.asCheckboxState(condition: (T) -> Boolean): CheckboxState.State<T> {
|
||||
return if (condition(this)) {
|
||||
CheckboxState.State.Checked(this)
|
||||
} else {
|
||||
CheckboxState.State.None(this)
|
||||
}
|
||||
}
|
||||
|
||||
inline fun <T> List<T>.mapAsCheckboxState(condition: (T) -> Boolean): List<CheckboxState.State<T>> {
|
||||
return this.map { it.asCheckboxState(condition) }
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package dev.achmad.ledgerr.core.preference
|
||||
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
|
||||
/**
|
||||
* Local-copy implementation of PreferenceStore mostly for test and preview purposes
|
||||
*/
|
||||
class InMemoryPreferenceStore(
|
||||
initialPreferences: Sequence<InMemoryPreference<*>> = sequenceOf(),
|
||||
) : PreferenceStore {
|
||||
|
||||
private val preferences: Map<String, Preference<*>> =
|
||||
initialPreferences.toList().associateBy { it.key() }
|
||||
|
||||
override fun getString(key: String, defaultValue: String): Preference<String> {
|
||||
val default = InMemoryPreference(key, null, defaultValue)
|
||||
val data: String? = preferences[key]?.get() as? String
|
||||
return if (data == null) default else InMemoryPreference(key, data, defaultValue)
|
||||
}
|
||||
|
||||
override fun getLong(key: String, defaultValue: Long): Preference<Long> {
|
||||
val default = InMemoryPreference(key, null, defaultValue)
|
||||
val data: Long? = preferences[key]?.get() as? Long
|
||||
return if (data == null) default else InMemoryPreference(key, data, defaultValue)
|
||||
}
|
||||
|
||||
override fun getInt(key: String, defaultValue: Int): Preference<Int> {
|
||||
val default = InMemoryPreference(key, null, defaultValue)
|
||||
val data: Int? = preferences[key]?.get() as? Int
|
||||
return if (data == null) default else InMemoryPreference(key, data, defaultValue)
|
||||
}
|
||||
|
||||
override fun getFloat(key: String, defaultValue: Float): Preference<Float> {
|
||||
val default = InMemoryPreference(key, null, defaultValue)
|
||||
val data: Float? = preferences[key]?.get() as? Float
|
||||
return if (data == null) default else InMemoryPreference(key, data, defaultValue)
|
||||
}
|
||||
|
||||
override fun getBoolean(key: String, defaultValue: Boolean): Preference<Boolean> {
|
||||
val default = InMemoryPreference(key, null, defaultValue)
|
||||
val data: Boolean? = preferences[key]?.get() as? Boolean
|
||||
return if (data == null) default else InMemoryPreference(key, data, defaultValue)
|
||||
}
|
||||
|
||||
override fun getStringSet(key: String, defaultValue: Set<String>): Preference<Set<String>> {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun <T> getObjectFromString(
|
||||
key: String,
|
||||
defaultValue: T,
|
||||
serializer: (T) -> String,
|
||||
deserializer: (String) -> T,
|
||||
): Preference<T> {
|
||||
val default = InMemoryPreference(key, null, defaultValue)
|
||||
val data: T? = preferences[key]?.get() as? T
|
||||
return if (data == null) default else InMemoryPreference(key, data, defaultValue)
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun <T> getObjectFromInt(
|
||||
key: String,
|
||||
defaultValue: T,
|
||||
serializer: (T) -> Int,
|
||||
deserializer: (Int) -> T,
|
||||
): Preference<T> {
|
||||
val default = InMemoryPreference(key, null, defaultValue)
|
||||
val data: T? = preferences[key]?.get() as? T
|
||||
return if (data == null) default else InMemoryPreference(key, data, defaultValue)
|
||||
}
|
||||
|
||||
override fun getAll(): Map<String, *> {
|
||||
return preferences
|
||||
}
|
||||
|
||||
class InMemoryPreference<T>(
|
||||
private val key: String,
|
||||
private var data: T?,
|
||||
private val defaultValue: T,
|
||||
) : Preference<T> {
|
||||
override fun key(): String = key
|
||||
|
||||
override fun get(): T = data ?: defaultValue()
|
||||
|
||||
override fun isSet(): Boolean = data != null
|
||||
|
||||
override fun delete() {
|
||||
data = null
|
||||
}
|
||||
|
||||
override fun defaultValue(): T = defaultValue
|
||||
|
||||
override fun changes(): Flow<T> = flow { data }
|
||||
|
||||
override fun stateIn(scope: CoroutineScope): StateFlow<T> {
|
||||
return changes().stateIn(scope, SharingStarted.Eagerly, get())
|
||||
}
|
||||
|
||||
override fun set(value: T) {
|
||||
data = value
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package dev.achmad.ledgerr.core.preference
|
||||
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
interface Preference<T> {
|
||||
|
||||
fun key(): String
|
||||
|
||||
fun get(): T
|
||||
|
||||
fun set(value: T)
|
||||
|
||||
fun isSet(): Boolean
|
||||
|
||||
fun delete()
|
||||
|
||||
fun defaultValue(): T
|
||||
|
||||
fun changes(): Flow<T>
|
||||
|
||||
fun stateIn(scope: CoroutineScope): StateFlow<T>
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* A preference that should not be exposed in places like backups without user consent.
|
||||
*/
|
||||
fun isPrivate(key: String): Boolean {
|
||||
return key.startsWith(PRIVATE_PREFIX)
|
||||
}
|
||||
fun privateKey(key: String): String {
|
||||
return "$PRIVATE_PREFIX$key"
|
||||
}
|
||||
|
||||
/**
|
||||
* A preference used for internal app state that isn't really a user preference
|
||||
* and therefore should not be in places like backups.
|
||||
*/
|
||||
fun isAppState(key: String): Boolean {
|
||||
return key.startsWith(APP_STATE_PREFIX)
|
||||
}
|
||||
fun appStateKey(key: String): String {
|
||||
return "$APP_STATE_PREFIX$key"
|
||||
}
|
||||
|
||||
private const val APP_STATE_PREFIX = "__APP_STATE_"
|
||||
private const val PRIVATE_PREFIX = "__PRIVATE_"
|
||||
}
|
||||
}
|
||||
|
||||
inline fun <reified T, R : T> Preference<T>.getAndSet(crossinline block: (T) -> R) = set(
|
||||
block(get()),
|
||||
)
|
||||
|
||||
operator fun <T> Preference<Set<T>>.plusAssign(item: T) {
|
||||
set(get() + item)
|
||||
}
|
||||
|
||||
operator fun <T> Preference<Set<T>>.plusAssign(items: Iterable<T>) {
|
||||
set(get() + items)
|
||||
}
|
||||
|
||||
operator fun <T> Preference<Set<T>>.minusAssign(item: T) {
|
||||
set(get() - item)
|
||||
}
|
||||
|
||||
fun Preference<Boolean>.toggle(): Boolean {
|
||||
set(!get())
|
||||
return get()
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package dev.achmad.ledgerr.core.preference
|
||||
|
||||
interface PreferenceStore {
|
||||
|
||||
fun getString(key: String, defaultValue: String = ""): Preference<String>
|
||||
|
||||
fun getLong(key: String, defaultValue: Long = 0): Preference<Long>
|
||||
|
||||
fun getInt(key: String, defaultValue: Int = 0): Preference<Int>
|
||||
|
||||
fun getFloat(key: String, defaultValue: Float = 0f): Preference<Float>
|
||||
|
||||
fun getBoolean(key: String, defaultValue: Boolean = false): Preference<Boolean>
|
||||
|
||||
fun getStringSet(key: String, defaultValue: Set<String> = emptySet()): Preference<Set<String>>
|
||||
|
||||
fun <T> getObjectFromString(
|
||||
key: String,
|
||||
defaultValue: T,
|
||||
serializer: (T) -> String,
|
||||
deserializer: (String) -> T,
|
||||
): Preference<T>
|
||||
|
||||
fun <T> getObjectFromInt(
|
||||
key: String,
|
||||
defaultValue: T,
|
||||
serializer: (T) -> Int,
|
||||
deserializer: (Int) -> T,
|
||||
): Preference<T>
|
||||
|
||||
fun getAll(): Map<String, *>
|
||||
}
|
||||
|
||||
fun PreferenceStore.getLongArray(
|
||||
key: String,
|
||||
defaultValue: List<Long>,
|
||||
): Preference<List<Long>> {
|
||||
return getObjectFromString(
|
||||
key = key,
|
||||
defaultValue = defaultValue,
|
||||
serializer = { it.joinToString(",") },
|
||||
deserializer = { it.split(",").mapNotNull { l -> l.toLongOrNull() } },
|
||||
)
|
||||
}
|
||||
|
||||
inline fun <reified T : Enum<T>> PreferenceStore.getEnum(
|
||||
key: String,
|
||||
defaultValue: T,
|
||||
): Preference<T> {
|
||||
return getObjectFromString(
|
||||
key = key,
|
||||
defaultValue = defaultValue,
|
||||
serializer = { it.name },
|
||||
deserializer = {
|
||||
try {
|
||||
enumValueOf(it)
|
||||
} catch (e: IllegalArgumentException) {
|
||||
defaultValue
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package dev.achmad.ledgerr.core.preference
|
||||
|
||||
enum class TriState {
|
||||
DISABLED, // Disable filter
|
||||
ENABLED_IS, // Enabled with "is" filter
|
||||
ENABLED_NOT, // Enabled with "not" filter
|
||||
;
|
||||
|
||||
fun next(): TriState {
|
||||
return when (this) {
|
||||
DISABLED -> ENABLED_IS
|
||||
ENABLED_IS -> ENABLED_NOT
|
||||
ENABLED_NOT -> DISABLED
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package dev.achmad.ledgerr.di
|
||||
|
||||
import org.koin.dsl.module
|
||||
|
||||
val coreModule = module {
|
||||
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package dev.achmad.ledgerr.di
|
||||
|
||||
import org.koin.dsl.module
|
||||
|
||||
val dataModule = module {
|
||||
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package dev.achmad.ledgerr.di
|
||||
|
||||
import org.koin.dsl.module
|
||||
|
||||
val domainModule = module {
|
||||
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package dev.achmad.ledgerr.di
|
||||
|
||||
import org.koin.dsl.module
|
||||
|
||||
val uiModule = module {
|
||||
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package dev.achmad.ledgerr.di.util
|
||||
|
||||
import org.koin.core.qualifier.named
|
||||
import org.koin.mp.KoinPlatformTools
|
||||
|
||||
inline fun <reified T : Any> injectLazy(): Lazy<T> {
|
||||
return lazy { KoinPlatformTools.defaultContext().get().get(T::class) }
|
||||
}
|
||||
|
||||
inline fun <reified T : Any> injectLazy(key: String): Lazy<T> {
|
||||
return lazy { KoinPlatformTools.defaultContext().get().get(T::class, named(key)) }
|
||||
}
|
||||
|
||||
inline fun <reified T : Any> inject(): T {
|
||||
return KoinPlatformTools.defaultContext().get().get(T::class)
|
||||
}
|
||||
|
||||
inline fun <reified T : Any> inject(key: String): T {
|
||||
return KoinPlatformTools.defaultContext().get().get(T::class, named(key))
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package dev.achmad.ledgerr.ui.base
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import android.view.ViewTreeObserver
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.core.util.Consumer
|
||||
import cafe.adriel.voyager.core.screen.Screen
|
||||
import cafe.adriel.voyager.core.stack.StackEvent
|
||||
import cafe.adriel.voyager.navigator.Navigator
|
||||
import cafe.adriel.voyager.navigator.NavigatorDisposeBehavior
|
||||
import cafe.adriel.voyager.transitions.ScreenTransition
|
||||
import dev.achmad.ledgerr.ui.screens.home.HomeScreen
|
||||
import dev.achmad.ledgerr.ui.theme.AppTheme
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.flow.callbackFlow
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import soup.compose.material.motion.animation.materialSharedAxisX
|
||||
import soup.compose.material.motion.animation.rememberSlideDistance
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
|
||||
private var isReady = false
|
||||
private var initialScreen: Screen = HomeScreen
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
val content: View = findViewById(android.R.id.content)
|
||||
content.viewTreeObserver.addOnPreDrawListener(
|
||||
object : ViewTreeObserver.OnPreDrawListener {
|
||||
override fun onPreDraw(): Boolean {
|
||||
return if (isReady) {
|
||||
content.viewTreeObserver.removeOnPreDrawListener(this)
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
handlePreDraw()
|
||||
|
||||
enableEdgeToEdge()
|
||||
|
||||
setContent {
|
||||
AppTheme {
|
||||
val slideDistance = rememberSlideDistance()
|
||||
Navigator(
|
||||
screen = initialScreen,
|
||||
disposeBehavior = NavigatorDisposeBehavior(
|
||||
disposeNestedNavigators = false,
|
||||
disposeSteps = true,
|
||||
)
|
||||
) { navigator ->
|
||||
ScreenTransition(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
navigator = navigator,
|
||||
transition = {
|
||||
materialSharedAxisX(
|
||||
forward = navigator.lastEvent != StackEvent.Pop,
|
||||
slideDistance = slideDistance,
|
||||
)
|
||||
},
|
||||
)
|
||||
HandleNewIntent(this@MainActivity, navigator)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handlePreDraw() {
|
||||
// Handle pre draw here (e.g. Splash Screen, fetch data, etc.)
|
||||
isReady = true
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HandleNewIntent(context: Context, navigator: Navigator) {
|
||||
LaunchedEffect(Unit) {
|
||||
callbackFlow {
|
||||
val componentActivity = context as ComponentActivity
|
||||
val consumer = Consumer<Intent> { trySend(it) }
|
||||
componentActivity.addOnNewIntentListener(consumer)
|
||||
awaitClose { componentActivity.removeOnNewIntentListener(consumer) }
|
||||
}.collectLatest { handleIntentAction(it, navigator) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleIntentAction(intent: Intent, navigator: Navigator) {
|
||||
// Handle intent here
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package dev.achmad.ledgerr.ui.screens.home
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import cafe.adriel.voyager.core.screen.Screen
|
||||
|
||||
object HomeScreen: Screen {
|
||||
|
||||
@Suppress("unused")
|
||||
private fun readResolve(): Any = HomeScreen
|
||||
|
||||
@Composable
|
||||
override fun Content() {
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package dev.achmad.ledgerr.ui.theme
|
||||
|
||||
import androidx.activity.compose.LocalActivity
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.core.view.WindowCompat
|
||||
|
||||
@Composable
|
||||
fun AppTheme(
|
||||
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
val colorScheme = when {
|
||||
darkTheme -> darkColorScheme()
|
||||
else -> lightColorScheme()
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(colorScheme.background),
|
||||
) {
|
||||
MaterialTheme(
|
||||
colorScheme = colorScheme,
|
||||
typography = Typography,
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
@Composable
|
||||
fun NavigationBarColor(color: Color) {
|
||||
val view = LocalView.current
|
||||
val activity = LocalActivity.current
|
||||
if (!view.isInEditMode) {
|
||||
SideEffect {
|
||||
activity?.window?.let {
|
||||
it.navigationBarColor = color.toArgb()
|
||||
WindowCompat.setDecorFitsSystemWindows(it, false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
@Composable
|
||||
fun StatusBarColor(color: Color) {
|
||||
val view = LocalView.current
|
||||
val activity = LocalActivity.current
|
||||
if (!view.isInEditMode) {
|
||||
SideEffect {
|
||||
activity?.window?.let {
|
||||
it.statusBarColor = color.toArgb()
|
||||
WindowCompat.setDecorFitsSystemWindows(it, false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package dev.achmad.ledgerr.ui.theme
|
||||
|
||||
import androidx.compose.material3.Typography
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
// Set of Material typography styles to start with
|
||||
val Typography = Typography(
|
||||
bodyLarge = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 16.sp,
|
||||
lineHeight = 24.sp,
|
||||
letterSpacing = 0.5.sp
|
||||
)
|
||||
/* Other default text styles to override
|
||||
titleLarge = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 22.sp,
|
||||
lineHeight = 28.sp,
|
||||
letterSpacing = 0.sp
|
||||
),
|
||||
labelSmall = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 11.sp,
|
||||
lineHeight = 16.sp,
|
||||
letterSpacing = 0.5.sp
|
||||
)
|
||||
*/
|
||||
)
|
||||
@@ -0,0 +1,12 @@
|
||||
# Add project specific R8 rules here.
|
||||
# AGP will combine all keep rule files in src/main/keepRules to pass to R8
|
||||
#
|
||||
# For more details, see
|
||||
# https://d.android.com/r/tools/r8/keep-rules
|
||||
|
||||
# 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 *;
|
||||
#}
|
||||
@@ -0,0 +1,170 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path
|
||||
android:fillColor="#3DDC84"
|
||||
android:pathData="M0,0h108v108h-108z" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M9,0L9,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,0L19,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M29,0L29,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M39,0L39,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M49,0L49,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M59,0L59,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M69,0L69,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M79,0L79,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M89,0L89,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M99,0L99,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,9L108,9"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,19L108,19"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,29L108,29"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,39L108,39"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,49L108,49"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,59L108,59"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,69L108,69"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,79L108,79"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,89L108,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,99L108,99"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,29L89,29"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,39L89,39"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,49L89,49"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,59L89,59"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,69L89,69"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,79L89,79"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M29,19L29,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M39,19L39,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M49,19L49,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M59,19L59,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M69,19L69,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M79,19L79,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
</vector>
|
||||
@@ -0,0 +1,30 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:aapt="http://schemas.android.com/aapt"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
|
||||
<aapt:attr name="android:fillColor">
|
||||
<gradient
|
||||
android:endX="85.84757"
|
||||
android:endY="92.4963"
|
||||
android:startX="42.9492"
|
||||
android:startY="49.59793"
|
||||
android:type="linear">
|
||||
<item
|
||||
android:color="#44000000"
|
||||
android:offset="0.0" />
|
||||
<item
|
||||
android:color="#00000000"
|
||||
android:offset="1.0" />
|
||||
</gradient>
|
||||
</aapt:attr>
|
||||
</path>
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:fillType="nonZero"
|
||||
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
|
||||
android:strokeWidth="1"
|
||||
android:strokeColor="#00000000" />
|
||||
</vector>
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 982 B |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 5.8 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 7.6 KiB |
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="purple_200">#FFBB86FC</color>
|
||||
<color name="purple_500">#FF6200EE</color>
|
||||
<color name="purple_700">#FF3700B3</color>
|
||||
<color name="teal_200">#FF03DAC5</color>
|
||||
<color name="teal_700">#FF018786</color>
|
||||
<color name="black">#FF000000</color>
|
||||
<color name="white">#FFFFFFFF</color>
|
||||
</resources>
|
||||
@@ -0,0 +1,3 @@
|
||||
<resources>
|
||||
<string name="app_name">Ledgerr</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
|
||||
<style name="Theme.Ledgerr" parent="android:Theme.Material.Light.NoActionBar" />
|
||||
</resources>
|
||||
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><!--
|
||||
Sample backup rules file; uncomment and customize as necessary.
|
||||
See https://developer.android.com/guide/topics/data/autobackup
|
||||
for details.
|
||||
Note: This file is ignored for devices older than API 31
|
||||
See https://developer.android.com/about/versions/12/backup-restore
|
||||
-->
|
||||
<full-backup-content>
|
||||
<!--
|
||||
<include domain="sharedpref" path="."/>
|
||||
<exclude domain="sharedpref" path="device.xml"/>
|
||||
-->
|
||||
</full-backup-content>
|
||||
@@ -0,0 +1,19 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><!--
|
||||
Sample data extraction rules file; uncomment and customize as necessary.
|
||||
See https://developer.android.com/about/versions/12/backup-restore#xml-changes
|
||||
for details.
|
||||
-->
|
||||
<data-extraction-rules>
|
||||
<cloud-backup>
|
||||
<!-- TODO: Use <include> and <exclude> to control what is backed up.
|
||||
<include .../>
|
||||
<exclude .../>
|
||||
-->
|
||||
</cloud-backup>
|
||||
<!--
|
||||
<device-transfer>
|
||||
<include .../>
|
||||
<exclude .../>
|
||||
</device-transfer>
|
||||
-->
|
||||
</data-extraction-rules>
|
||||
@@ -0,0 +1,17 @@
|
||||
package dev.achmad.ledgerr
|
||||
|
||||
import org.junit.Test
|
||||
|
||||
import org.junit.Assert.*
|
||||
|
||||
/**
|
||||
* 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)
|
||||
}
|
||||
}
|
||||