Initial commit

This commit is contained in:
Paweł Orzech 2026-01-31 01:15:54 +01:00
commit 47bca4d678
No known key found for this signature in database
65 changed files with 3548 additions and 0 deletions

2
.gitattributes vendored Normal file
View file

@ -0,0 +1,2 @@
# Auto detect text files and perform LF normalization
* text=auto

27
.gitignore vendored Normal file
View file

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

View file

@ -0,0 +1,2 @@
#Sat Jan 31 01:15:24 CET 2026
java.home=/Applications/Android Studio.app/Contents/jbr/Contents/Home

3
.idea/.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
# Default ignored files
/shelf/
/workspace.xml

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AndroidProjectSystem">
<option name="providerId" value="com.android.tools.idea.GradleProjectSystem" />
</component>
</project>

12
.idea/gradle.xml Normal file
View file

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="GradleSettings">
<option name="linkedExternalProjectsSettings">
<GradleProjectSettings>
<option name="testRunner" value="CHOOSE_PER_TEST" />
<option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="gradleJvm" value="#GRADLE_LOCAL_JAVA_HOME" />
</GradleProjectSettings>
</option>
</component>
</project>

10
.idea/migrations.xml Normal file
View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectMigrations">
<option name="MigrateToGradleLocalJavaHome">
<set>
<option value="$PROJECT_DIR$" />
</set>
</option>
</component>
</project>

10
.idea/misc.xml Normal file
View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="ProjectRootManager">
<output url="file://$PROJECT_DIR$/build/classes" />
</component>
<component name="ProjectType">
<option name="id" value="Android" />
</component>
</project>

View file

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="RunConfigurationProducerService">
<option name="ignoredProducers">
<set>
<option value="com.intellij.execution.junit.AbstractAllInDirectoryConfigurationProducer" />
<option value="com.intellij.execution.junit.AllInPackageConfigurationProducer" />
<option value="com.intellij.execution.junit.PatternConfigurationProducer" />
<option value="com.intellij.execution.junit.TestInClassConfigurationProducer" />
<option value="com.intellij.execution.junit.UniqueIdConfigurationProducer" />
<option value="com.intellij.execution.junit.testDiscovery.JUnitTestDiscoveryConfigurationProducer" />
<option value="org.jetbrains.kotlin.idea.junit.KotlinJUnitRunConfigurationProducer" />
<option value="org.jetbrains.kotlin.idea.junit.KotlinPatternConfigurationProducer" />
</set>
</option>
</component>
</project>

21
LICENSE Normal file
View file

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

2
README.md Normal file
View file

@ -0,0 +1,2 @@
# FastMask

114
app/build.gradle.kts Normal file
View file

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

39
app/proguard-rules.pro vendored Normal file
View file

@ -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.* <methods>;
}
-dontwarn org.codehaus.mojo.animal_sniffer.IgnoreJRERequirement
-dontwarn javax.annotation.**
-dontwarn kotlin.Unit
-dontwarn retrofit2.KotlinExtensions
-dontwarn retrofit2.KotlinExtensions$*
# OkHttp
-dontwarn okhttp3.**
-dontwarn okio.**

View file

@ -0,0 +1,29 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<application
android:name=".FastMaskApplication"
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.FastMask"
tools:targetApi="31">
<activity
android:name=".MainActivity"
android:exported="true"
android:theme="@style/Theme.FastMask">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>

View file

@ -0,0 +1,7 @@
package com.fastmask
import android.app.Application
import dagger.hilt.android.HiltAndroidApp
@HiltAndroidApp
class FastMaskApplication : Application()

View file

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

View file

@ -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<JmapSession> = 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<List<MaskedEmailDto>> = 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<MaskedEmailDto> = 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<Unit> = 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<Unit> = 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<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 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)

View file

@ -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<String, String>,
val accounts: Map<String, JmapAccount>,
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<String>,
val methodCalls: List<JsonArray>
)
@Serializable
data class JmapResponse(
val methodResponses: List<JsonArray>,
val sessionState: String? = null
)
@Serializable
data class MaskedEmailGetResponse(
val accountId: String,
val state: String,
val list: List<MaskedEmailDto>,
val notFound: List<String> = emptyList()
)
@Serializable
data class MaskedEmailSetResponse(
val accountId: String,
val oldState: String? = null,
val newState: String,
val created: Map<String, MaskedEmailDto>? = null,
val updated: Map<String, JsonElement?>? = null,
val destroyed: List<String>? = null,
val notCreated: Map<String, JmapSetError>? = null,
val notUpdated: Map<String, JmapSetError>? = null,
val notDestroyed: Map<String, JmapSetError>? = 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
)

View file

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

View file

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

View file

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

View file

