commit dfca375a9b7938b75b4d71be16bcb264a13cee31 Author: Achmad Setyabudi Susilo Date: Sun Jun 28 13:21:57 2026 +0700 chore: setup project diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..aa724b7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +*.iml +.gradle +/local.properties +/.idea/caches +/.idea/libraries +/.idea/modules.xml +/.idea/workspace.xml +/.idea/navEditor.xml +/.idea/assetWizardSettings.xml +.DS_Store +/build +/captures +.externalNativeBuild +.cxx +local.properties diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..26d3352 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,3 @@ +# Default ignored files +/shelf/ +/workspace.xml diff --git a/.idea/AndroidProjectSystem.xml b/.idea/AndroidProjectSystem.xml new file mode 100644 index 0000000..4a53bee --- /dev/null +++ b/.idea/AndroidProjectSystem.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/appInsightsSettings.xml b/.idea/appInsightsSettings.xml new file mode 100644 index 0000000..44b93d0 --- /dev/null +++ b/.idea/appInsightsSettings.xml @@ -0,0 +1,40 @@ + + + + + + \ No newline at end of file diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml new file mode 100644 index 0000000..9c3d95a --- /dev/null +++ b/.idea/codeStyles/Project.xml @@ -0,0 +1,157 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/codeStyles/codeStyleConfig.xml b/.idea/codeStyles/codeStyleConfig.xml new file mode 100644 index 0000000..79ee123 --- /dev/null +++ b/.idea/codeStyles/codeStyleConfig.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/.idea/compiler.xml b/.idea/compiler.xml new file mode 100644 index 0000000..b86273d --- /dev/null +++ b/.idea/compiler.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/deploymentTargetSelector.xml b/.idea/deploymentTargetSelector.xml new file mode 100644 index 0000000..ca16a99 --- /dev/null +++ b/.idea/deploymentTargetSelector.xml @@ -0,0 +1,11 @@ + + + + + + + + + \ No newline at end of file diff --git a/.idea/gradle.xml b/.idea/gradle.xml new file mode 100644 index 0000000..02c4aa5 --- /dev/null +++ b/.idea/gradle.xml @@ -0,0 +1,18 @@ + + + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..74dd639 --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,10 @@ + + + + + + + + + \ No newline at end of file diff --git a/.idea/runConfigurations.xml b/.idea/runConfigurations.xml new file mode 100644 index 0000000..16660f1 --- /dev/null +++ b/.idea/runConfigurations.xml @@ -0,0 +1,17 @@ + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..94a25f7 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/app/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..e6823e2 --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,87 @@ +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.compose) + alias(libs.plugins.serialization) + alias(libs.plugins.ksp) +} + +android { + namespace = "dev.achmad.ledgerr" + compileSdk { + version = release(36) { + minorApiLevel = 1 + } + } + + defaultConfig { + applicationId = "dev.achmad.ledgerr" + minSdk = 26 + targetSdk = 36 + versionCode = 1 + versionName = "1.0" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + release { + optimization { + enable = false + } + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + buildFeatures { + compose = true + } +} + +kotlin { + compilerOptions { + freeCompilerArgs.addAll( + "-XXLanguage:+ContextParameters", + "-opt-in=kotlinx.serialization.ExperimentalSerializationApi", + ) + } +} + +dependencies { + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.activity.compose) + implementation(libs.androidx.compose.material3) + implementation(libs.androidx.compose.ui) + implementation(libs.androidx.compose.ui.graphics) + implementation(libs.androidx.compose.ui.tooling.preview) + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.lifecycle.runtime.ktx) + testImplementation(libs.junit) + androidTestImplementation(platform(libs.androidx.compose.bom)) + androidTestImplementation(libs.androidx.compose.ui.test.junit4) + androidTestImplementation(libs.androidx.espresso.core) + androidTestImplementation(libs.androidx.junit) + debugImplementation(libs.androidx.compose.ui.test.manifest) + debugImplementation(libs.androidx.compose.ui.tooling) + + implementation(libs.material.icons) + implementation(libs.material.motion.compose.core) + implementation(libs.coil.compose) + + implementation(libs.voyager.navigator) + implementation(libs.voyager.tabNavigator) + implementation(libs.voyager.transitions) + implementation(libs.voyager.screenmodel) + + implementation(platform(libs.koin.bom)) + implementation(libs.koin.android) + + api(libs.okhttp.core) + api(libs.okhttp.logging) + api(libs.okhttp.brotli) + api(libs.okhttp.dnsoverhttps) + api(libs.okio) + api(libs.serialization.json) + api(libs.serialization.json.okio) +} \ No newline at end of file diff --git a/app/src/androidTest/java/dev/achmad/ledgerr/ExampleInstrumentedTest.kt b/app/src/androidTest/java/dev/achmad/ledgerr/ExampleInstrumentedTest.kt new file mode 100644 index 0000000..3136c05 --- /dev/null +++ b/app/src/androidTest/java/dev/achmad/ledgerr/ExampleInstrumentedTest.kt @@ -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) + } +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..6982f83 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/java/dev/achmad/ledgerr/core/network/AndroidCookieJar.kt b/app/src/main/java/dev/achmad/ledgerr/core/network/AndroidCookieJar.kt new file mode 100644 index 0000000..daf2534 --- /dev/null +++ b/app/src/main/java/dev/achmad/ledgerr/core/network/AndroidCookieJar.kt @@ -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) { + val urlString = url.toString() + + cookies.forEach { manager.setCookie(urlString, it.toString()) } + } + + override fun loadForRequest(url: HttpUrl): List { + return get(url) + } + + fun get(url: HttpUrl): List { + 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? = null, maxAge: Int = -1): Int { + val urlString = url.toString() + val cookies = manager.getCookie(urlString) ?: return 0 + + fun List.filterNames(): List { + 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 {} + } +} \ No newline at end of file diff --git a/app/src/main/java/dev/achmad/ledgerr/core/network/NetworkHelper.kt b/app/src/main/java/dev/achmad/ledgerr/core/network/NetworkHelper.kt new file mode 100644 index 0000000..ab29693 --- /dev/null +++ b/app/src/main/java/dev/achmad/ledgerr/core/network/NetworkHelper.kt @@ -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) + } + +} \ No newline at end of file diff --git a/app/src/main/java/dev/achmad/ledgerr/core/network/OkHttpExtensions.kt b/app/src/main/java/dev/achmad/ledgerr/core/network/OkHttpExtensions.kt new file mode 100644 index 0000000..be8ef1b --- /dev/null +++ b/app/src/main/java/dev/achmad/ledgerr/core/network/OkHttpExtensions.kt @@ -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): 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 Response.parseAs(): T { + return decodeFromJsonResponse(serializer(), this) +} + +context(json: Json) +fun decodeFromJsonResponse( + deserializer: DeserializationStrategy, + 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") \ No newline at end of file diff --git a/app/src/main/java/dev/achmad/ledgerr/core/network/ProgressListener.kt b/app/src/main/java/dev/achmad/ledgerr/core/network/ProgressListener.kt new file mode 100644 index 0000000..af06eb5 --- /dev/null +++ b/app/src/main/java/dev/achmad/ledgerr/core/network/ProgressListener.kt @@ -0,0 +1,5 @@ +package dev.achmad.ledgerr.core.network + +interface ProgressListener { + fun update(bytesRead: Long, contentLength: Long, done: Boolean) +} \ No newline at end of file diff --git a/app/src/main/java/dev/achmad/ledgerr/core/network/ProgressResponseBody.kt b/app/src/main/java/dev/achmad/ledgerr/core/network/ProgressResponseBody.kt new file mode 100644 index 0000000..e029494 --- /dev/null +++ b/app/src/main/java/dev/achmad/ledgerr/core/network/ProgressResponseBody.kt @@ -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 + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/dev/achmad/ledgerr/core/network/Requests.kt b/app/src/main/java/dev/achmad/ledgerr/core/network/Requests.kt new file mode 100644 index 0000000..f3891aa --- /dev/null +++ b/app/src/main/java/dev/achmad/ledgerr/core/network/Requests.kt @@ -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() +} \ No newline at end of file diff --git a/app/src/main/java/dev/achmad/ledgerr/core/network/interceptor/IgnoreGzipInterceptor.kt b/app/src/main/java/dev/achmad/ledgerr/core/network/interceptor/IgnoreGzipInterceptor.kt new file mode 100644 index 0000000..cde6e10 --- /dev/null +++ b/app/src/main/java/dev/achmad/ledgerr/core/network/interceptor/IgnoreGzipInterceptor.kt @@ -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) + } +} \ No newline at end of file diff --git a/app/src/main/java/dev/achmad/ledgerr/core/network/interceptor/RateLimitInterceptor.kt b/app/src/main/java/dev/achmad/ledgerr/core/network/interceptor/RateLimitInterceptor.kt new file mode 100644 index 0000000..758ddd9 --- /dev/null +++ b/app/src/main/java/dev/achmad/ledgerr/core/network/interceptor/RateLimitInterceptor.kt @@ -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(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 + } +} \ No newline at end of file diff --git a/app/src/main/java/dev/achmad/ledgerr/core/network/interceptor/SpecificHostRateLimitInterceptor.kt b/app/src/main/java/dev/achmad/ledgerr/core/network/interceptor/SpecificHostRateLimitInterceptor.kt new file mode 100644 index 0000000..8d072c2 --- /dev/null +++ b/app/src/main/java/dev/achmad/ledgerr/core/network/interceptor/SpecificHostRateLimitInterceptor.kt @@ -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)) \ No newline at end of file diff --git a/app/src/main/java/dev/achmad/ledgerr/core/network/interceptor/UncaughtExceptionInterceptor.kt b/app/src/main/java/dev/achmad/ledgerr/core/network/interceptor/UncaughtExceptionInterceptor.kt new file mode 100644 index 0000000..89cd4ee --- /dev/null +++ b/app/src/main/java/dev/achmad/ledgerr/core/network/interceptor/UncaughtExceptionInterceptor.kt @@ -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) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/dev/achmad/ledgerr/core/network/interceptor/UserAgentInterceptor.kt b/app/src/main/java/dev/achmad/ledgerr/core/network/interceptor/UserAgentInterceptor.kt new file mode 100644 index 0000000..b111e85 --- /dev/null +++ b/app/src/main/java/dev/achmad/ledgerr/core/network/interceptor/UserAgentInterceptor.kt @@ -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) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/dev/achmad/ledgerr/core/preference/AndroidPreference.kt b/app/src/main/java/dev/achmad/ledgerr/core/preference/AndroidPreference.kt new file mode 100644 index 0000000..ca13f25 --- /dev/null +++ b/app/src/main/java/dev/achmad/ledgerr/core/preference/AndroidPreference.kt @@ -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( + private val preferences: SharedPreferences, + private val keyFlow: Flow, + private val key: String, + private val defaultValue: T, +) : Preference { + + 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 { + return keyFlow + .filter { it == key || it == null } + .onStart { emit("ignition") } + .map { get() } + .conflate() + } + + override fun stateIn(scope: CoroutineScope): StateFlow { + return changes().stateIn(scope, SharingStarted.Eagerly, get()) + } + + class StringPrimitive( + preferences: SharedPreferences, + keyFlow: Flow, + key: String, + defaultValue: String, + ) : AndroidPreference(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, + key: String, + defaultValue: Long, + ) : AndroidPreference(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, + key: String, + defaultValue: Int, + ) : AndroidPreference(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, + key: String, + defaultValue: Float, + ) : AndroidPreference(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, + key: String, + defaultValue: Boolean, + ) : AndroidPreference(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, + key: String, + defaultValue: Set, + ) : AndroidPreference>(preferences, keyFlow, key, defaultValue) { + override fun read( + preferences: SharedPreferences, + key: String, + defaultValue: Set, + ): Set { + return preferences.getStringSet(key, defaultValue) ?: defaultValue + } + + override fun write(key: String, value: Set): Editor.() -> Unit = { + putStringSet(key, value) + } + } + + class ObjectAsString( + preferences: SharedPreferences, + keyFlow: Flow, + key: String, + defaultValue: T, + private val serializer: (T) -> String, + private val deserializer: (String) -> T, + ) : AndroidPreference(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( + preferences: SharedPreferences, + keyFlow: Flow, + key: String, + defaultValue: T, + private val serializer: (T) -> Int, + private val deserializer: (Int) -> T, + ) : AndroidPreference(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)) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/dev/achmad/ledgerr/core/preference/AndroidPreferenceStore.kt b/app/src/main/java/dev/achmad/ledgerr/core/preference/AndroidPreferenceStore.kt new file mode 100644 index 0000000..64d9ac5 --- /dev/null +++ b/app/src/main/java/dev/achmad/ledgerr/core/preference/AndroidPreferenceStore.kt @@ -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 { + return AndroidPreference.StringPrimitive(sharedPreferences, keyFlow, key, defaultValue) + } + + override fun getLong(key: String, defaultValue: Long): Preference { + return AndroidPreference.LongPrimitive(sharedPreferences, keyFlow, key, defaultValue) + } + + override fun getInt(key: String, defaultValue: Int): Preference { + return AndroidPreference.IntPrimitive(sharedPreferences, keyFlow, key, defaultValue) + } + + override fun getFloat(key: String, defaultValue: Float): Preference { + return AndroidPreference.FloatPrimitive(sharedPreferences, keyFlow, key, defaultValue) + } + + override fun getBoolean(key: String, defaultValue: Boolean): Preference { + return AndroidPreference.BooleanPrimitive(sharedPreferences, keyFlow, key, defaultValue) + } + + override fun getStringSet(key: String, defaultValue: Set): Preference> { + return AndroidPreference.StringSetPrimitive(sharedPreferences, keyFlow, key, defaultValue) + } + + override fun getObjectFromString( + key: String, + defaultValue: T, + serializer: (T) -> String, + deserializer: (String) -> T, + ): Preference { + return AndroidPreference.ObjectAsString( + preferences = sharedPreferences, + keyFlow = keyFlow, + key = key, + defaultValue = defaultValue, + serializer = serializer, + deserializer = deserializer, + ) + } + + override fun getObjectFromInt( + key: String, + defaultValue: T, + serializer: (T) -> Int, + deserializer: (Int) -> T, + ): Preference { + return AndroidPreference.ObjectAsInt( + preferences = sharedPreferences, + keyFlow = keyFlow, + key = key, + defaultValue = defaultValue, + serializer = serializer, + deserializer = deserializer, + ) + } + + override fun getAll(): Map { + return sharedPreferences.all ?: emptyMap() + } +} + +private val SharedPreferences.keyFlow + get() = callbackFlow { + val listener = SharedPreferences.OnSharedPreferenceChangeListener { _, key: String? -> + trySend( + key, + ) + } + registerOnSharedPreferenceChangeListener(listener) + awaitClose { + unregisterOnSharedPreferenceChangeListener(listener) + } + } \ No newline at end of file diff --git a/app/src/main/java/dev/achmad/ledgerr/core/preference/CheckboxState.kt b/app/src/main/java/dev/achmad/ledgerr/core/preference/CheckboxState.kt new file mode 100644 index 0000000..e691a17 --- /dev/null +++ b/app/src/main/java/dev/achmad/ledgerr/core/preference/CheckboxState.kt @@ -0,0 +1,47 @@ +package dev.achmad.ledgerr.core.preference + +sealed class CheckboxState(open val value: T) { + + abstract fun next(): CheckboxState + + sealed class State(override val value: T) : CheckboxState(value) { + data class Checked(override val value: T) : State(value) + data class None(override val value: T) : State(value) + + val isChecked: Boolean + get() = this is Checked + + override fun next(): CheckboxState { + return when (this) { + is Checked -> None(value) + is None -> Checked(value) + } + } + } + + sealed class TriState(override val value: T) : CheckboxState(value) { + data class Include(override val value: T) : TriState(value) + data class Exclude(override val value: T) : TriState(value) + data class None(override val value: T) : TriState(value) + + override fun next(): CheckboxState { + return when (this) { + is Exclude -> None(value) + is Include -> Exclude(value) + is None -> Include(value) + } + } + } +} + +inline fun T.asCheckboxState(condition: (T) -> Boolean): CheckboxState.State { + return if (condition(this)) { + CheckboxState.State.Checked(this) + } else { + CheckboxState.State.None(this) + } +} + +inline fun List.mapAsCheckboxState(condition: (T) -> Boolean): List> { + return this.map { it.asCheckboxState(condition) } +} \ No newline at end of file diff --git a/app/src/main/java/dev/achmad/ledgerr/core/preference/InMemoryPreferenceStore.kt b/app/src/main/java/dev/achmad/ledgerr/core/preference/InMemoryPreferenceStore.kt new file mode 100644 index 0000000..1ce6b6b --- /dev/null +++ b/app/src/main/java/dev/achmad/ledgerr/core/preference/InMemoryPreferenceStore.kt @@ -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> = sequenceOf(), +) : PreferenceStore { + + private val preferences: Map> = + initialPreferences.toList().associateBy { it.key() } + + override fun getString(key: String, defaultValue: String): Preference { + 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 { + 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 { + 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 { + 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 { + 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): Preference> { + TODO("Not yet implemented") + } + + @Suppress("UNCHECKED_CAST") + override fun getObjectFromString( + key: String, + defaultValue: T, + serializer: (T) -> String, + deserializer: (String) -> T, + ): Preference { + 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 getObjectFromInt( + key: String, + defaultValue: T, + serializer: (T) -> Int, + deserializer: (Int) -> T, + ): Preference { + 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 { + return preferences + } + + class InMemoryPreference( + private val key: String, + private var data: T?, + private val defaultValue: T, + ) : Preference { + 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 = flow { data } + + override fun stateIn(scope: CoroutineScope): StateFlow { + return changes().stateIn(scope, SharingStarted.Eagerly, get()) + } + + override fun set(value: T) { + data = value + } + } +} \ No newline at end of file diff --git a/app/src/main/java/dev/achmad/ledgerr/core/preference/Preference.kt b/app/src/main/java/dev/achmad/ledgerr/core/preference/Preference.kt new file mode 100644 index 0000000..3ebbb33 --- /dev/null +++ b/app/src/main/java/dev/achmad/ledgerr/core/preference/Preference.kt @@ -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 { + + fun key(): String + + fun get(): T + + fun set(value: T) + + fun isSet(): Boolean + + fun delete() + + fun defaultValue(): T + + fun changes(): Flow + + fun stateIn(scope: CoroutineScope): StateFlow + + 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 Preference.getAndSet(crossinline block: (T) -> R) = set( + block(get()), +) + +operator fun Preference>.plusAssign(item: T) { + set(get() + item) +} + +operator fun Preference>.plusAssign(items: Iterable) { + set(get() + items) +} + +operator fun Preference>.minusAssign(item: T) { + set(get() - item) +} + +fun Preference.toggle(): Boolean { + set(!get()) + return get() +} \ No newline at end of file diff --git a/app/src/main/java/dev/achmad/ledgerr/core/preference/PreferenceStore.kt b/app/src/main/java/dev/achmad/ledgerr/core/preference/PreferenceStore.kt new file mode 100644 index 0000000..292ed80 --- /dev/null +++ b/app/src/main/java/dev/achmad/ledgerr/core/preference/PreferenceStore.kt @@ -0,0 +1,62 @@ +package dev.achmad.ledgerr.core.preference + +interface PreferenceStore { + + fun getString(key: String, defaultValue: String = ""): Preference + + fun getLong(key: String, defaultValue: Long = 0): Preference + + fun getInt(key: String, defaultValue: Int = 0): Preference + + fun getFloat(key: String, defaultValue: Float = 0f): Preference + + fun getBoolean(key: String, defaultValue: Boolean = false): Preference + + fun getStringSet(key: String, defaultValue: Set = emptySet()): Preference> + + fun getObjectFromString( + key: String, + defaultValue: T, + serializer: (T) -> String, + deserializer: (String) -> T, + ): Preference + + fun getObjectFromInt( + key: String, + defaultValue: T, + serializer: (T) -> Int, + deserializer: (Int) -> T, + ): Preference + + fun getAll(): Map +} + +fun PreferenceStore.getLongArray( + key: String, + defaultValue: List, +): Preference> { + return getObjectFromString( + key = key, + defaultValue = defaultValue, + serializer = { it.joinToString(",") }, + deserializer = { it.split(",").mapNotNull { l -> l.toLongOrNull() } }, + ) +} + +inline fun > PreferenceStore.getEnum( + key: String, + defaultValue: T, +): Preference { + return getObjectFromString( + key = key, + defaultValue = defaultValue, + serializer = { it.name }, + deserializer = { + try { + enumValueOf(it) + } catch (e: IllegalArgumentException) { + defaultValue + } + }, + ) +} \ No newline at end of file diff --git a/app/src/main/java/dev/achmad/ledgerr/core/preference/TriState.kt b/app/src/main/java/dev/achmad/ledgerr/core/preference/TriState.kt new file mode 100644 index 0000000..bab0175 --- /dev/null +++ b/app/src/main/java/dev/achmad/ledgerr/core/preference/TriState.kt @@ -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 + } + } +} \ No newline at end of file diff --git a/app/src/main/java/dev/achmad/ledgerr/di/CoreModule.kt b/app/src/main/java/dev/achmad/ledgerr/di/CoreModule.kt new file mode 100644 index 0000000..3ecb8b2 --- /dev/null +++ b/app/src/main/java/dev/achmad/ledgerr/di/CoreModule.kt @@ -0,0 +1,7 @@ +package dev.achmad.ledgerr.di + +import org.koin.dsl.module + +val coreModule = module { + +} \ No newline at end of file diff --git a/app/src/main/java/dev/achmad/ledgerr/di/DataModule.kt b/app/src/main/java/dev/achmad/ledgerr/di/DataModule.kt new file mode 100644 index 0000000..83bbdd0 --- /dev/null +++ b/app/src/main/java/dev/achmad/ledgerr/di/DataModule.kt @@ -0,0 +1,7 @@ +package dev.achmad.ledgerr.di + +import org.koin.dsl.module + +val dataModule = module { + +} \ No newline at end of file diff --git a/app/src/main/java/dev/achmad/ledgerr/di/DomainModule.kt b/app/src/main/java/dev/achmad/ledgerr/di/DomainModule.kt new file mode 100644 index 0000000..669aa7a --- /dev/null +++ b/app/src/main/java/dev/achmad/ledgerr/di/DomainModule.kt @@ -0,0 +1,7 @@ +package dev.achmad.ledgerr.di + +import org.koin.dsl.module + +val domainModule = module { + +} \ No newline at end of file diff --git a/app/src/main/java/dev/achmad/ledgerr/di/UiModule.kt b/app/src/main/java/dev/achmad/ledgerr/di/UiModule.kt new file mode 100644 index 0000000..f472423 --- /dev/null +++ b/app/src/main/java/dev/achmad/ledgerr/di/UiModule.kt @@ -0,0 +1,7 @@ +package dev.achmad.ledgerr.di + +import org.koin.dsl.module + +val uiModule = module { + +} \ No newline at end of file diff --git a/app/src/main/java/dev/achmad/ledgerr/di/util/KoinExtensions.kt b/app/src/main/java/dev/achmad/ledgerr/di/util/KoinExtensions.kt new file mode 100644 index 0000000..9b05946 --- /dev/null +++ b/app/src/main/java/dev/achmad/ledgerr/di/util/KoinExtensions.kt @@ -0,0 +1,20 @@ +package dev.achmad.ledgerr.di.util + +import org.koin.core.qualifier.named +import org.koin.mp.KoinPlatformTools + +inline fun injectLazy(): Lazy { + return lazy { KoinPlatformTools.defaultContext().get().get(T::class) } +} + +inline fun injectLazy(key: String): Lazy { + return lazy { KoinPlatformTools.defaultContext().get().get(T::class, named(key)) } +} + +inline fun inject(): T { + return KoinPlatformTools.defaultContext().get().get(T::class) +} + +inline fun inject(key: String): T { + return KoinPlatformTools.defaultContext().get().get(T::class, named(key)) +} \ No newline at end of file diff --git a/app/src/main/java/dev/achmad/ledgerr/ui/base/MainActivity.kt b/app/src/main/java/dev/achmad/ledgerr/ui/base/MainActivity.kt new file mode 100644 index 0000000..2cf5050 --- /dev/null +++ b/app/src/main/java/dev/achmad/ledgerr/ui/base/MainActivity.kt @@ -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 { trySend(it) } + componentActivity.addOnNewIntentListener(consumer) + awaitClose { componentActivity.removeOnNewIntentListener(consumer) } + }.collectLatest { handleIntentAction(it, navigator) } + } + } + + private fun handleIntentAction(intent: Intent, navigator: Navigator) { + // Handle intent here + } + +} \ No newline at end of file diff --git a/app/src/main/java/dev/achmad/ledgerr/ui/screens/home/HomeScreen.kt b/app/src/main/java/dev/achmad/ledgerr/ui/screens/home/HomeScreen.kt new file mode 100644 index 0000000..62aff2f --- /dev/null +++ b/app/src/main/java/dev/achmad/ledgerr/ui/screens/home/HomeScreen.kt @@ -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() { + + } +} \ No newline at end of file diff --git a/app/src/main/java/dev/achmad/ledgerr/ui/theme/Theme.kt b/app/src/main/java/dev/achmad/ledgerr/ui/theme/Theme.kt new file mode 100644 index 0000000..369aa65 --- /dev/null +++ b/app/src/main/java/dev/achmad/ledgerr/ui/theme/Theme.kt @@ -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) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/dev/achmad/ledgerr/ui/theme/Type.kt b/app/src/main/java/dev/achmad/ledgerr/ui/theme/Type.kt new file mode 100644 index 0000000..24ec6ab --- /dev/null +++ b/app/src/main/java/dev/achmad/ledgerr/ui/theme/Type.kt @@ -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 + ) + */ +) \ No newline at end of file diff --git a/app/src/main/keepRules/rules.keep b/app/src/main/keepRules/rules.keep new file mode 100644 index 0000000..d7e081a --- /dev/null +++ b/app/src/main/keepRules/rules.keep @@ -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 *; +#} \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..07d5da9 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..2b068d1 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi/ic_launcher.xml b/app/src/main/res/mipmap-anydpi/ic_launcher.xml new file mode 100644 index 0000000..6f3b755 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml new file mode 100644 index 0000000..6f3b755 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/app/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 0000000..c209e78 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 0000000..b2dfe3d Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/app/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 0000000..4f0f1d6 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp new file mode 100644 index 0000000..62b611d Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 0000000..948a307 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..1b9a695 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp new file mode 100644 index 0000000..28d4b77 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..9287f50 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp new file mode 100644 index 0000000..aa7d642 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..9126ae3 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..f8c6127 --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +1,10 @@ + + + #FFBB86FC + #FF6200EE + #FF3700B3 + #FF03DAC5 + #FF018786 + #FF000000 + #FFFFFFFF + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..b53c7f8 --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + Ledgerr + \ No newline at end of file diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..67d3a14 --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,5 @@ + + + +