From 47bca4d678f9a2529f51e0bc818fcfab2e7e7d7d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Orzech?= Date: Sat, 31 Jan 2026 01:15:54 +0100 Subject: [PATCH] Initial commit --- .gitattributes | 2 + .gitignore | 27 ++ .gradle/config.properties | 2 + .idea/.gitignore | 3 + .idea/AndroidProjectSystem.xml | 6 + .idea/gradle.xml | 12 + .idea/migrations.xml | 10 + .idea/misc.xml | 10 + .idea/runConfigurations.xml | 17 + LICENSE | 21 + README.md | 2 + app/build.gradle.kts | 114 +++++ app/proguard-rules.pro | 39 ++ app/src/main/AndroidManifest.xml | 29 ++ .../java/com/fastmask/FastMaskApplication.kt | 7 + .../main/java/com/fastmask/MainActivity.kt | 50 +++ .../java/com/fastmask/data/api/JmapApi.kt | 259 ++++++++++++ .../java/com/fastmask/data/api/JmapModels.kt | 107 +++++ .../java/com/fastmask/data/api/JmapService.kt | 33 ++ .../com/fastmask/data/local/TokenStorage.kt | 47 +++ .../data/repository/AuthRepositoryImpl.kt | 33 ++ .../repository/MaskedEmailRepositoryImpl.kt | 109 +++++ .../java/com/fastmask/di/NetworkModule.kt | 66 +++ .../java/com/fastmask/di/RepositoryModule.kt | 28 ++ .../com/fastmask/domain/model/MaskedEmail.kt | 61 +++ .../domain/repository/AuthRepository.kt | 8 + .../repository/MaskedEmailRepository.kt | 12 + .../usecase/CreateMaskedEmailUseCase.kt | 23 + .../usecase/DeleteMaskedEmailUseCase.kt | 12 + .../domain/usecase/GetMaskedEmailsUseCase.kt | 13 + .../fastmask/domain/usecase/LoginUseCase.kt | 15 + .../fastmask/domain/usecase/LogoutUseCase.kt | 12 + .../usecase/UpdateMaskedEmailUseCase.kt | 13 + .../java/com/fastmask/ui/auth/LoginScreen.kt | 181 ++++++++ .../com/fastmask/ui/auth/LoginViewModel.kt | 68 +++ .../fastmask/ui/components/ErrorMessage.kt | 55 +++ .../ui/components/LoadingIndicator.kt | 26 ++ .../fastmask/ui/components/MaskedEmailCard.kt | 96 +++++ .../ui/create/CreateMaskedEmailScreen.kt | 197 +++++++++ .../ui/create/CreateMaskedEmailViewModel.kt | 103 +++++ .../ui/detail/MaskedEmailDetailScreen.kt | 393 ++++++++++++++++++ .../ui/detail/MaskedEmailDetailViewModel.kt | 196 +++++++++ .../fastmask/ui/list/MaskedEmailListScreen.kt | 246 +++++++++++ .../ui/list/MaskedEmailListViewModel.kt | 132 ++++++ .../fastmask/ui/navigation/FastMaskNavHost.kt | 73 ++++ .../com/fastmask/ui/navigation/NavRoutes.kt | 10 + .../main/java/com/fastmask/ui/theme/Color.kt | 20 + .../main/java/com/fastmask/ui/theme/Theme.kt | 74 ++++ .../main/java/com/fastmask/ui/theme/Type.kt | 73 ++++ .../res/drawable/ic_launcher_background.xml | 10 + .../res/drawable/ic_launcher_foreground.xml | 24 ++ .../res/mipmap-anydpi-v26/ic_launcher.xml | 5 + .../mipmap-anydpi-v26/ic_launcher_round.xml | 5 + app/src/main/res/values/colors.xml | 11 + app/src/main/res/values/strings.xml | 4 + app/src/main/res/values/themes.xml | 7 + app/src/main/res/xml/backup_rules.xml | 4 + .../main/res/xml/data_extraction_rules.xml | 9 + build.gradle.kts | 6 + gradle.properties | 4 + gradle/wrapper/gradle-wrapper.properties | 7 + gradlew | 189 +++++++++ gradlew.bat | 92 ++++ local.properties | 8 + settings.gradle.kts | 18 + 65 files changed, 3548 insertions(+) create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 .gradle/config.properties create mode 100644 .idea/.gitignore create mode 100644 .idea/AndroidProjectSystem.xml create mode 100644 .idea/gradle.xml create mode 100644 .idea/migrations.xml create mode 100644 .idea/misc.xml create mode 100644 .idea/runConfigurations.xml create mode 100644 LICENSE create mode 100644 README.md create mode 100644 app/build.gradle.kts create mode 100644 app/proguard-rules.pro create mode 100644 app/src/main/AndroidManifest.xml create mode 100644 app/src/main/java/com/fastmask/FastMaskApplication.kt create mode 100644 app/src/main/java/com/fastmask/MainActivity.kt create mode 100644 app/src/main/java/com/fastmask/data/api/JmapApi.kt create mode 100644 app/src/main/java/com/fastmask/data/api/JmapModels.kt create mode 100644 app/src/main/java/com/fastmask/data/api/JmapService.kt create mode 100644 app/src/main/java/com/fastmask/data/local/TokenStorage.kt create mode 100644 app/src/main/java/com/fastmask/data/repository/AuthRepositoryImpl.kt create mode 100644 app/src/main/java/com/fastmask/data/repository/MaskedEmailRepositoryImpl.kt create mode 100644 app/src/main/java/com/fastmask/di/NetworkModule.kt create mode 100644 app/src/main/java/com/fastmask/di/RepositoryModule.kt create mode 100644 app/src/main/java/com/fastmask/domain/model/MaskedEmail.kt create mode 100644 app/src/main/java/com/fastmask/domain/repository/AuthRepository.kt create mode 100644 app/src/main/java/com/fastmask/domain/repository/MaskedEmailRepository.kt create mode 100644 app/src/main/java/com/fastmask/domain/usecase/CreateMaskedEmailUseCase.kt create mode 100644 app/src/main/java/com/fastmask/domain/usecase/DeleteMaskedEmailUseCase.kt create mode 100644 app/src/main/java/com/fastmask/domain/usecase/GetMaskedEmailsUseCase.kt create mode 100644 app/src/main/java/com/fastmask/domain/usecase/LoginUseCase.kt create mode 100644 app/src/main/java/com/fastmask/domain/usecase/LogoutUseCase.kt create mode 100644 app/src/main/java/com/fastmask/domain/usecase/UpdateMaskedEmailUseCase.kt create mode 100644 app/src/main/java/com/fastmask/ui/auth/LoginScreen.kt create mode 100644 app/src/main/java/com/fastmask/ui/auth/LoginViewModel.kt create mode 100644 app/src/main/java/com/fastmask/ui/components/ErrorMessage.kt create mode 100644 app/src/main/java/com/fastmask/ui/components/LoadingIndicator.kt create mode 100644 app/src/main/java/com/fastmask/ui/components/MaskedEmailCard.kt create mode 100644 app/src/main/java/com/fastmask/ui/create/CreateMaskedEmailScreen.kt create mode 100644 app/src/main/java/com/fastmask/ui/create/CreateMaskedEmailViewModel.kt create mode 100644 app/src/main/java/com/fastmask/ui/detail/MaskedEmailDetailScreen.kt create mode 100644 app/src/main/java/com/fastmask/ui/detail/MaskedEmailDetailViewModel.kt create mode 100644 app/src/main/java/com/fastmask/ui/list/MaskedEmailListScreen.kt create mode 100644 app/src/main/java/com/fastmask/ui/list/MaskedEmailListViewModel.kt create mode 100644 app/src/main/java/com/fastmask/ui/navigation/FastMaskNavHost.kt create mode 100644 app/src/main/java/com/fastmask/ui/navigation/NavRoutes.kt create mode 100644 app/src/main/java/com/fastmask/ui/theme/Color.kt create mode 100644 app/src/main/java/com/fastmask/ui/theme/Theme.kt create mode 100644 app/src/main/java/com/fastmask/ui/theme/Type.kt create mode 100644 app/src/main/res/drawable/ic_launcher_background.xml create mode 100644 app/src/main/res/drawable/ic_launcher_foreground.xml create mode 100644 app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml create mode 100644 app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml create mode 100644 app/src/main/res/values/colors.xml create mode 100644 app/src/main/res/values/strings.xml create mode 100644 app/src/main/res/values/themes.xml create mode 100644 app/src/main/res/xml/backup_rules.xml create mode 100644 app/src/main/res/xml/data_extraction_rules.xml create mode 100644 build.gradle.kts create mode 100644 gradle.properties create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100755 gradlew create mode 100644 gradlew.bat create mode 100644 local.properties create mode 100644 settings.gradle.kts diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..dfe0770 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Auto detect text files and perform LF normalization +* text=auto diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..566e06b --- /dev/null +++ b/.gitignore @@ -0,0 +1,27 @@ +# Compiled class file +*.class + +# Log file +*.log + +# BlueJ files +*.ctxt + +# Mobile Tools for Java (J2ME) +.mtj.tmp/ + +# Package Files # +*.jar +*.war +*.nar +*.ear +*.zip +*.tar.gz +*.rar + +# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml +hs_err_pid* +replay_pid* + +# Kotlin Gradle plugin data, see https://kotlinlang.org/docs/whatsnew20.html#new-directory-for-kotlin-data-in-gradle-projects +.kotlin/ \ No newline at end of file diff --git a/.gradle/config.properties b/.gradle/config.properties new file mode 100644 index 0000000..1c10364 --- /dev/null +++ b/.gradle/config.properties @@ -0,0 +1,2 @@ +#Sat Jan 31 01:15:24 CET 2026 +java.home=/Applications/Android Studio.app/Contents/jbr/Contents/Home 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/gradle.xml b/.idea/gradle.xml new file mode 100644 index 0000000..b838237 --- /dev/null +++ b/.idea/gradle.xml @@ -0,0 +1,12 @@ + + + + + + \ No newline at end of file diff --git a/.idea/migrations.xml b/.idea/migrations.xml new file mode 100644 index 0000000..f8051a6 --- /dev/null +++ b/.idea/migrations.xml @@ -0,0 +1,10 @@ + + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..3040d03 --- /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/LICENSE b/LICENSE new file mode 100644 index 0000000..07028ca --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Paweł Orzech + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..0f59331 --- /dev/null +++ b/README.md @@ -0,0 +1,2 @@ +# FastMask + diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..9bb367a --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,114 @@ +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.android") + id("com.google.dagger.hilt.android") + id("org.jetbrains.kotlin.plugin.serialization") + kotlin("kapt") +} + +android { + namespace = "com.fastmask" + compileSdk = 34 + + defaultConfig { + applicationId = "com.fastmask" + minSdk = 26 + targetSdk = 34 + versionCode = 1 + versionName = "1.0" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + vectorDrawables { + useSupportLibrary = true + } + } + + buildTypes { + release { + isMinifyEnabled = true + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = "17" + } + + buildFeatures { + compose = true + } + + composeOptions { + kotlinCompilerExtensionVersion = "1.5.8" + } + + packaging { + resources { + excludes += "/META-INF/{AL2.0,LGPL2.1}" + } + } +} + +dependencies { + // Core Android + implementation("androidx.core:core-ktx:1.12.0") + implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0") + implementation("androidx.activity:activity-compose:1.8.2") + + // Compose + implementation(platform("androidx.compose:compose-bom:2024.01.00")) + implementation("androidx.compose.ui:ui") + implementation("androidx.compose.ui:ui-graphics") + implementation("androidx.compose.ui:ui-tooling-preview") + implementation("androidx.compose.material3:material3") + implementation("androidx.compose.material:material-icons-extended") + + // Navigation + implementation("androidx.navigation:navigation-compose:2.7.6") + implementation("androidx.hilt:hilt-navigation-compose:1.1.0") + + // Hilt + implementation("com.google.dagger:hilt-android:2.50") + kapt("com.google.dagger:hilt-compiler:2.50") + + // Networking + implementation("com.squareup.retrofit2:retrofit:2.9.0") + implementation("com.squareup.okhttp3:okhttp:4.12.0") + implementation("com.squareup.okhttp3:logging-interceptor:4.12.0") + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.2") + implementation("com.jakewharton.retrofit:retrofit2-kotlinx-serialization-converter:1.0.0") + + // Security for encrypted storage + implementation("androidx.security:security-crypto:1.1.0-alpha06") + + // DataStore + implementation("androidx.datastore:datastore-preferences:1.0.0") + + // Lifecycle + implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0") + implementation("androidx.lifecycle:lifecycle-runtime-compose:2.7.0") + + // Coroutines + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3") + + // Testing + testImplementation("junit:junit:4.13.2") + androidTestImplementation("androidx.test.ext:junit:1.1.5") + androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1") + androidTestImplementation(platform("androidx.compose:compose-bom:2024.01.00")) + androidTestImplementation("androidx.compose.ui:ui-test-junit4") + debugImplementation("androidx.compose.ui:ui-tooling") + debugImplementation("androidx.compose.ui:ui-test-manifest") +} + +kapt { + correctErrorTypes = true +} diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..7e5ec41 --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,39 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle.kts. + +# Keep Kotlin serialization +-keepattributes *Annotation*, InnerClasses +-dontnote kotlinx.serialization.AnnotationsKt + +-keepclassmembers class kotlinx.serialization.json.** { + *** Companion; +} +-keepclasseswithmembers class kotlinx.serialization.json.** { + kotlinx.serialization.KSerializer serializer(...); +} + +# Keep JMAP models +-keep,includedescriptorclasses class com.fastmask.data.api.**$$serializer { *; } +-keepclassmembers class com.fastmask.data.api.** { + *** Companion; +} +-keepclasseswithmembers class com.fastmask.data.api.** { + kotlinx.serialization.KSerializer serializer(...); +} + +# Retrofit +-keepattributes Signature, InnerClasses, EnclosingMethod +-keepattributes RuntimeVisibleAnnotations, RuntimeVisibleParameterAnnotations +-keepclassmembers,allowshrinking,allowobfuscation interface * { + @retrofit2.http.* ; +} +-dontwarn org.codehaus.mojo.animal_sniffer.IgnoreJRERequirement +-dontwarn javax.annotation.** +-dontwarn kotlin.Unit +-dontwarn retrofit2.KotlinExtensions +-dontwarn retrofit2.KotlinExtensions$* + +# OkHttp +-dontwarn okhttp3.** +-dontwarn okio.** diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..a495fdf --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + diff --git a/app/src/main/java/com/fastmask/FastMaskApplication.kt b/app/src/main/java/com/fastmask/FastMaskApplication.kt new file mode 100644 index 0000000..d2e00fe --- /dev/null +++ b/app/src/main/java/com/fastmask/FastMaskApplication.kt @@ -0,0 +1,7 @@ +package com.fastmask + +import android.app.Application +import dagger.hilt.android.HiltAndroidApp + +@HiltAndroidApp +class FastMaskApplication : Application() diff --git a/app/src/main/java/com/fastmask/MainActivity.kt b/app/src/main/java/com/fastmask/MainActivity.kt new file mode 100644 index 0000000..1aea624 --- /dev/null +++ b/app/src/main/java/com/fastmask/MainActivity.kt @@ -0,0 +1,50 @@ +package com.fastmask + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.ui.Modifier +import androidx.navigation.compose.rememberNavController +import com.fastmask.domain.repository.AuthRepository +import com.fastmask.ui.navigation.FastMaskNavHost +import com.fastmask.ui.navigation.NavRoutes +import com.fastmask.ui.theme.FastMaskTheme +import dagger.hilt.android.AndroidEntryPoint +import javax.inject.Inject + +@AndroidEntryPoint +class MainActivity : ComponentActivity() { + + @Inject + lateinit var authRepository: AuthRepository + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + + setContent { + FastMaskTheme { + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background + ) { + val navController = rememberNavController() + val startDestination = if (authRepository.isLoggedIn()) { + NavRoutes.EMAIL_LIST + } else { + NavRoutes.LOGIN + } + + FastMaskNavHost( + navController = navController, + startDestination = startDestination + ) + } + } + } + } +} diff --git a/app/src/main/java/com/fastmask/data/api/JmapApi.kt b/app/src/main/java/com/fastmask/data/api/JmapApi.kt new file mode 100644 index 0000000..a593863 --- /dev/null +++ b/app/src/main/java/com/fastmask/data/api/JmapApi.kt @@ -0,0 +1,259 @@ +package com.fastmask.data.api + +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.decodeFromJsonElement +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class JmapApi @Inject constructor( + private val jmapService: JmapService, + private val json: Json +) { + private var cachedSession: JmapSession? = null + private var cachedAccountId: String? = null + + suspend fun getSession(token: String): Result = runCatching { + val authHeader = "Bearer $token" + jmapService.getSession(authHeader = authHeader).also { + cachedSession = it + cachedAccountId = it.primaryAccounts["https://www.fastmail.com/dev/maskedemail"] + ?: it.primaryAccounts.values.firstOrNull() + } + } + + fun getAccountId(): String? = cachedAccountId + + fun getApiUrl(): String = cachedSession?.apiUrl ?: JmapService.FASTMAIL_API_URL + + suspend fun getMaskedEmails(token: String): Result> = runCatching { + val accountId = cachedAccountId ?: throw IllegalStateException("Session not initialized") + val authHeader = "Bearer $token" + + val methodCall = buildJsonArray { + add(JsonPrimitive("MaskedEmail/get")) + add(buildJsonObject { + put("accountId", JsonPrimitive(accountId)) + }) + add(JsonPrimitive("0")) + } + + val request = JmapRequest( + using = JmapService.JMAP_CAPABILITIES, + methodCalls = listOf(methodCall) + ) + + val response = jmapService.executeMethod( + url = getApiUrl(), + authHeader = authHeader, + request = request + ) + + parseGetResponse(response) + } + + suspend fun createMaskedEmail( + token: String, + create: MaskedEmailCreate + ): Result = runCatching { + val accountId = cachedAccountId ?: throw IllegalStateException("Session not initialized") + val authHeader = "Bearer $token" + + val createObject = buildJsonObject { + put("state", JsonPrimitive(create.state.name.lowercase())) + create.forDomain?.let { put("forDomain", JsonPrimitive(it)) } + create.description?.let { put("description", JsonPrimitive(it)) } + create.emailPrefix?.let { put("emailPrefix", JsonPrimitive(it)) } + create.url?.let { put("url", JsonPrimitive(it)) } + } + + val methodCall = buildJsonArray { + add(JsonPrimitive("MaskedEmail/set")) + add(buildJsonObject { + put("accountId", JsonPrimitive(accountId)) + put("create", buildJsonObject { + put("new1", createObject) + }) + }) + add(JsonPrimitive("0")) + } + + val request = JmapRequest( + using = JmapService.JMAP_CAPABILITIES, + methodCalls = listOf(methodCall) + ) + + val response = jmapService.executeMethod( + url = getApiUrl(), + authHeader = authHeader, + request = request + ) + + parseSetResponseCreated(response, "new1") + } + + suspend fun updateMaskedEmail( + token: String, + id: String, + update: MaskedEmailUpdate + ): Result = runCatching { + val accountId = cachedAccountId ?: throw IllegalStateException("Session not initialized") + val authHeader = "Bearer $token" + + val updateObject = buildJsonObject { + update.state?.let { put("state", JsonPrimitive(it.name.lowercase())) } + update.forDomain?.let { put("forDomain", JsonPrimitive(it)) } + update.description?.let { put("description", JsonPrimitive(it)) } + update.url?.let { put("url", JsonPrimitive(it)) } + } + + val methodCall = buildJsonArray { + add(JsonPrimitive("MaskedEmail/set")) + add(buildJsonObject { + put("accountId", JsonPrimitive(accountId)) + put("update", buildJsonObject { + put(id, updateObject) + }) + }) + add(JsonPrimitive("0")) + } + + val request = JmapRequest( + using = JmapService.JMAP_CAPABILITIES, + methodCalls = listOf(methodCall) + ) + + val response = jmapService.executeMethod( + url = getApiUrl(), + authHeader = authHeader, + request = request + ) + + parseSetResponseUpdated(response, id) + } + + suspend fun deleteMaskedEmail(token: String, id: String): Result = runCatching { + val accountId = cachedAccountId ?: throw IllegalStateException("Session not initialized") + val authHeader = "Bearer $token" + + val methodCall = buildJsonArray { + add(JsonPrimitive("MaskedEmail/set")) + add(buildJsonObject { + put("accountId", JsonPrimitive(accountId)) + put("destroy", buildJsonArray { add(JsonPrimitive(id)) }) + }) + add(JsonPrimitive("0")) + } + + val request = JmapRequest( + using = JmapService.JMAP_CAPABILITIES, + methodCalls = listOf(methodCall) + ) + + val response = jmapService.executeMethod( + url = getApiUrl(), + authHeader = authHeader, + request = request + ) + + parseSetResponseDestroyed(response, id) + } + + private fun parseGetResponse(response: JmapResponse): List { + val methodResponse = response.methodResponses.firstOrNull() + ?: throw IllegalStateException("Empty response") + + val responseType = (methodResponse[0] as? JsonPrimitive)?.content + if (responseType == "error") { + val errorData = methodResponse[1] as? JsonObject + val errorType = (errorData?.get("type") as? JsonPrimitive)?.content + throw JmapException("JMAP Error: $errorType") + } + + val data = methodResponse[1] as? JsonObject + ?: throw IllegalStateException("Invalid response format") + + val getResponse: MaskedEmailGetResponse = json.decodeFromJsonElement(data) + return getResponse.list + } + + private fun parseSetResponseCreated(response: JmapResponse, createId: String): MaskedEmailDto { + val methodResponse = response.methodResponses.firstOrNull() + ?: throw IllegalStateException("Empty response") + + val responseType = (methodResponse[0] as? JsonPrimitive)?.content + if (responseType == "error") { + val errorData = methodResponse[1] as? JsonObject + val errorType = (errorData?.get("type") as? JsonPrimitive)?.content + throw JmapException("JMAP Error: $errorType") + } + + val data = methodResponse[1] as? JsonObject + ?: throw IllegalStateException("Invalid response format") + + val setResponse: MaskedEmailSetResponse = json.decodeFromJsonElement(data) + + setResponse.notCreated?.get(createId)?.let { error -> + throw JmapException("Failed to create: ${error.type} - ${error.description}") + } + + return setResponse.created?.get(createId) + ?: throw IllegalStateException("Created email not found in response") + } + + private fun parseSetResponseUpdated(response: JmapResponse, id: String) { + val methodResponse = response.methodResponses.firstOrNull() + ?: throw IllegalStateException("Empty response") + + val responseType = (methodResponse[0] as? JsonPrimitive)?.content + if (responseType == "error") { + val errorData = methodResponse[1] as? JsonObject + val errorType = (errorData?.get("type") as? JsonPrimitive)?.content + throw JmapException("JMAP Error: $errorType") + } + + val data = methodResponse[1] as? JsonObject + ?: throw IllegalStateException("Invalid response format") + + val setResponse: MaskedEmailSetResponse = json.decodeFromJsonElement(data) + + setResponse.notUpdated?.get(id)?.let { error -> + throw JmapException("Failed to update: ${error.type} - ${error.description}") + } + } + + private fun parseSetResponseDestroyed(response: JmapResponse, id: String) { + val methodResponse = response.methodResponses.firstOrNull() + ?: throw IllegalStateException("Empty response") + + val responseType = (methodResponse[0] as? JsonPrimitive)?.content + if (responseType == "error") { + val errorData = methodResponse[1] as? JsonObject + val errorType = (errorData?.get("type") as? JsonPrimitive)?.content + throw JmapException("JMAP Error: $errorType") + } + + val data = methodResponse[1] as? JsonObject + ?: throw IllegalStateException("Invalid response format") + + val setResponse: MaskedEmailSetResponse = json.decodeFromJsonElement(data) + + setResponse.notDestroyed?.get(id)?.let { error -> + throw JmapException("Failed to delete: ${error.type} - ${error.description}") + } + } + + fun clearSession() { + cachedSession = null + cachedAccountId = null + } +} + +class JmapException(message: String) : Exception(message) diff --git a/app/src/main/java/com/fastmask/data/api/JmapModels.kt b/app/src/main/java/com/fastmask/data/api/JmapModels.kt new file mode 100644 index 0000000..f0116c9 --- /dev/null +++ b/app/src/main/java/com/fastmask/data/api/JmapModels.kt @@ -0,0 +1,107 @@ +package com.fastmask.data.api + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject + +@Serializable +data class JmapSession( + val username: String, + val apiUrl: String, + val primaryAccounts: Map, + val accounts: Map, + val capabilities: JsonObject, + val state: String +) + +@Serializable +data class JmapAccount( + val name: String, + val isPersonal: Boolean = true, + val isReadOnly: Boolean = false, + val accountCapabilities: JsonObject = JsonObject(emptyMap()) +) + +@Serializable +data class JmapRequest( + val using: List, + val methodCalls: List +) + +@Serializable +data class JmapResponse( + val methodResponses: List, + val sessionState: String? = null +) + +@Serializable +data class MaskedEmailGetResponse( + val accountId: String, + val state: String, + val list: List, + val notFound: List = emptyList() +) + +@Serializable +data class MaskedEmailSetResponse( + val accountId: String, + val oldState: String? = null, + val newState: String, + val created: Map? = null, + val updated: Map? = null, + val destroyed: List? = null, + val notCreated: Map? = null, + val notUpdated: Map? = null, + val notDestroyed: Map? = null +) + +@Serializable +data class JmapSetError( + val type: String, + val description: String? = null +) + +@Serializable +data class MaskedEmailDto( + val id: String, + val email: String, + val state: MaskedEmailState, + val forDomain: String? = null, + val description: String? = null, + val createdBy: String? = null, + val url: String? = null, + val emailPrefix: String? = null, + val createdAt: String? = null, + val lastMessageAt: String? = null +) + +@Serializable +enum class MaskedEmailState { + @SerialName("pending") + PENDING, + @SerialName("enabled") + ENABLED, + @SerialName("disabled") + DISABLED, + @SerialName("deleted") + DELETED +} + +@Serializable +data class MaskedEmailCreate( + val state: MaskedEmailState = MaskedEmailState.ENABLED, + val forDomain: String? = null, + val description: String? = null, + val emailPrefix: String? = null, + val url: String? = null +) + +@Serializable +data class MaskedEmailUpdate( + val state: MaskedEmailState? = null, + val forDomain: String? = null, + val description: String? = null, + val url: String? = null +) diff --git a/app/src/main/java/com/fastmask/data/api/JmapService.kt b/app/src/main/java/com/fastmask/data/api/JmapService.kt new file mode 100644 index 0000000..cd20ec6 --- /dev/null +++ b/app/src/main/java/com/fastmask/data/api/JmapService.kt @@ -0,0 +1,33 @@ +package com.fastmask.data.api + +import retrofit2.http.Body +import retrofit2.http.GET +import retrofit2.http.Header +import retrofit2.http.POST +import retrofit2.http.Url + +interface JmapService { + + @GET + suspend fun getSession( + @Url url: String = FASTMAIL_SESSION_URL, + @Header("Authorization") authHeader: String + ): JmapSession + + @POST + suspend fun executeMethod( + @Url url: String, + @Header("Authorization") authHeader: String, + @Body request: JmapRequest + ): JmapResponse + + companion object { + const val FASTMAIL_SESSION_URL = "https://api.fastmail.com/jmap/session" + const val FASTMAIL_API_URL = "https://api.fastmail.com/jmap/api/" + + val JMAP_CAPABILITIES = listOf( + "urn:ietf:params:jmap:core", + "https://www.fastmail.com/dev/maskedemail" + ) + } +} diff --git a/app/src/main/java/com/fastmask/data/local/TokenStorage.kt b/app/src/main/java/com/fastmask/data/local/TokenStorage.kt new file mode 100644 index 0000000..f5ed142 --- /dev/null +++ b/app/src/main/java/com/fastmask/data/local/TokenStorage.kt @@ -0,0 +1,47 @@ +package com.fastmask.data.local + +import android.content.Context +import android.content.SharedPreferences +import androidx.security.crypto.EncryptedSharedPreferences +import androidx.security.crypto.MasterKey +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class TokenStorage @Inject constructor( + @ApplicationContext private val context: Context +) { + private val masterKey = MasterKey.Builder(context) + .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) + .build() + + private val sharedPreferences: SharedPreferences = EncryptedSharedPreferences.create( + context, + PREFS_FILE_NAME, + masterKey, + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM + ) + + fun saveToken(token: String) { + sharedPreferences.edit().putString(KEY_API_TOKEN, token).apply() + } + + fun getToken(): String? { + return sharedPreferences.getString(KEY_API_TOKEN, null) + } + + fun clearToken() { + sharedPreferences.edit().remove(KEY_API_TOKEN).apply() + } + + fun hasToken(): Boolean { + return getToken() != null + } + + companion object { + private const val PREFS_FILE_NAME = "fastmask_secure_prefs" + private const val KEY_API_TOKEN = "api_token" + } +} diff --git a/app/src/main/java/com/fastmask/data/repository/AuthRepositoryImpl.kt b/app/src/main/java/com/fastmask/data/repository/AuthRepositoryImpl.kt new file mode 100644 index 0000000..ac09623 --- /dev/null +++ b/app/src/main/java/com/fastmask/data/repository/AuthRepositoryImpl.kt @@ -0,0 +1,33 @@ +package com.fastmask.data.repository + +import com.fastmask.data.api.JmapApi +import com.fastmask.data.local.TokenStorage +import com.fastmask.domain.repository.AuthRepository +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class AuthRepositoryImpl @Inject constructor( + private val tokenStorage: TokenStorage, + private val jmapApi: JmapApi +) : AuthRepository { + + override suspend fun login(token: String): Result { + return jmapApi.getSession(token).map { + tokenStorage.saveToken(token) + } + } + + override fun logout() { + tokenStorage.clearToken() + jmapApi.clearSession() + } + + override fun isLoggedIn(): Boolean { + return tokenStorage.hasToken() + } + + override fun getToken(): String? { + return tokenStorage.getToken() + } +} diff --git a/app/src/main/java/com/fastmask/data/repository/MaskedEmailRepositoryImpl.kt b/app/src/main/java/com/fastmask/data/repository/MaskedEmailRepositoryImpl.kt new file mode 100644 index 0000000..9a5c999 --- /dev/null +++ b/app/src/main/java/com/fastmask/data/repository/MaskedEmailRepositoryImpl.kt @@ -0,0 +1,109 @@ +package com.fastmask.data.repository + +import com.fastmask.data.api.JmapApi +import com.fastmask.data.api.MaskedEmailCreate +import com.fastmask.data.api.MaskedEmailDto +import com.fastmask.data.api.MaskedEmailState +import com.fastmask.data.api.MaskedEmailUpdate +import com.fastmask.data.local.TokenStorage +import com.fastmask.domain.model.CreateMaskedEmailParams +import com.fastmask.domain.model.EmailState +import com.fastmask.domain.model.MaskedEmail +import com.fastmask.domain.model.UpdateMaskedEmailParams +import com.fastmask.domain.repository.MaskedEmailRepository +import java.time.Instant +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class MaskedEmailRepositoryImpl @Inject constructor( + private val jmapApi: JmapApi, + private val tokenStorage: TokenStorage +) : MaskedEmailRepository { + + override suspend fun getMaskedEmails(): Result> { + val token = tokenStorage.getToken() + ?: return Result.failure(IllegalStateException("Not authenticated")) + + return jmapApi.getMaskedEmails(token).map { dtos -> + dtos.map { it.toDomain() } + } + } + + override suspend fun createMaskedEmail(params: CreateMaskedEmailParams): Result { + val token = tokenStorage.getToken() + ?: return Result.failure(IllegalStateException("Not authenticated")) + + val create = MaskedEmailCreate( + state = params.state.toApi(), + forDomain = params.forDomain?.takeIf { it.isNotBlank() }, + description = params.description?.takeIf { it.isNotBlank() }, + emailPrefix = params.emailPrefix?.takeIf { it.isNotBlank() }, + url = params.url?.takeIf { it.isNotBlank() } + ) + + return jmapApi.createMaskedEmail(token, create).map { it.toDomain() } + } + + override suspend fun updateMaskedEmail(id: String, params: UpdateMaskedEmailParams): Result { + val token = tokenStorage.getToken() + ?: return Result.failure(IllegalStateException("Not authenticated")) + + val update = MaskedEmailUpdate( + state = params.state?.toApi(), + forDomain = params.forDomain, + description = params.description, + url = params.url + ) + + return jmapApi.updateMaskedEmail(token, id, update) + } + + override suspend fun deleteMaskedEmail(id: String): Result { + val token = tokenStorage.getToken() + ?: return Result.failure(IllegalStateException("Not authenticated")) + + return jmapApi.deleteMaskedEmail(token, id) + } +} + +private fun MaskedEmailDto.toDomain(): MaskedEmail { + return MaskedEmail( + id = id, + email = email, + state = state.toDomain(), + forDomain = forDomain, + description = description, + createdBy = createdBy, + url = url, + emailPrefix = emailPrefix, + createdAt = createdAt?.let { parseInstant(it) }, + lastMessageAt = lastMessageAt?.let { parseInstant(it) } + ) +} + +private fun MaskedEmailState.toDomain(): EmailState { + return when (this) { + MaskedEmailState.PENDING -> EmailState.PENDING + MaskedEmailState.ENABLED -> EmailState.ENABLED + MaskedEmailState.DISABLED -> EmailState.DISABLED + MaskedEmailState.DELETED -> EmailState.DELETED + } +} + +private fun EmailState.toApi(): MaskedEmailState { + return when (this) { + EmailState.PENDING -> MaskedEmailState.PENDING + EmailState.ENABLED -> MaskedEmailState.ENABLED + EmailState.DISABLED -> MaskedEmailState.DISABLED + EmailState.DELETED -> MaskedEmailState.DELETED + } +} + +private fun parseInstant(dateString: String): Instant? { + return try { + Instant.parse(dateString) + } catch (e: Exception) { + null + } +} diff --git a/app/src/main/java/com/fastmask/di/NetworkModule.kt b/app/src/main/java/com/fastmask/di/NetworkModule.kt new file mode 100644 index 0000000..7b38133 --- /dev/null +++ b/app/src/main/java/com/fastmask/di/NetworkModule.kt @@ -0,0 +1,66 @@ +package com.fastmask.di + +import com.fastmask.data.api.JmapService +import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import kotlinx.serialization.json.Json +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.logging.HttpLoggingInterceptor +import retrofit2.Retrofit +import java.util.concurrent.TimeUnit +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object NetworkModule { + + @Provides + @Singleton + fun provideJson(): Json { + return Json { + ignoreUnknownKeys = true + isLenient = true + encodeDefaults = true + explicitNulls = false + } + } + + @Provides + @Singleton + fun provideOkHttpClient(): OkHttpClient { + val loggingInterceptor = HttpLoggingInterceptor().apply { + level = HttpLoggingInterceptor.Level.BODY + } + + return OkHttpClient.Builder() + .addInterceptor(loggingInterceptor) + .connectTimeout(30, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .writeTimeout(30, TimeUnit.SECONDS) + .build() + } + + @Provides + @Singleton + fun provideRetrofit( + okHttpClient: OkHttpClient, + json: Json + ): Retrofit { + val contentType = "application/json".toMediaType() + return Retrofit.Builder() + .baseUrl(JmapService.FASTMAIL_API_URL) + .client(okHttpClient) + .addConverterFactory(json.asConverterFactory(contentType)) + .build() + } + + @Provides + @Singleton + fun provideJmapService(retrofit: Retrofit): JmapService { + return retrofit.create(JmapService::class.java) + } +} diff --git a/app/src/main/java/com/fastmask/di/RepositoryModule.kt b/app/src/main/java/com/fastmask/di/RepositoryModule.kt new file mode 100644 index 0000000..c464989 --- /dev/null +++ b/app/src/main/java/com/fastmask/di/RepositoryModule.kt @@ -0,0 +1,28 @@ +package com.fastmask.di + +import com.fastmask.data.repository.AuthRepositoryImpl +import com.fastmask.data.repository.MaskedEmailRepositoryImpl +import com.fastmask.domain.repository.AuthRepository +import com.fastmask.domain.repository.MaskedEmailRepository +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +abstract class RepositoryModule { + + @Binds + @Singleton + abstract fun bindAuthRepository( + authRepositoryImpl: AuthRepositoryImpl + ): AuthRepository + + @Binds + @Singleton + abstract fun bindMaskedEmailRepository( + maskedEmailRepositoryImpl: MaskedEmailRepositoryImpl + ): MaskedEmailRepository +} diff --git a/app/src/main/java/com/fastmask/domain/model/MaskedEmail.kt b/app/src/main/java/com/fastmask/domain/model/MaskedEmail.kt new file mode 100644 index 0000000..1b81bb6 --- /dev/null +++ b/app/src/main/java/com/fastmask/domain/model/MaskedEmail.kt @@ -0,0 +1,61 @@ +package com.fastmask.domain.model + +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle + +data class MaskedEmail( + val id: String, + val email: String, + val state: EmailState, + val forDomain: String?, + val description: String?, + val createdBy: String?, + val url: String?, + val emailPrefix: String?, + val createdAt: Instant?, + val lastMessageAt: Instant? +) { + val displayName: String + get() = description?.takeIf { it.isNotBlank() } + ?: forDomain?.takeIf { it.isNotBlank() } + ?: email.substringBefore("@") + + val formattedCreatedAt: String? + get() = createdAt?.let { formatInstant(it) } + + val formattedLastMessageAt: String? + get() = lastMessageAt?.let { formatInstant(it) } + + val isActive: Boolean + get() = state == EmailState.ENABLED || state == EmailState.PENDING + + private fun formatInstant(instant: Instant): String { + val formatter = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM) + .withZone(ZoneId.systemDefault()) + return formatter.format(instant) + } +} + +enum class EmailState { + PENDING, + ENABLED, + DISABLED, + DELETED +} + +data class CreateMaskedEmailParams( + val state: EmailState = EmailState.ENABLED, + val forDomain: String? = null, + val description: String? = null, + val emailPrefix: String? = null, + val url: String? = null +) + +data class UpdateMaskedEmailParams( + val state: EmailState? = null, + val forDomain: String? = null, + val description: String? = null, + val url: String? = null +) diff --git a/app/src/main/java/com/fastmask/domain/repository/AuthRepository.kt b/app/src/main/java/com/fastmask/domain/repository/AuthRepository.kt new file mode 100644 index 0000000..11bec86 --- /dev/null +++ b/app/src/main/java/com/fastmask/domain/repository/AuthRepository.kt @@ -0,0 +1,8 @@ +package com.fastmask.domain.repository + +interface AuthRepository { + suspend fun login(token: String): Result + fun logout() + fun isLoggedIn(): Boolean + fun getToken(): String? +} diff --git a/app/src/main/java/com/fastmask/domain/repository/MaskedEmailRepository.kt b/app/src/main/java/com/fastmask/domain/repository/MaskedEmailRepository.kt new file mode 100644 index 0000000..a16b456 --- /dev/null +++ b/app/src/main/java/com/fastmask/domain/repository/MaskedEmailRepository.kt @@ -0,0 +1,12 @@ +package com.fastmask.domain.repository + +import com.fastmask.domain.model.CreateMaskedEmailParams +import com.fastmask.domain.model.MaskedEmail +import com.fastmask.domain.model.UpdateMaskedEmailParams + +interface MaskedEmailRepository { + suspend fun getMaskedEmails(): Result> + suspend fun createMaskedEmail(params: CreateMaskedEmailParams): Result + suspend fun updateMaskedEmail(id: String, params: UpdateMaskedEmailParams): Result + suspend fun deleteMaskedEmail(id: String): Result +} diff --git a/app/src/main/java/com/fastmask/domain/usecase/CreateMaskedEmailUseCase.kt b/app/src/main/java/com/fastmask/domain/usecase/CreateMaskedEmailUseCase.kt new file mode 100644 index 0000000..17dc03e --- /dev/null +++ b/app/src/main/java/com/fastmask/domain/usecase/CreateMaskedEmailUseCase.kt @@ -0,0 +1,23 @@ +package com.fastmask.domain.usecase + +import com.fastmask.domain.model.CreateMaskedEmailParams +import com.fastmask.domain.model.MaskedEmail +import com.fastmask.domain.repository.MaskedEmailRepository +import javax.inject.Inject + +class CreateMaskedEmailUseCase @Inject constructor( + private val repository: MaskedEmailRepository +) { + suspend operator fun invoke(params: CreateMaskedEmailParams): Result { + if (params.emailPrefix != null) { + val prefix = params.emailPrefix + if (prefix.length > 64) { + return Result.failure(IllegalArgumentException("Email prefix must be 64 characters or less")) + } + if (!prefix.matches(Regex("^[a-z0-9_]*$"))) { + return Result.failure(IllegalArgumentException("Email prefix can only contain lowercase letters, numbers, and underscores")) + } + } + return repository.createMaskedEmail(params) + } +} diff --git a/app/src/main/java/com/fastmask/domain/usecase/DeleteMaskedEmailUseCase.kt b/app/src/main/java/com/fastmask/domain/usecase/DeleteMaskedEmailUseCase.kt new file mode 100644 index 0000000..db59d45 --- /dev/null +++ b/app/src/main/java/com/fastmask/domain/usecase/DeleteMaskedEmailUseCase.kt @@ -0,0 +1,12 @@ +package com.fastmask.domain.usecase + +import com.fastmask.domain.repository.MaskedEmailRepository +import javax.inject.Inject + +class DeleteMaskedEmailUseCase @Inject constructor( + private val repository: MaskedEmailRepository +) { + suspend operator fun invoke(id: String): Result { + return repository.deleteMaskedEmail(id) + } +} diff --git a/app/src/main/java/com/fastmask/domain/usecase/GetMaskedEmailsUseCase.kt b/app/src/main/java/com/fastmask/domain/usecase/GetMaskedEmailsUseCase.kt new file mode 100644 index 0000000..f258310 --- /dev/null +++ b/app/src/main/java/com/fastmask/domain/usecase/GetMaskedEmailsUseCase.kt @@ -0,0 +1,13 @@ +package com.fastmask.domain.usecase + +import com.fastmask.domain.model.MaskedEmail +import com.fastmask.domain.repository.MaskedEmailRepository +import javax.inject.Inject + +class GetMaskedEmailsUseCase @Inject constructor( + private val repository: MaskedEmailRepository +) { + suspend operator fun invoke(): Result> { + return repository.getMaskedEmails() + } +} diff --git a/app/src/main/java/com/fastmask/domain/usecase/LoginUseCase.kt b/app/src/main/java/com/fastmask/domain/usecase/LoginUseCase.kt new file mode 100644 index 0000000..3617306 --- /dev/null +++ b/app/src/main/java/com/fastmask/domain/usecase/LoginUseCase.kt @@ -0,0 +1,15 @@ +package com.fastmask.domain.usecase + +import com.fastmask.domain.repository.AuthRepository +import javax.inject.Inject + +class LoginUseCase @Inject constructor( + private val authRepository: AuthRepository +) { + suspend operator fun invoke(token: String): Result { + if (token.isBlank()) { + return Result.failure(IllegalArgumentException("API token cannot be empty")) + } + return authRepository.login(token) + } +} diff --git a/app/src/main/java/com/fastmask/domain/usecase/LogoutUseCase.kt b/app/src/main/java/com/fastmask/domain/usecase/LogoutUseCase.kt new file mode 100644 index 0000000..43f8aae --- /dev/null +++ b/app/src/main/java/com/fastmask/domain/usecase/LogoutUseCase.kt @@ -0,0 +1,12 @@ +package com.fastmask.domain.usecase + +import com.fastmask.domain.repository.AuthRepository +import javax.inject.Inject + +class LogoutUseCase @Inject constructor( + private val authRepository: AuthRepository +) { + operator fun invoke() { + authRepository.logout() + } +} diff --git a/app/src/main/java/com/fastmask/domain/usecase/UpdateMaskedEmailUseCase.kt b/app/src/main/java/com/fastmask/domain/usecase/UpdateMaskedEmailUseCase.kt new file mode 100644 index 0000000..5dde601 --- /dev/null +++ b/app/src/main/java/com/fastmask/domain/usecase/UpdateMaskedEmailUseCase.kt @@ -0,0 +1,13 @@ +package com.fastmask.domain.usecase + +import com.fastmask.domain.model.UpdateMaskedEmailParams +import com.fastmask.domain.repository.MaskedEmailRepository +import javax.inject.Inject + +class UpdateMaskedEmailUseCase @Inject constructor( + private val repository: MaskedEmailRepository +) { + suspend operator fun invoke(id: String, params: UpdateMaskedEmailParams): Result { + return repository.updateMaskedEmail(id, params) + } +} diff --git a/app/src/main/java/com/fastmask/ui/auth/LoginScreen.kt b/app/src/main/java/com/fastmask/ui/auth/LoginScreen.kt new file mode 100644 index 0000000..159bf10 --- /dev/null +++ b/app/src/main/java/com/fastmask/ui/auth/LoginScreen.kt @@ -0,0 +1,181 @@ +package com.fastmask.ui.auth + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Email +import androidx.compose.material.icons.filled.Visibility +import androidx.compose.material.icons.filled.VisibilityOff +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import kotlinx.coroutines.flow.collectLatest + +@Composable +fun LoginScreen( + onLoginSuccess: () -> Unit, + viewModel: LoginViewModel = hiltViewModel() +) { + val uiState by viewModel.uiState.collectAsState() + var showToken by remember { mutableStateOf(false) } + + LaunchedEffect(Unit) { + viewModel.events.collectLatest { event -> + when (event) { + is LoginEvent.LoginSuccess -> onLoginSuccess() + } + } + } + + Scaffold { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .padding(24.dp) + .verticalScroll(rememberScrollState()), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Icon( + imageVector = Icons.Default.Email, + contentDescription = null, + modifier = Modifier.size(80.dp), + tint = MaterialTheme.colorScheme.primary + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = "FastMask", + style = MaterialTheme.typography.headlineLarge, + color = MaterialTheme.colorScheme.primary + ) + + Text( + text = "Fastmail Masked Email Manager", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + Spacer(modifier = Modifier.height(48.dp)) + + OutlinedTextField( + value = uiState.token, + onValueChange = viewModel::onTokenChange, + label = { Text("API Token") }, + placeholder = { Text("Enter your Fastmail API token") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + visualTransformation = if (showToken) VisualTransformation.None else PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Done + ), + keyboardActions = KeyboardActions( + onDone = { viewModel.login() } + ), + trailingIcon = { + IconButton(onClick = { showToken = !showToken }) { + Icon( + imageVector = if (showToken) Icons.Default.VisibilityOff else Icons.Default.Visibility, + contentDescription = if (showToken) "Hide token" else "Show token" + ) + } + }, + isError = uiState.error != null, + enabled = !uiState.isLoading + ) + + if (uiState.error != null) { + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = uiState.error!!, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall + ) + } + + Spacer(modifier = Modifier.height(24.dp)) + + Button( + onClick = viewModel::login, + modifier = Modifier.fillMaxWidth(), + enabled = !uiState.isLoading && uiState.token.isNotBlank() + ) { + if (uiState.isLoading) { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + color = MaterialTheme.colorScheme.onPrimary, + strokeWidth = 2.dp + ) + } else { + Text("Login") + } + } + + Spacer(modifier = Modifier.height(32.dp)) + + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant + ) + ) { + Column( + modifier = Modifier.padding(16.dp) + ) { + Text( + text = "How to get your API token:", + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "1. Log in to Fastmail web app\n" + + "2. Go to Settings > Privacy & Security\n" + + "3. Click on Integrations > API tokens\n" + + "4. Create a new token with \"Masked Email\" scope\n" + + "5. Copy the token and paste it above", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Start + ) + } + } + } + } +} diff --git a/app/src/main/java/com/fastmask/ui/auth/LoginViewModel.kt b/app/src/main/java/com/fastmask/ui/auth/LoginViewModel.kt new file mode 100644 index 0000000..000e6fa --- /dev/null +++ b/app/src/main/java/com/fastmask/ui/auth/LoginViewModel.kt @@ -0,0 +1,68 @@ +package com.fastmask.ui.auth + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.fastmask.domain.usecase.LoginUseCase +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class LoginViewModel @Inject constructor( + private val loginUseCase: LoginUseCase +) : ViewModel() { + + private val _uiState = MutableStateFlow(LoginUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private val _events = MutableSharedFlow() + val events: SharedFlow = _events.asSharedFlow() + + fun onTokenChange(token: String) { + _uiState.update { it.copy(token = token, error = null) } + } + + fun login() { + val token = _uiState.value.token.trim() + if (token.isBlank()) { + _uiState.update { it.copy(error = "Please enter your API token") } + return + } + + viewModelScope.launch { + _uiState.update { it.copy(isLoading = true, error = null) } + + loginUseCase(token).fold( + onSuccess = { + _uiState.update { it.copy(isLoading = false) } + _events.emit(LoginEvent.LoginSuccess) + }, + onFailure = { error -> + _uiState.update { + it.copy( + isLoading = false, + error = error.message ?: "Login failed" + ) + } + } + ) + } + } +} + +data class LoginUiState( + val token: String = "", + val isLoading: Boolean = false, + val error: String? = null +) + +sealed class LoginEvent { + data object LoginSuccess : LoginEvent() +} diff --git a/app/src/main/java/com/fastmask/ui/components/ErrorMessage.kt b/app/src/main/java/com/fastmask/ui/components/ErrorMessage.kt new file mode 100644 index 0000000..621d701 --- /dev/null +++ b/app/src/main/java/com/fastmask/ui/components/ErrorMessage.kt @@ -0,0 +1,55 @@ +package com.fastmask.ui.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Error +import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp + +@Composable +fun ErrorMessage( + message: String, + onRetry: (() -> Unit)? = null, + modifier: Modifier = Modifier +) { + Column( + modifier = modifier + .fillMaxSize() + .padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Icon( + imageVector = Icons.Default.Error, + contentDescription = null, + modifier = Modifier.size(64.dp), + tint = MaterialTheme.colorScheme.error + ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = message, + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurface + ) + if (onRetry != null) { + Spacer(modifier = Modifier.height(24.dp)) + Button(onClick = onRetry) { + Text("Retry") + } + } + } +} diff --git a/app/src/main/java/com/fastmask/ui/components/LoadingIndicator.kt b/app/src/main/java/com/fastmask/ui/components/LoadingIndicator.kt new file mode 100644 index 0000000..a59377e --- /dev/null +++ b/app/src/main/java/com/fastmask/ui/components/LoadingIndicator.kt @@ -0,0 +1,26 @@ +package com.fastmask.ui.components + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.size +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +@Composable +fun LoadingIndicator( + modifier: Modifier = Modifier +) { + Box( + modifier = modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator( + modifier = Modifier.size(48.dp), + color = MaterialTheme.colorScheme.primary + ) + } +} diff --git a/app/src/main/java/com/fastmask/ui/components/MaskedEmailCard.kt b/app/src/main/java/com/fastmask/ui/components/MaskedEmailCard.kt new file mode 100644 index 0000000..6c3301a --- /dev/null +++ b/app/src/main/java/com/fastmask/ui/components/MaskedEmailCard.kt @@ -0,0 +1,96 @@ +package com.fastmask.ui.components + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.HourglassEmpty +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.fastmask.domain.model.EmailState +import com.fastmask.domain.model.MaskedEmail +import com.fastmask.ui.theme.DeletedRed +import com.fastmask.ui.theme.DisabledGray +import com.fastmask.ui.theme.EnabledGreen +import com.fastmask.ui.theme.PendingOrange + +@Composable +fun MaskedEmailCard( + maskedEmail: MaskedEmail, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + Card( + modifier = modifier + .fillMaxWidth() + .clickable(onClick = onClick), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + StatusIcon(state = maskedEmail.state) + Spacer(modifier = Modifier.width(16.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = maskedEmail.displayName, + style = MaterialTheme.typography.titleMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + text = maskedEmail.email, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + maskedEmail.forDomain?.let { domain -> + Text( + text = domain, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + } + } +} + +@Composable +private fun StatusIcon(state: EmailState) { + val (icon, color) = when (state) { + EmailState.ENABLED -> Icons.Default.Check to EnabledGreen + EmailState.DISABLED -> Icons.Default.Close to DisabledGray + EmailState.DELETED -> Icons.Default.Delete to DeletedRed + EmailState.PENDING -> Icons.Default.HourglassEmpty to PendingOrange + } + + Icon( + imageVector = icon, + contentDescription = state.name, + modifier = Modifier.size(24.dp), + tint = color + ) +} diff --git a/app/src/main/java/com/fastmask/ui/create/CreateMaskedEmailScreen.kt b/app/src/main/java/com/fastmask/ui/create/CreateMaskedEmailScreen.kt new file mode 100644 index 0000000..e590b3e --- /dev/null +++ b/app/src/main/java/com/fastmask/ui/create/CreateMaskedEmailScreen.kt @@ -0,0 +1,197 @@ +package com.fastmask.ui.create + +import android.widget.Toast +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import com.fastmask.domain.model.EmailState +import kotlinx.coroutines.flow.collectLatest + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CreateMaskedEmailScreen( + onNavigateBack: () -> Unit, + viewModel: CreateMaskedEmailViewModel = hiltViewModel() +) { + val uiState by viewModel.uiState.collectAsState() + val context = LocalContext.current + + LaunchedEffect(Unit) { + viewModel.events.collectLatest { event -> + when (event) { + is CreateMaskedEmailEvent.Created -> { + Toast.makeText( + context, + "Created: ${event.email}", + Toast.LENGTH_LONG + ).show() + onNavigateBack() + } + } + } + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Create Masked Email") }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back" + ) + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer + ) + ) + } + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .padding(16.dp) + .verticalScroll(rememberScrollState()) + ) { + OutlinedTextField( + value = uiState.emailPrefix, + onValueChange = viewModel::onPrefixChange, + label = { Text("Email Prefix (optional)") }, + placeholder = { Text("e.g., mysite_shopping") }, + supportingText = { + Text( + text = uiState.prefixError + ?: "Max 64 chars: lowercase letters, numbers, underscores" + ) + }, + isError = uiState.prefixError != null, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + enabled = !uiState.isLoading + ) + + Spacer(modifier = Modifier.height(16.dp)) + + OutlinedTextField( + value = uiState.forDomain, + onValueChange = viewModel::onDomainChange, + label = { Text("Associated Domain (optional)") }, + placeholder = { Text("e.g., example.com") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + enabled = !uiState.isLoading + ) + + Spacer(modifier = Modifier.height(16.dp)) + + OutlinedTextField( + value = uiState.description, + onValueChange = viewModel::onDescriptionChange, + label = { Text("Description (optional)") }, + placeholder = { Text("e.g., Shopping account") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + enabled = !uiState.isLoading + ) + + Spacer(modifier = Modifier.height(16.dp)) + + OutlinedTextField( + value = uiState.url, + onValueChange = viewModel::onUrlChange, + label = { Text("URL (optional)") }, + placeholder = { Text("e.g., https://example.com") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + enabled = !uiState.isLoading + ) + + Spacer(modifier = Modifier.height(24.dp)) + + Text( + text = "Initial State", + style = MaterialTheme.typography.titleMedium + ) + + Spacer(modifier = Modifier.height(8.dp)) + + listOf(EmailState.ENABLED, EmailState.DISABLED).forEach { state -> + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + RadioButton( + selected = uiState.initialState == state, + onClick = { viewModel.onStateChange(state) }, + enabled = !uiState.isLoading + ) + Text( + text = state.name.lowercase().replaceFirstChar { it.uppercase() }, + style = MaterialTheme.typography.bodyLarge + ) + } + } + + if (uiState.error != null) { + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = uiState.error!!, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodyMedium + ) + } + + Spacer(modifier = Modifier.height(32.dp)) + + Button( + onClick = viewModel::create, + modifier = Modifier.fillMaxWidth(), + enabled = !uiState.isLoading && uiState.prefixError == null + ) { + if (uiState.isLoading) { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + color = MaterialTheme.colorScheme.onPrimary, + strokeWidth = 2.dp + ) + } else { + Text("Create Masked Email") + } + } + } + } +} diff --git a/app/src/main/java/com/fastmask/ui/create/CreateMaskedEmailViewModel.kt b/app/src/main/java/com/fastmask/ui/create/CreateMaskedEmailViewModel.kt new file mode 100644 index 0000000..36aed62 --- /dev/null +++ b/app/src/main/java/com/fastmask/ui/create/CreateMaskedEmailViewModel.kt @@ -0,0 +1,103 @@ +package com.fastmask.ui.create + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.fastmask.domain.model.CreateMaskedEmailParams +import com.fastmask.domain.model.EmailState +import com.fastmask.domain.usecase.CreateMaskedEmailUseCase +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class CreateMaskedEmailViewModel @Inject constructor( + private val createMaskedEmailUseCase: CreateMaskedEmailUseCase +) : ViewModel() { + + private val _uiState = MutableStateFlow(CreateMaskedEmailUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private val _events = MutableSharedFlow() + val events: SharedFlow = _events.asSharedFlow() + + fun onPrefixChange(prefix: String) { + val sanitized = prefix.lowercase().filter { it.isLetterOrDigit() || it == '_' } + val error = when { + sanitized.length > 64 -> "Prefix must be 64 characters or less" + sanitized.isNotEmpty() && !sanitized.matches(Regex("^[a-z0-9_]*$")) -> + "Only lowercase letters, numbers, and underscores allowed" + else -> null + } + _uiState.update { it.copy(emailPrefix = sanitized.take(64), prefixError = error) } + } + + fun onDomainChange(domain: String) { + _uiState.update { it.copy(forDomain = domain) } + } + + fun onDescriptionChange(description: String) { + _uiState.update { it.copy(description = description) } + } + + fun onUrlChange(url: String) { + _uiState.update { it.copy(url = url) } + } + + fun onStateChange(state: EmailState) { + _uiState.update { it.copy(initialState = state) } + } + + fun create() { + val state = _uiState.value + if (state.prefixError != null) return + + viewModelScope.launch { + _uiState.update { it.copy(isLoading = true, error = null) } + + val params = CreateMaskedEmailParams( + state = state.initialState, + forDomain = state.forDomain.takeIf { it.isNotBlank() }, + description = state.description.takeIf { it.isNotBlank() }, + emailPrefix = state.emailPrefix.takeIf { it.isNotBlank() }, + url = state.url.takeIf { it.isNotBlank() } + ) + + createMaskedEmailUseCase(params).fold( + onSuccess = { email -> + _uiState.update { it.copy(isLoading = false) } + _events.emit(CreateMaskedEmailEvent.Created(email.email)) + }, + onFailure = { error -> + _uiState.update { + it.copy( + isLoading = false, + error = error.message ?: "Failed to create masked email" + ) + } + } + ) + } + } +} + +data class CreateMaskedEmailUiState( + val emailPrefix: String = "", + val forDomain: String = "", + val description: String = "", + val url: String = "", + val initialState: EmailState = EmailState.ENABLED, + val isLoading: Boolean = false, + val error: String? = null, + val prefixError: String? = null +) + +sealed class CreateMaskedEmailEvent { + data class Created(val email: String) : CreateMaskedEmailEvent() +} diff --git a/app/src/main/java/com/fastmask/ui/detail/MaskedEmailDetailScreen.kt b/app/src/main/java/com/fastmask/ui/detail/MaskedEmailDetailScreen.kt new file mode 100644 index 0000000..282f684 --- /dev/null +++ b/app/src/main/java/com/fastmask/ui/detail/MaskedEmailDetailScreen.kt @@ -0,0 +1,393 @@ +package com.fastmask.ui.detail + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.widget.Toast +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import com.fastmask.domain.model.EmailState +import com.fastmask.ui.components.ErrorMessage +import com.fastmask.ui.components.LoadingIndicator +import com.fastmask.ui.theme.DeletedRed +import com.fastmask.ui.theme.DisabledGray +import com.fastmask.ui.theme.EnabledGreen +import com.fastmask.ui.theme.PendingOrange +import kotlinx.coroutines.flow.collectLatest + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun MaskedEmailDetailScreen( + onNavigateBack: () -> Unit, + viewModel: MaskedEmailDetailViewModel = hiltViewModel() +) { + val uiState by viewModel.uiState.collectAsState() + val context = LocalContext.current + var showDeleteDialog by remember { mutableStateOf(false) } + + LaunchedEffect(Unit) { + viewModel.events.collectLatest { event -> + when (event) { + is MaskedEmailDetailEvent.Updated -> { + Toast.makeText(context, "Updated successfully", Toast.LENGTH_SHORT).show() + } + is MaskedEmailDetailEvent.Deleted -> { + Toast.makeText(context, "Deleted successfully", Toast.LENGTH_SHORT).show() + onNavigateBack() + } + } + } + } + + if (showDeleteDialog) { + AlertDialog( + onDismissRequest = { showDeleteDialog = false }, + title = { Text("Delete Masked Email") }, + text = { Text("Are you sure you want to delete this masked email? This action cannot be undone.") }, + confirmButton = { + TextButton( + onClick = { + showDeleteDialog = false + viewModel.delete() + }, + colors = ButtonDefaults.textButtonColors( + contentColor = MaterialTheme.colorScheme.error + ) + ) { + Text("Delete") + } + }, + dismissButton = { + TextButton(onClick = { showDeleteDialog = false }) { + Text("Cancel") + } + } + ) + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Email Details") }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back" + ) + } + }, + actions = { + if (uiState.email != null) { + IconButton(onClick = { showDeleteDialog = true }) { + Icon( + imageVector = Icons.Default.Delete, + contentDescription = "Delete", + tint = MaterialTheme.colorScheme.error + ) + } + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer + ) + ) + } + ) { paddingValues -> + when { + uiState.isLoading && uiState.email == null -> { + LoadingIndicator( + modifier = Modifier.padding(paddingValues) + ) + } + + uiState.error != null && uiState.email == null -> { + ErrorMessage( + message = uiState.error!!, + onRetry = viewModel::loadEmail, + modifier = Modifier.padding(paddingValues) + ) + } + + uiState.email != null -> { + EmailDetailContent( + uiState = uiState, + onDescriptionChange = viewModel::onDescriptionChange, + onForDomainChange = viewModel::onForDomainChange, + onUrlChange = viewModel::onUrlChange, + onToggleState = viewModel::toggleState, + onSaveChanges = viewModel::saveChanges, + modifier = Modifier.padding(paddingValues) + ) + } + } + } +} + +@Composable +private fun EmailDetailContent( + uiState: MaskedEmailDetailUiState, + onDescriptionChange: (String) -> Unit, + onForDomainChange: (String) -> Unit, + onUrlChange: (String) -> Unit, + onToggleState: () -> Unit, + onSaveChanges: () -> Unit, + modifier: Modifier = Modifier +) { + val email = uiState.email!! + val context = LocalContext.current + + Column( + modifier = modifier + .fillMaxSize() + .padding(16.dp) + .verticalScroll(rememberScrollState()) + ) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant + ) + ) { + Column( + modifier = Modifier.padding(16.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = "Email Address", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + text = email.email, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + } + IconButton( + onClick = { + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + val clip = ClipData.newPlainText("Email", email.email) + clipboard.setPrimaryClip(clip) + Toast.makeText(context, "Copied to clipboard", Toast.LENGTH_SHORT).show() + } + ) { + Icon( + imageVector = Icons.Default.ContentCopy, + contentDescription = "Copy email" + ) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "Status: ", + style = MaterialTheme.typography.bodyMedium + ) + val (statusText, statusColor) = when (email.state) { + EmailState.ENABLED -> "Enabled" to EnabledGreen + EmailState.DISABLED -> "Disabled" to DisabledGray + EmailState.DELETED -> "Deleted" to DeletedRed + EmailState.PENDING -> "Pending" to PendingOrange + } + Text( + text = statusText, + style = MaterialTheme.typography.bodyMedium, + color = statusColor, + fontWeight = FontWeight.Bold + ) + } + + email.createdBy?.let { createdBy -> + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "Created by: $createdBy", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + email.formattedCreatedAt?.let { createdAt -> + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = "Created: $createdAt", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + email.formattedLastMessageAt?.let { lastMessage -> + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = "Last message: $lastMessage", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + + Spacer(modifier = Modifier.height(24.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + if (email.state == EmailState.DISABLED || email.state == EmailState.DELETED) { + Button( + onClick = onToggleState, + modifier = Modifier.weight(1f), + enabled = !uiState.isUpdating, + colors = ButtonDefaults.buttonColors( + containerColor = EnabledGreen + ) + ) { + if (uiState.isUpdating) { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + strokeWidth = 2.dp + ) + } else { + Text("Enable") + } + } + } + if (email.state == EmailState.ENABLED || email.state == EmailState.PENDING) { + OutlinedButton( + onClick = onToggleState, + modifier = Modifier.weight(1f), + enabled = !uiState.isUpdating + ) { + if (uiState.isUpdating) { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + strokeWidth = 2.dp + ) + } else { + Text("Disable") + } + } + } + } + + Spacer(modifier = Modifier.height(24.dp)) + + Text( + text = "Edit Details", + style = MaterialTheme.typography.titleMedium + ) + + Spacer(modifier = Modifier.height(16.dp)) + + OutlinedTextField( + value = uiState.editedDescription, + onValueChange = onDescriptionChange, + label = { Text("Description") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + enabled = !uiState.isUpdating + ) + + Spacer(modifier = Modifier.height(16.dp)) + + OutlinedTextField( + value = uiState.editedForDomain, + onValueChange = onForDomainChange, + label = { Text("Associated Domain") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + enabled = !uiState.isUpdating + ) + + Spacer(modifier = Modifier.height(16.dp)) + + OutlinedTextField( + value = uiState.editedUrl, + onValueChange = onUrlChange, + label = { Text("URL") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + enabled = !uiState.isUpdating + ) + + if (uiState.error != null) { + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = uiState.error!!, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodyMedium + ) + } + + Spacer(modifier = Modifier.height(24.dp)) + + Button( + onClick = onSaveChanges, + modifier = Modifier.fillMaxWidth(), + enabled = !uiState.isUpdating + ) { + if (uiState.isUpdating) { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + color = MaterialTheme.colorScheme.onPrimary, + strokeWidth = 2.dp + ) + } else { + Text("Save Changes") + } + } + } +} diff --git a/app/src/main/java/com/fastmask/ui/detail/MaskedEmailDetailViewModel.kt b/app/src/main/java/com/fastmask/ui/detail/MaskedEmailDetailViewModel.kt new file mode 100644 index 0000000..44b29a9 --- /dev/null +++ b/app/src/main/java/com/fastmask/ui/detail/MaskedEmailDetailViewModel.kt @@ -0,0 +1,196 @@ +package com.fastmask.ui.detail + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.fastmask.domain.model.EmailState +import com.fastmask.domain.model.MaskedEmail +import com.fastmask.domain.model.UpdateMaskedEmailParams +import com.fastmask.domain.usecase.DeleteMaskedEmailUseCase +import com.fastmask.domain.usecase.GetMaskedEmailsUseCase +import com.fastmask.domain.usecase.UpdateMaskedEmailUseCase +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class MaskedEmailDetailViewModel @Inject constructor( + savedStateHandle: SavedStateHandle, + private val getMaskedEmailsUseCase: GetMaskedEmailsUseCase, + private val updateMaskedEmailUseCase: UpdateMaskedEmailUseCase, + private val deleteMaskedEmailUseCase: DeleteMaskedEmailUseCase +) : ViewModel() { + + private val emailId: String = savedStateHandle.get("emailId") + ?: throw IllegalArgumentException("emailId is required") + + private val _uiState = MutableStateFlow(MaskedEmailDetailUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private val _events = MutableSharedFlow() + val events: SharedFlow = _events.asSharedFlow() + + init { + loadEmail() + } + + fun loadEmail() { + viewModelScope.launch { + _uiState.update { it.copy(isLoading = true, error = null) } + + getMaskedEmailsUseCase().fold( + onSuccess = { emails -> + val email = emails.find { it.id == emailId } + if (email != null) { + _uiState.update { + it.copy( + isLoading = false, + email = email, + editedDescription = email.description ?: "", + editedForDomain = email.forDomain ?: "", + editedUrl = email.url ?: "" + ) + } + } else { + _uiState.update { + it.copy( + isLoading = false, + error = "Email not found" + ) + } + } + }, + onFailure = { error -> + _uiState.update { + it.copy( + isLoading = false, + error = error.message ?: "Failed to load email" + ) + } + } + ) + } + } + + fun onDescriptionChange(description: String) { + _uiState.update { it.copy(editedDescription = description) } + } + + fun onForDomainChange(domain: String) { + _uiState.update { it.copy(editedForDomain = domain) } + } + + fun onUrlChange(url: String) { + _uiState.update { it.copy(editedUrl = url) } + } + + fun toggleState() { + val email = _uiState.value.email ?: return + val newState = if (email.state == EmailState.ENABLED) EmailState.DISABLED else EmailState.ENABLED + updateState(newState) + } + + fun enable() = updateState(EmailState.ENABLED) + + fun disable() = updateState(EmailState.DISABLED) + + private fun updateState(newState: EmailState) { + viewModelScope.launch { + _uiState.update { it.copy(isUpdating = true) } + + updateMaskedEmailUseCase(emailId, UpdateMaskedEmailParams(state = newState)).fold( + onSuccess = { + loadEmail() + _events.emit(MaskedEmailDetailEvent.Updated) + }, + onFailure = { error -> + _uiState.update { + it.copy( + isUpdating = false, + error = error.message ?: "Failed to update" + ) + } + } + ) + } + } + + fun saveChanges() { + val state = _uiState.value + val email = state.email ?: return + + val hasChanges = state.editedDescription != (email.description ?: "") || + state.editedForDomain != (email.forDomain ?: "") || + state.editedUrl != (email.url ?: "") + + if (!hasChanges) return + + viewModelScope.launch { + _uiState.update { it.copy(isUpdating = true) } + + val params = UpdateMaskedEmailParams( + description = state.editedDescription.takeIf { it.isNotBlank() }, + forDomain = state.editedForDomain.takeIf { it.isNotBlank() }, + url = state.editedUrl.takeIf { it.isNotBlank() } + ) + + updateMaskedEmailUseCase(emailId, params).fold( + onSuccess = { + loadEmail() + _events.emit(MaskedEmailDetailEvent.Updated) + }, + onFailure = { error -> + _uiState.update { + it.copy( + isUpdating = false, + error = error.message ?: "Failed to save changes" + ) + } + } + ) + } + } + + fun delete() { + viewModelScope.launch { + _uiState.update { it.copy(isDeleting = true) } + + deleteMaskedEmailUseCase(emailId).fold( + onSuccess = { + _events.emit(MaskedEmailDetailEvent.Deleted) + }, + onFailure = { error -> + _uiState.update { + it.copy( + isDeleting = false, + error = error.message ?: "Failed to delete" + ) + } + } + ) + } + } +} + +data class MaskedEmailDetailUiState( + val isLoading: Boolean = false, + val isUpdating: Boolean = false, + val isDeleting: Boolean = false, + val email: MaskedEmail? = null, + val editedDescription: String = "", + val editedForDomain: String = "", + val editedUrl: String = "", + val error: String? = null +) + +sealed class MaskedEmailDetailEvent { + data object Updated : MaskedEmailDetailEvent() + data object Deleted : MaskedEmailDetailEvent() +} diff --git a/app/src/main/java/com/fastmask/ui/list/MaskedEmailListScreen.kt b/app/src/main/java/com/fastmask/ui/list/MaskedEmailListScreen.kt new file mode 100644 index 0000000..e7c7c90 --- /dev/null +++ b/app/src/main/java/com/fastmask/ui/list/MaskedEmailListScreen.kt @@ -0,0 +1,246 @@ +package com.fastmask.ui.list + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.FilterList +import androidx.compose.material.icons.filled.Logout +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import com.fastmask.domain.model.MaskedEmail +import com.fastmask.ui.components.ErrorMessage +import com.fastmask.ui.components.LoadingIndicator +import com.fastmask.ui.components.MaskedEmailCard +import kotlinx.coroutines.flow.collectLatest + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun MaskedEmailListScreen( + onNavigateToCreate: () -> Unit, + onNavigateToDetail: (String) -> Unit, + onLogout: () -> Unit, + viewModel: MaskedEmailListViewModel = hiltViewModel() +) { + val uiState by viewModel.uiState.collectAsState() + var showFilterMenu by remember { mutableStateOf(false) } + + LaunchedEffect(Unit) { + viewModel.events.collectLatest { event -> + when (event) { + is MaskedEmailListEvent.LoggedOut -> onLogout() + } + } + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Masked Emails") }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer + ), + actions = { + Box { + IconButton(onClick = { showFilterMenu = true }) { + Icon( + imageVector = Icons.Default.FilterList, + contentDescription = "Filter" + ) + } + DropdownMenu( + expanded = showFilterMenu, + onDismissRequest = { showFilterMenu = false } + ) { + EmailFilter.entries.forEach { filter -> + DropdownMenuItem( + text = { Text(filter.name.lowercase().replaceFirstChar { it.uppercase() }) }, + onClick = { + viewModel.onFilterChange(filter) + showFilterMenu = false + } + ) + } + } + } + IconButton(onClick = viewModel::logout) { + Icon( + imageVector = Icons.Default.Logout, + contentDescription = "Logout" + ) + } + } + ) + }, + floatingActionButton = { + FloatingActionButton(onClick = onNavigateToCreate) { + Icon( + imageVector = Icons.Default.Add, + contentDescription = "Create new masked email" + ) + } + } + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + ) { + SearchBar( + query = uiState.searchQuery, + onQueryChange = viewModel::onSearchQueryChange, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp) + ) + + FilterChips( + selectedFilter = uiState.selectedFilter, + onFilterSelected = viewModel::onFilterChange, + modifier = Modifier.padding(horizontal = 16.dp) + ) + + Spacer(modifier = Modifier.height(8.dp)) + + when { + uiState.isLoading && uiState.emails.isEmpty() -> { + LoadingIndicator() + } + + uiState.error != null && uiState.emails.isEmpty() -> { + ErrorMessage( + message = uiState.error!!, + onRetry = viewModel::loadMaskedEmails + ) + } + + else -> { + EmailList( + emails = uiState.filteredEmails, + isRefreshing = uiState.isLoading, + onRefresh = viewModel::loadMaskedEmails, + onEmailClick = { email -> onNavigateToDetail(email.id) } + ) + } + } + } + } +} + +@Composable +private fun SearchBar( + query: String, + onQueryChange: (String) -> Unit, + modifier: Modifier = Modifier +) { + OutlinedTextField( + value = query, + onValueChange = onQueryChange, + modifier = modifier.fillMaxWidth(), + placeholder = { Text("Search emails...") }, + leadingIcon = { + Icon( + imageVector = Icons.Default.Search, + contentDescription = null + ) + }, + singleLine = true + ) +} + +@Composable +private fun FilterChips( + selectedFilter: EmailFilter, + onFilterSelected: (EmailFilter) -> Unit, + modifier: Modifier = Modifier +) { + Row( + modifier = modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + EmailFilter.entries.forEach { filter -> + FilterChip( + selected = filter == selectedFilter, + onClick = { onFilterSelected(filter) }, + label = { Text(filter.name.lowercase().replaceFirstChar { it.uppercase() }) } + ) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun EmailList( + emails: List, + isRefreshing: Boolean, + onRefresh: () -> Unit, + onEmailClick: (MaskedEmail) -> Unit +) { + PullToRefreshBox( + isRefreshing = isRefreshing, + onRefresh = onRefresh, + modifier = Modifier.fillMaxSize() + ) { + if (emails.isEmpty()) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Text( + text = "No masked emails found", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } else { + LazyColumn( + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + items( + items = emails, + key = { it.id } + ) { email -> + MaskedEmailCard( + maskedEmail = email, + onClick = { onEmailClick(email) } + ) + } + } + } + } +} diff --git a/app/src/main/java/com/fastmask/ui/list/MaskedEmailListViewModel.kt b/app/src/main/java/com/fastmask/ui/list/MaskedEmailListViewModel.kt new file mode 100644 index 0000000..b1a5efc --- /dev/null +++ b/app/src/main/java/com/fastmask/ui/list/MaskedEmailListViewModel.kt @@ -0,0 +1,132 @@ +package com.fastmask.ui.list + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.fastmask.domain.model.EmailState +import com.fastmask.domain.model.MaskedEmail +import com.fastmask.domain.usecase.GetMaskedEmailsUseCase +import com.fastmask.domain.usecase.LogoutUseCase +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class MaskedEmailListViewModel @Inject constructor( + private val getMaskedEmailsUseCase: GetMaskedEmailsUseCase, + private val logoutUseCase: LogoutUseCase +) : ViewModel() { + + private val _uiState = MutableStateFlow(MaskedEmailListUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private val _events = MutableSharedFlow() + val events: SharedFlow = _events.asSharedFlow() + + init { + loadMaskedEmails() + } + + fun loadMaskedEmails() { + viewModelScope.launch { + _uiState.update { it.copy(isLoading = true, error = null) } + + getMaskedEmailsUseCase().fold( + onSuccess = { emails -> + _uiState.update { + it.copy( + isLoading = false, + emails = emails.sortedByDescending { email -> email.createdAt }, + filteredEmails = filterEmails( + emails, + it.searchQuery, + it.selectedFilter + ) + ) + } + }, + onFailure = { error -> + _uiState.update { + it.copy( + isLoading = false, + error = error.message ?: "Failed to load emails" + ) + } + } + ) + } + } + + fun onSearchQueryChange(query: String) { + _uiState.update { + it.copy( + searchQuery = query, + filteredEmails = filterEmails(it.emails, query, it.selectedFilter) + ) + } + } + + fun onFilterChange(filter: EmailFilter) { + _uiState.update { + it.copy( + selectedFilter = filter, + filteredEmails = filterEmails(it.emails, it.searchQuery, filter) + ) + } + } + + fun logout() { + logoutUseCase() + viewModelScope.launch { + _events.emit(MaskedEmailListEvent.LoggedOut) + } + } + + private fun filterEmails( + emails: List, + query: String, + filter: EmailFilter + ): List { + return emails + .filter { email -> + when (filter) { + EmailFilter.ALL -> true + EmailFilter.ENABLED -> email.state == EmailState.ENABLED + EmailFilter.DISABLED -> email.state == EmailState.DISABLED + EmailFilter.DELETED -> email.state == EmailState.DELETED + } + } + .filter { email -> + if (query.isBlank()) true + else { + email.email.contains(query, ignoreCase = true) || + email.description?.contains(query, ignoreCase = true) == true || + email.forDomain?.contains(query, ignoreCase = true) == true + } + } + .sortedByDescending { it.createdAt } + } +} + +data class MaskedEmailListUiState( + val isLoading: Boolean = false, + val emails: List = emptyList(), + val filteredEmails: List = emptyList(), + val searchQuery: String = "", + val selectedFilter: EmailFilter = EmailFilter.ALL, + val error: String? = null +) + +enum class EmailFilter { + ALL, ENABLED, DISABLED, DELETED +} + +sealed class MaskedEmailListEvent { + data object LoggedOut : MaskedEmailListEvent() +} diff --git a/app/src/main/java/com/fastmask/ui/navigation/FastMaskNavHost.kt b/app/src/main/java/com/fastmask/ui/navigation/FastMaskNavHost.kt new file mode 100644 index 0000000..f483a83 --- /dev/null +++ b/app/src/main/java/com/fastmask/ui/navigation/FastMaskNavHost.kt @@ -0,0 +1,73 @@ +package com.fastmask.ui.navigation + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.navigation.NavHostController +import androidx.navigation.NavType +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.navArgument +import com.fastmask.ui.auth.LoginScreen +import com.fastmask.ui.create.CreateMaskedEmailScreen +import com.fastmask.ui.detail.MaskedEmailDetailScreen +import com.fastmask.ui.list.MaskedEmailListScreen + +@Composable +fun FastMaskNavHost( + navController: NavHostController, + startDestination: String, + modifier: Modifier = Modifier +) { + NavHost( + navController = navController, + startDestination = startDestination, + modifier = modifier + ) { + composable(NavRoutes.LOGIN) { + LoginScreen( + onLoginSuccess = { + navController.navigate(NavRoutes.EMAIL_LIST) { + popUpTo(NavRoutes.LOGIN) { inclusive = true } + } + } + ) + } + + composable(NavRoutes.EMAIL_LIST) { + MaskedEmailListScreen( + onNavigateToCreate = { + navController.navigate(NavRoutes.CREATE_EMAIL) + }, + onNavigateToDetail = { emailId -> + navController.navigate(NavRoutes.emailDetail(emailId)) + }, + onLogout = { + navController.navigate(NavRoutes.LOGIN) { + popUpTo(0) { inclusive = true } + } + } + ) + } + + composable(NavRoutes.CREATE_EMAIL) { + CreateMaskedEmailScreen( + onNavigateBack = { + navController.popBackStack() + } + ) + } + + composable( + route = NavRoutes.EMAIL_DETAIL, + arguments = listOf( + navArgument("emailId") { type = NavType.StringType } + ) + ) { + MaskedEmailDetailScreen( + onNavigateBack = { + navController.popBackStack() + } + ) + } + } +} diff --git a/app/src/main/java/com/fastmask/ui/navigation/NavRoutes.kt b/app/src/main/java/com/fastmask/ui/navigation/NavRoutes.kt new file mode 100644 index 0000000..74053ad --- /dev/null +++ b/app/src/main/java/com/fastmask/ui/navigation/NavRoutes.kt @@ -0,0 +1,10 @@ +package com.fastmask.ui.navigation + +object NavRoutes { + const val LOGIN = "login" + const val EMAIL_LIST = "email_list" + const val CREATE_EMAIL = "create_email" + const val EMAIL_DETAIL = "email_detail/{emailId}" + + fun emailDetail(emailId: String) = "email_detail/$emailId" +} diff --git a/app/src/main/java/com/fastmask/ui/theme/Color.kt b/app/src/main/java/com/fastmask/ui/theme/Color.kt new file mode 100644 index 0000000..7c643e2 --- /dev/null +++ b/app/src/main/java/com/fastmask/ui/theme/Color.kt @@ -0,0 +1,20 @@ +package com.fastmask.ui.theme + +import androidx.compose.ui.graphics.Color + +val Purple80 = Color(0xFFD0BCFF) +val PurpleGrey80 = Color(0xFFCCC2DC) +val Pink80 = Color(0xFFEFB8C8) + +val Purple40 = Color(0xFF6650a4) +val PurpleGrey40 = Color(0xFF625b71) +val Pink40 = Color(0xFF7D5260) + +val FastmailBlue = Color(0xFF0066CC) +val FastmailBlueLight = Color(0xFF4D94DB) +val FastmailBlueDark = Color(0xFF004C99) + +val EnabledGreen = Color(0xFF4CAF50) +val DisabledGray = Color(0xFF9E9E9E) +val DeletedRed = Color(0xFFE53935) +val PendingOrange = Color(0xFFFF9800) diff --git a/app/src/main/java/com/fastmask/ui/theme/Theme.kt b/app/src/main/java/com/fastmask/ui/theme/Theme.kt new file mode 100644 index 0000000..b37eea0 --- /dev/null +++ b/app/src/main/java/com/fastmask/ui/theme/Theme.kt @@ -0,0 +1,74 @@ +package com.fastmask.ui.theme + +import android.app.Activity +import android.os.Build +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.SideEffect +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalView +import androidx.core.view.WindowCompat + +private val DarkColorScheme = darkColorScheme( + primary = FastmailBlueLight, + secondary = PurpleGrey80, + tertiary = Pink80, + background = Color(0xFF121212), + surface = Color(0xFF1E1E1E), + onPrimary = Color.White, + onSecondary = Color.White, + onTertiary = Color.White, + onBackground = Color.White, + onSurface = Color.White +) + +private val LightColorScheme = lightColorScheme( + primary = FastmailBlue, + secondary = PurpleGrey40, + tertiary = Pink40, + background = Color(0xFFFFFBFE), + surface = Color(0xFFFFFBFE), + onPrimary = Color.White, + onSecondary = Color.White, + onTertiary = Color.White, + onBackground = Color(0xFF1C1B1F), + onSurface = Color(0xFF1C1B1F) +) + +@Composable +fun FastMaskTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + dynamicColor: Boolean = true, + content: @Composable () -> Unit +) { + val colorScheme = when { + dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { + val context = LocalContext.current + if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + } + darkTheme -> DarkColorScheme + else -> LightColorScheme + } + + val view = LocalView.current + if (!view.isInEditMode) { + SideEffect { + val window = (view.context as Activity).window + window.statusBarColor = colorScheme.surface.toArgb() + WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !darkTheme + } + } + + MaterialTheme( + colorScheme = colorScheme, + typography = Typography, + content = content + ) +} diff --git a/app/src/main/java/com/fastmask/ui/theme/Type.kt b/app/src/main/java/com/fastmask/ui/theme/Type.kt new file mode 100644 index 0000000..c477e29 --- /dev/null +++ b/app/src/main/java/com/fastmask/ui/theme/Type.kt @@ -0,0 +1,73 @@ +package com.fastmask.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 + +val Typography = Typography( + bodyLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.5.sp + ), + bodyMedium = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 14.sp, + lineHeight = 20.sp, + letterSpacing = 0.25.sp + ), + bodySmall = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 12.sp, + lineHeight = 16.sp, + letterSpacing = 0.4.sp + ), + titleLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.SemiBold, + fontSize = 22.sp, + lineHeight = 28.sp, + letterSpacing = 0.sp + ), + titleMedium = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.SemiBold, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.15.sp + ), + titleSmall = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Medium, + fontSize = 14.sp, + lineHeight = 20.sp, + letterSpacing = 0.1.sp + ), + labelLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Medium, + fontSize = 14.sp, + lineHeight = 20.sp, + letterSpacing = 0.1.sp + ), + labelMedium = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Medium, + fontSize = 12.sp, + lineHeight = 16.sp, + letterSpacing = 0.5.sp + ), + labelSmall = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Medium, + fontSize = 11.sp, + lineHeight = 16.sp, + letterSpacing = 0.5.sp + ) +) 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..11d33ce --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,10 @@ + + + + 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..1873450 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,24 @@ + + + + + + + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..d378acd --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..d378acd --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..07bed88 --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +1,11 @@ + + + #FFBB86FC + #FF6200EE + #FF3700B3 + #FF03DAC5 + #FF018786 + #FF000000 + #FFFFFFFF + #0066CC + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..ca6e0be --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,4 @@ + + + FastMask + diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..6911dbe --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,7 @@ + + + + diff --git a/app/src/main/res/xml/backup_rules.xml b/app/src/main/res/xml/backup_rules.xml new file mode 100644 index 0000000..836b7c0 --- /dev/null +++ b/app/src/main/res/xml/backup_rules.xml @@ -0,0 +1,4 @@ + + + + diff --git a/app/src/main/res/xml/data_extraction_rules.xml b/app/src/main/res/xml/data_extraction_rules.xml new file mode 100644 index 0000000..65058f3 --- /dev/null +++ b/app/src/main/res/xml/data_extraction_rules.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..ff89ca1 --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,6 @@ +plugins { + id("com.android.application") version "8.2.2" apply false + id("org.jetbrains.kotlin.android") version "1.9.22" apply false + id("com.google.dagger.hilt.android") version "2.50" apply false + id("org.jetbrains.kotlin.plugin.serialization") version "1.9.22" apply false +} diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..f0a2e55 --- /dev/null +++ b/gradle.properties @@ -0,0 +1,4 @@ +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +android.useAndroidX=true +kotlin.code.style=official +android.nonTransitiveRClass=true diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..1af9e09 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..9dde9e7 --- /dev/null +++ b/gradlew @@ -0,0 +1,189 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#)}; t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # temporary files being created. Need the extra '|| :' at the end because + # shift exits with an error when arg list is empty. + shift + set -- "$@" "$arg" + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command; +# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of +# temporary files for use as options. +# * $GRADLE_USER_HOME defaults to ${HOME}/.gradle +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n:// +# Input: 'What'\''s528/LN'\''(13) 14' +# With -n: ["'s","(13) 14"] +# Without: ["'s(13)","14"] +# +# With -x:// +# Without: /usr/bin/xargs: unmatched double quote; by default quotes are special to xargs unless you use the -0 option +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[`528/LN"$]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..93e3f59 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,92 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/local.properties b/local.properties new file mode 100644 index 0000000..d5ea8a2 --- /dev/null +++ b/local.properties @@ -0,0 +1,8 @@ +## This file must *NOT* be checked into Version Control Systems, +# as it contains information specific to your local configuration. +# +# Location of the SDK. This is only used by Gradle. +# For customization when using a Version Control System, please read the +# header note. +#Sat Jan 31 01:15:24 CET 2026 +sdk.dir=/Users/pawelorzech/Library/Android/sdk diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..cdf4708 --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,18 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "FastMask" +include(":app")