@ -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<List<MaskedEmail>> {
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<MaskedEmail> {
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<Unit> {
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<Unit> {
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
}
}

View file

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

View file

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

View file

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

View file

@ -0,0 +1,8 @@
package com.fastmask.domain.repository
interface AuthRepository {
suspend fun login(token: String): Result<Unit>
fun logout()
fun isLoggedIn(): Boolean
fun getToken(): String?
}

View file

@ -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<List<MaskedEmail>>
suspend fun createMaskedEmail(params: CreateMaskedEmailParams): Result<MaskedEmail>
suspend fun updateMaskedEmail(id: String, params: UpdateMaskedEmailParams): Result<Unit>
suspend fun deleteMaskedEmail(id: String): Result<Unit>
}

View file

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

View file

@ -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<Unit> {
return repository.deleteMaskedEmail(id)
}
}

View file

@ -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<List<MaskedEmail>> {
return repository.getMaskedEmails()
}
}

View file

@ -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<Unit> {
if (token.isBlank()) {
return Result.failure(IllegalArgumentException("API token cannot be empty"))
}
return authRepository.login(token)
}
}

View file

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

View file

@ -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<Unit> {
return repository.updateMaskedEmail(id, params)
}
}

View file

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

View file

@ -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<LoginUiState> = _uiState.asStateFlow()
private val _events = MutableSharedFlow<LoginEvent>()
val events: SharedFlow<LoginEvent> = _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()
}

View file

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

View file

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

View file

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

View file

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

View file

@ -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<CreateMaskedEmailUiState> = _uiState.asStateFlow()
private val _events = MutableSharedFlow<CreateMaskedEmailEvent>()
val events: SharedFlow<CreateMaskedEmailEvent> = _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()
}

View file

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

View file

@ -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<String>("emailId")
?: throw IllegalArgumentException("emailId is required")
private val _uiState = MutableStateFlow(MaskedEmailDetailUiState())
val uiState: StateFlow<MaskedEmailDetailUiState> = _uiState.asStateFlow()
private val _events = MutableSharedFlow<MaskedEmailDetailEvent>()
val events: SharedFlow<MaskedEmailDetailEvent> = _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()
}

View file

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

View file

@ -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<MaskedEmailListUiState> = _uiState.asStateFlow()
private val _events = MutableSharedFlow<MaskedEmailListEvent>()
val events: SharedFlow<MaskedEmailListEvent> = _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<MaskedEmail>,
query: String,
filter: EmailFilter
): List<MaskedEmail> {
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<MaskedEmail> = emptyList(),
val filteredEmails: List<MaskedEmail> = 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()
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#FFFFFF"
android:pathData="M0,0h108v108h-108z"/>
</vector>

View file

@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<group android:scaleX="0.35"
android:scaleY="0.35"
android:translateX="35"
android:translateY="35">
<path
android:fillColor="#0066CC"
android:pathData="M54,10C29.7,10 10,29.7 10,54s19.7,44 44,44s44,-19.7 44,-44S78.3,10 54,10zM54,90c-19.9,0 -36,-16.1 -36,-36s16.1,-36 36,-36s36,16.1 36,36S73.9,90 54,90z"/>
<path
android:fillColor="#0066CC"
android:pathData="M54,30c-13.3,0 -24,10.7 -24,24s10.7,24 24,24s24,-10.7 24,-24S67.3,30 54,30zM54,70c-8.8,0 -16,-7.2 -16,-16s7.2,-16 16,-16s16,7.2 16,16S62.8,70 54,70z"/>
<path
android:fillColor="#0066CC"
android:pathData="M75,40l-42,0l0,8l42,0z"/>
<path
android:fillColor="#0066CC"
android:pathData="M75,60l-42,0l0,8l42,0z"/>
</group>
</vector>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>

View file

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="purple_200">#FFBB86FC</color>
<color name="purple_500">#FF6200EE</color>
<color name="purple_700">#FF3700B3</color>
<color name="teal_200">#FF03DAC5</color>
<color name="teal_700">#FF018786</color>
<color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color>
<color name="fastmail_blue">#0066CC</color>
</resources>

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">FastMask</string>
</resources>

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.FastMask" parent="android:Theme.Material.Light.NoActionBar">
<item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:navigationBarColor">@android:color/transparent</item>
</style>
</resources>

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<full-backup-content>
<exclude domain="sharedpref" path="fastmask_secure_prefs.xml"/>
</full-backup-content>

View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<data-extraction-rules>
<cloud-backup>
<exclude domain="sharedpref" path="fastmask_secure_prefs.xml"/>
</cloud-backup>
<device-transfer>
<exclude domain="sharedpref" path="fastmask_secure_prefs.xml"/>
</device-transfer>
</data-extraction-rules>

6
build.gradle.kts Normal file
View file

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

4
gradle.properties Normal file
View file

@ -0,0 +1,4 @@
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
android.useAndroidX=true
kotlin.code.style=official
android.nonTransitiveRClass=true

View file

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

189
gradlew vendored Executable file
View file

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

92
gradlew.bat vendored Normal file
View file

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

8
local.properties Normal file
View file

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

18
settings.gradle.kts Normal file
View file

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