diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..fd49e83 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,8 @@ +{ + "permissions": { + "allow": [ + "mcp__plugin_figma_figma__use_figma", + "mcp__plugin_figma_figma__get_screenshot" + ] + } +} diff --git a/.idea/AndroidProjectSystem.xml b/.idea/AndroidProjectSystem.xml new file mode 100644 index 0000000..4a53bee --- /dev/null +++ b/.idea/AndroidProjectSystem.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/appInsightsSettings.xml b/.idea/appInsightsSettings.xml new file mode 100644 index 0000000..371f2e2 --- /dev/null +++ b/.idea/appInsightsSettings.xml @@ -0,0 +1,26 @@ + + + + + + \ No newline at end of file diff --git a/.idea/compiler.xml b/.idea/compiler.xml new file mode 100644 index 0000000..b86273d --- /dev/null +++ b/.idea/compiler.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/deploymentTargetSelector.xml b/.idea/deploymentTargetSelector.xml new file mode 100644 index 0000000..ca2efc6 --- /dev/null +++ b/.idea/deploymentTargetSelector.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/deviceManager.xml b/.idea/deviceManager.xml new file mode 100644 index 0000000..91f9558 --- /dev/null +++ b/.idea/deviceManager.xml @@ -0,0 +1,13 @@ + + + + + + \ No newline at end of file diff --git a/.idea/gradle.xml b/.idea/gradle.xml new file mode 100644 index 0000000..02c4aa5 --- /dev/null +++ b/.idea/gradle.xml @@ -0,0 +1,18 @@ + + + + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 0000000..7061a0d --- /dev/null +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,61 @@ + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..f041549 --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,22 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/runConfigurations.xml b/.idea/runConfigurations.xml new file mode 100644 index 0000000..16660f1 --- /dev/null +++ b/.idea/runConfigurations.xml @@ -0,0 +1,17 @@ + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..94a25f7 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..fbc9665 --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,79 @@ +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.compose) + alias(libs.plugins.devtool.ksp) + alias(libs.plugins.serialization) + alias(libs.plugins.koin.compiler) +} + +android { + namespace = "com.longnh15.heimdall" + compileSdk { + version = release(37) + } + + defaultConfig { + applicationId = "com.longnh15.heimdall" + minSdk = 26 + targetSdk = 37 + versionCode = 1 + versionName = "1.0" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + signingConfig = signingConfigs.getByName("debug") + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + buildFeatures { + compose = true + } +} + +dependencies { + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.activity.compose) + implementation(libs.androidx.compose.material3) + implementation(libs.androidx.lifecycle.process) + implementation(libs.icon.pack) + implementation(libs.androidx.compose.ui) + implementation(libs.androidx.compose.ui.graphics) + implementation(libs.androidx.compose.ui.tooling.preview) + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.lifecycle.runtime.ktx) + implementation(libs.androidx.lifecycle.viewmodel.compose) + testImplementation(libs.junit) + androidTestImplementation(platform(libs.androidx.compose.bom)) + androidTestImplementation(libs.androidx.compose.ui.test.junit4) + androidTestImplementation(libs.androidx.espresso.core) + androidTestImplementation(libs.androidx.junit) + debugImplementation(libs.androidx.compose.ui.test.manifest) + debugImplementation(libs.androidx.compose.ui.tooling) + + implementation(libs.navigation.compose) + implementation(libs.androidx.datastore.preferences) + implementation(libs.biometric.compose) + + // Koin + implementation(libs.koin) + implementation(libs.koin.compose) + implementation(libs.koin.annotations) + implementation(libs.koin.compose.viewmodel) + implementation(libs.koin.compose.viewmodel.navigation) + + // Room + implementation(libs.androidx.room) + ksp(libs.androidx.room.compiler) + implementation(libs.androidx.room.ktx) +} \ No newline at end of file diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/app/src/androidTest/java/com/longnh15/heimdall/ExampleInstrumentedTest.kt b/app/src/androidTest/java/com/longnh15/heimdall/ExampleInstrumentedTest.kt new file mode 100644 index 0000000..d990fbf --- /dev/null +++ b/app/src/androidTest/java/com/longnh15/heimdall/ExampleInstrumentedTest.kt @@ -0,0 +1,24 @@ +package com.longnh15.heimdall + +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.ext.junit.runners.AndroidJUnit4 + +import org.junit.Test +import org.junit.runner.RunWith + +import org.junit.Assert.* + +/** + * Instrumented test, which will execute on an Android device. + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +@RunWith(AndroidJUnit4::class) +class ExampleInstrumentedTest { + @Test + fun useAppContext() { + // Context of the app under test. + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + assertEquals("com.longnh15.heimdall", appContext.packageName) + } +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..2fde86d --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/ic_launcher-playstore.png b/app/src/main/ic_launcher-playstore.png new file mode 100644 index 0000000..3dad8c0 Binary files /dev/null and b/app/src/main/ic_launcher-playstore.png differ diff --git a/app/src/main/java/com/longnh15/heimdall/HeimdallApplication.kt b/app/src/main/java/com/longnh15/heimdall/HeimdallApplication.kt new file mode 100644 index 0000000..a59eda3 --- /dev/null +++ b/app/src/main/java/com/longnh15/heimdall/HeimdallApplication.kt @@ -0,0 +1,36 @@ +package com.longnh15.heimdall + +import android.app.Application +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.ProcessLifecycleOwner +import com.longnh15.heimdall.di.appModule +import com.longnh15.heimdall.domain.vault.VaultSessionManager +import org.koin.android.ext.android.inject +import org.koin.android.ext.koin.androidContext +import org.koin.android.ext.koin.androidLogger +import org.koin.core.annotation.ComponentScan +import org.koin.core.annotation.KoinApplication +import org.koin.plugin.module.dsl.startKoin + +//@KoinApplication(modules = [DatabaseModule::class]) +//@ComponentScan +class HeimdallApplication: Application() { + private val vaultSessionManager: VaultSessionManager by inject() + private val vaultSessionLifecycleObserver = object : DefaultLifecycleObserver { + override fun onStart(owner: LifecycleOwner) { + vaultSessionManager.refreshSession() + } + } + + override fun onCreate() { + super.onCreate() + startKoin { + androidLogger() + androidContext(this@HeimdallApplication) + modules(appModule) + } + + ProcessLifecycleOwner.get().lifecycle.addObserver(vaultSessionLifecycleObserver) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/longnh15/heimdall/HeimdallAutofillService.kt b/app/src/main/java/com/longnh15/heimdall/HeimdallAutofillService.kt new file mode 100644 index 0000000..b98d19e --- /dev/null +++ b/app/src/main/java/com/longnh15/heimdall/HeimdallAutofillService.kt @@ -0,0 +1,83 @@ +package com.longnh15.heimdall + +import android.app.assist.AssistStructure +import android.os.CancellationSignal +import android.service.autofill.AutofillService +import android.service.autofill.Dataset +import android.service.autofill.FillCallback +import android.service.autofill.FillContext +import android.service.autofill.FillRequest +import android.service.autofill.FillResponse +import android.service.autofill.SaveCallback +import android.service.autofill.SaveRequest +import android.util.Log +import android.view.autofill.AutofillValue +import android.widget.RemoteViews + +class HeimdallAutofillService : AutofillService() { + + private fun buildFillResponse( + parsedFields: ParsedField + ): FillResponse { + val responseBuilder = FillResponse.Builder() + val datasetBuilder = Dataset.Builder() + + parsedFields.usernameId?.let { id -> + datasetBuilder.setValue( + id, + AutofillValue.forText("+84826552595"), + RemoteViews(packageName, android.R.layout.simple_list_item_1).also { + it.setTextViewText(android.R.id.text1, "Long Nguyễn") + } + ) + } + + parsedFields.passwordId?.let { id -> + datasetBuilder.setValue( + id, + AutofillValue.forText("123123123"), + RemoteViews(packageName, android.R.layout.simple_list_item_1).also { + it.setTextViewText(android.R.id.text1, "••••••••") + } + ) + } + + responseBuilder.addDataset(datasetBuilder.build()) + + return responseBuilder.build() + } + + override fun onFillRequest( + request: FillRequest, + cancellationSignal: CancellationSignal, + callback: FillCallback + ) { + // Get the structure from the request + val context: List = request.fillContexts + val structure: AssistStructure = context[context.size - 1].structure + + Log.d("Heimdall", "Package: ${structure.activityComponent.packageName}") + Log.d("Heimdall", "Window nodes: ${structure.windowNodeCount}") + + // Traverse the structure looking for nodes to fill out + val parser = StructureParser(structure) + val parsedFields = parser.parse() + Log.d("Heimdall", "Parsed fields: $parsedFields") + if (parsedFields.isEmpty()) { + callback.onSuccess(null) + return + } + + + val fillResponse = buildFillResponse(parsedFields) + callback.onSuccess(fillResponse) + } + + override fun onSaveRequest( + p0: SaveRequest, + p1: SaveCallback + ) { + // Empty for now + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/longnh15/heimdall/MainActivity.kt b/app/src/main/java/com/longnh15/heimdall/MainActivity.kt new file mode 100644 index 0000000..7d6ac3d --- /dev/null +++ b/app/src/main/java/com/longnh15/heimdall/MainActivity.kt @@ -0,0 +1,40 @@ +package com.longnh15.heimdall + +import android.os.Bundle +import android.view.autofill.AutofillManager +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.longnh15.heimdall.navigation.HeimdallNavGraph +import com.longnh15.heimdall.navigation.Screen +import com.longnh15.heimdall.ui.theme.HeimdallTheme +import org.koin.androidx.viewmodel.ext.android.viewModel + +class MainActivity : ComponentActivity() { + private val viewModel: MainViewModel by viewModel() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + setContent { + HeimdallTheme { + val isFirstLaunch by viewModel.isFirstLaunch.collectAsStateWithLifecycle() + HeimdallNavGraph( + modifier = Modifier.fillMaxSize(), + startDestination = if (isFirstLaunch != false) Screen.Onboarding else Screen.Vault, + isAutofillServiceEnabled = ::isAutofillServiceEnabled, + onCompleteOnboarding = viewModel::onCompleteOnboarding, + ) + } + } + } + + fun isAutofillServiceEnabled(): Boolean { + val afm = getSystemService(AutofillManager::class.java) + return afm?.hasEnabledAutofillServices() ?: false + } +} \ No newline at end of file diff --git a/app/src/main/java/com/longnh15/heimdall/MainViewModel.kt b/app/src/main/java/com/longnh15/heimdall/MainViewModel.kt new file mode 100644 index 0000000..500ec83 --- /dev/null +++ b/app/src/main/java/com/longnh15/heimdall/MainViewModel.kt @@ -0,0 +1,30 @@ +package com.longnh15.heimdall + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.longnh15.heimdall.data.preferences.PreferenceDataSource +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import org.koin.core.annotation.KoinViewModel + +@KoinViewModel +class MainViewModel ( + private val preferenceDataSource: PreferenceDataSource +) : ViewModel() { + val isFirstLaunch: StateFlow = preferenceDataSource.preferences + .map { it.isFirstLaunch } + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5000), + initialValue = null + ) + + fun onCompleteOnboarding() { + viewModelScope.launch { + preferenceDataSource.setFirstLaunchDone() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/longnh15/heimdall/ParsedField.kt b/app/src/main/java/com/longnh15/heimdall/ParsedField.kt new file mode 100644 index 0000000..1642dce --- /dev/null +++ b/app/src/main/java/com/longnh15/heimdall/ParsedField.kt @@ -0,0 +1,10 @@ +package com.longnh15.heimdall + +import android.view.autofill.AutofillId + +data class ParsedField( + val usernameId: AutofillId?, + val passwordId: AutofillId? +) { + fun isEmpty() = usernameId == null && passwordId == null +} diff --git a/app/src/main/java/com/longnh15/heimdall/StructureParser.kt b/app/src/main/java/com/longnh15/heimdall/StructureParser.kt new file mode 100644 index 0000000..eb90185 --- /dev/null +++ b/app/src/main/java/com/longnh15/heimdall/StructureParser.kt @@ -0,0 +1,93 @@ +package com.longnh15.heimdall + +import android.app.assist.AssistStructure +import android.text.InputType +import android.util.Log +import android.view.View +import android.view.autofill.AutofillId + +class StructureParser(private val structure: AssistStructure) { + + fun parse(): ParsedField { + var usernameId: AutofillId? = null + var passwordId: AutofillId? = null + + for (i in 0 until structure.windowNodeCount) { + val windowNode = structure.getWindowNodeAt(i) + Log.d("Heimdall", "Root children: ${windowNode.rootViewNode.childCount}") + traverseNode(windowNode.rootViewNode) { node -> + when (classifyNode(node)) { + FieldType.USERNAME -> if (usernameId == null) usernameId = node.autofillId + FieldType.PASSWORD -> if (passwordId == null) passwordId = node.autofillId + else -> {} + } + } + } + + return ParsedField(usernameId, passwordId) + } + + fun traverseNode(node: AssistStructure.ViewNode, visitor: (AssistStructure.ViewNode) -> Unit) { + visitor(node) + for (i in 0 until node.childCount) { + traverseNode(node.getChildAt(i), visitor) + } + } + + private enum class FieldType { USERNAME, PASSWORD, NONE } + + private fun classifyNode(node: AssistStructure.ViewNode): FieldType { + // Must be an autofill-able text field + if (node.autofillId == null) return FieldType.NONE + if (node.visibility != View.VISIBLE) return FieldType.NONE + + // --- Heuristic 1: autofillHints (most reliable, respect first) --- + node.autofillHints?.forEach { hint -> + when (hint) { + View.AUTOFILL_HINT_USERNAME, + View.AUTOFILL_HINT_EMAIL_ADDRESS -> return FieldType.USERNAME + + View.AUTOFILL_HINT_PASSWORD -> return FieldType.PASSWORD + } + } + + // --- Heuristic 2: inputType flags --- + val inputType = node.inputType + if (inputType and InputType.TYPE_TEXT_VARIATION_PASSWORD != 0 || + inputType and InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD != 0 || + inputType and InputType.TYPE_TEXT_VARIATION_WEB_PASSWORD != 0 + ) return FieldType.PASSWORD + + if (inputType and InputType.TYPE_TEXT_VARIATION_EMAIL_ADDRESS != 0 || + inputType and InputType.TYPE_TEXT_VARIATION_WEB_EMAIL_ADDRESS != 0 || + inputType and InputType.TYPE_CLASS_PHONE != 0 + ) return FieldType.USERNAME + + // --- Heuristic 3: htmlInfo for web views --- + val htmlAttrs = node.htmlInfo?.attributes + htmlAttrs?.forEach { pair -> + if (pair.first.equals("type", ignoreCase = true)) { + when (pair.second.lowercase()) { + "password" -> return FieldType.PASSWORD + "email" -> return FieldType.USERNAME // "text" is a weak signal, see note + } + } + } + + // --- Heuristic 4: hint / idEntry / contentDescription text matching --- + val textSignals = listOfNotNull( + node.hint, + node.idEntry, + node.contentDescription?.toString(), + node.text?.toString() + ).map { it.lowercase() } + + val usernameKeywords = listOf("user", "email", "login", "account", "phone", "username") + val passwordKeywords = listOf("pass", "pwd", "secret", "pin") + + if (textSignals.any { s -> passwordKeywords.any { s.contains(it) } }) return FieldType.PASSWORD + if (textSignals.any { s -> usernameKeywords.any { s.contains(it) } }) return FieldType.USERNAME + + return FieldType.NONE + } +} \ No newline at end of file diff --git a/app/src/main/java/com/longnh15/heimdall/data/preferences/AppPreferences.kt b/app/src/main/java/com/longnh15/heimdall/data/preferences/AppPreferences.kt new file mode 100644 index 0000000..bf21743 --- /dev/null +++ b/app/src/main/java/com/longnh15/heimdall/data/preferences/AppPreferences.kt @@ -0,0 +1,5 @@ +package com.longnh15.heimdall.data.preferences + +data class AppPreferences( + val isFirstLaunch: Boolean = true, +) \ No newline at end of file diff --git a/app/src/main/java/com/longnh15/heimdall/data/preferences/PreferenceDataSource.kt b/app/src/main/java/com/longnh15/heimdall/data/preferences/PreferenceDataSource.kt new file mode 100644 index 0000000..5cf933e --- /dev/null +++ b/app/src/main/java/com/longnh15/heimdall/data/preferences/PreferenceDataSource.kt @@ -0,0 +1,31 @@ +package com.longnh15.heimdall.data.preferences + +import android.content.Context +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.preferencesDataStore +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import org.koin.core.annotation.Singleton + +private val Context.dataStore by preferencesDataStore("settings") + +@Singleton +class PreferenceDataSource( + appContext: Context +) { + private object Keys { + val IS_FIRST_LAUNCH = booleanPreferencesKey("is_first_launch") + } + + private val dataStore = appContext.dataStore + val preferences: Flow = dataStore.data.map { prefs -> + AppPreferences( + isFirstLaunch = prefs[Keys.IS_FIRST_LAUNCH] ?: true + ) + } + + suspend fun setFirstLaunchDone() { + dataStore.edit { it[Keys.IS_FIRST_LAUNCH] = false } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/longnh15/heimdall/data/repository/CredentialRepositoryImpl.kt b/app/src/main/java/com/longnh15/heimdall/data/repository/CredentialRepositoryImpl.kt new file mode 100644 index 0000000..ce3b724 --- /dev/null +++ b/app/src/main/java/com/longnh15/heimdall/data/repository/CredentialRepositoryImpl.kt @@ -0,0 +1,20 @@ +package com.longnh15.heimdall.data.repository + +import com.longnh15.heimdall.data.source.local.LocalDataSource +import com.longnh15.heimdall.domain.model.Credential +import com.longnh15.heimdall.domain.repository.CredentialRepository +import kotlinx.coroutines.flow.Flow +import org.koin.core.annotation.Singleton + +@Singleton +class CredentialRepositoryImpl( + val localDataSource: LocalDataSource +) : CredentialRepository { + override suspend fun save(credential: Credential) { + localDataSource.save(credential) + } + + override fun getAll(): Flow> { + return localDataSource.getAllCredentials() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/longnh15/heimdall/data/source/local/CredentialDao.kt b/app/src/main/java/com/longnh15/heimdall/data/source/local/CredentialDao.kt new file mode 100644 index 0000000..82e50ff --- /dev/null +++ b/app/src/main/java/com/longnh15/heimdall/data/source/local/CredentialDao.kt @@ -0,0 +1,16 @@ +package com.longnh15.heimdall.data.source.local + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.Query +import com.longnh15.heimdall.data.source.local.entity.CredentialEntity +import kotlinx.coroutines.flow.Flow + +@Dao +interface CredentialDao { + @Query("SELECT * FROM credential") + fun getAll(): Flow> + + @Insert + suspend fun insert(credential: CredentialEntity) +} \ No newline at end of file diff --git a/app/src/main/java/com/longnh15/heimdall/data/source/local/HeimdallDatabase.kt b/app/src/main/java/com/longnh15/heimdall/data/source/local/HeimdallDatabase.kt new file mode 100644 index 0000000..c2fde77 --- /dev/null +++ b/app/src/main/java/com/longnh15/heimdall/data/source/local/HeimdallDatabase.kt @@ -0,0 +1,13 @@ +package com.longnh15.heimdall.data.source.local + +import androidx.room.Database +import androidx.room.RoomDatabase +import com.longnh15.heimdall.data.source.local.entity.CredentialEntity + +@Database( + entities = [CredentialEntity::class], + version = 1, +) +abstract class HeimdallDatabase: RoomDatabase() { + abstract fun credentialDao(): CredentialDao +} \ No newline at end of file diff --git a/app/src/main/java/com/longnh15/heimdall/data/source/local/LocalDataSource.kt b/app/src/main/java/com/longnh15/heimdall/data/source/local/LocalDataSource.kt new file mode 100644 index 0000000..511a0f0 --- /dev/null +++ b/app/src/main/java/com/longnh15/heimdall/data/source/local/LocalDataSource.kt @@ -0,0 +1,19 @@ +package com.longnh15.heimdall.data.source.local + +import com.longnh15.heimdall.data.source.local.entity.toDomain +import com.longnh15.heimdall.data.source.local.entity.toEntity +import com.longnh15.heimdall.domain.model.Credential +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +class LocalDataSource( + private val credentialDao: CredentialDao +) { + fun getAllCredentials(): Flow> { + return credentialDao.getAll().map { entities -> entities.map { it.toDomain() } } + } + + suspend fun save(credential: Credential) { + credentialDao.insert(credential.toEntity()) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/longnh15/heimdall/data/source/local/entity/CredentialEntity.kt b/app/src/main/java/com/longnh15/heimdall/data/source/local/entity/CredentialEntity.kt new file mode 100644 index 0000000..34ac833 --- /dev/null +++ b/app/src/main/java/com/longnh15/heimdall/data/source/local/entity/CredentialEntity.kt @@ -0,0 +1,13 @@ +package com.longnh15.heimdall.data.source.local.entity + +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity(tableName = "credential") +data class CredentialEntity( + @PrimaryKey(autoGenerate = true) val id: Long = 0L, + val name: String, + val url: String?, + val username: String, + val password: String +) \ No newline at end of file diff --git a/app/src/main/java/com/longnh15/heimdall/data/source/local/entity/CredentialMapper.kt b/app/src/main/java/com/longnh15/heimdall/data/source/local/entity/CredentialMapper.kt new file mode 100644 index 0000000..d11491e --- /dev/null +++ b/app/src/main/java/com/longnh15/heimdall/data/source/local/entity/CredentialMapper.kt @@ -0,0 +1,21 @@ +package com.longnh15.heimdall.data.source.local.entity + +import com.longnh15.heimdall.domain.model.Credential + +fun Credential.toEntity(): CredentialEntity { + return CredentialEntity( + name = name, + url = url, + username = username, + password = password + ) +} + +fun CredentialEntity.toDomain(): Credential { + return Credential( + name = name, + url = url.orEmpty(), + username = username, + password = password + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/longnh15/heimdall/data/vault/VaultSessionManagerImpl.kt b/app/src/main/java/com/longnh15/heimdall/data/vault/VaultSessionManagerImpl.kt new file mode 100644 index 0000000..9f950ab --- /dev/null +++ b/app/src/main/java/com/longnh15/heimdall/data/vault/VaultSessionManagerImpl.kt @@ -0,0 +1,42 @@ +package com.longnh15.heimdall.data.vault + +import com.longnh15.heimdall.domain.vault.VaultSessionManager +import com.longnh15.heimdall.domain.vault.VaultSessionState +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update +import org.koin.core.annotation.Singleton +import kotlin.time.Duration.Companion.seconds +import kotlin.time.TimeSource + +@Singleton +class VaultSessionManagerImpl: VaultSessionManager { + private companion object { + const val TIMEOUT_DURATION = 5 // 5 sec + } + + private val _sessionState = MutableStateFlow(VaultSessionState.Locked) + override val sessionState: StateFlow + get() = _sessionState + + override fun unlock() { + val now = TimeSource.Monotonic.markNow() + _sessionState.update { VaultSessionState.Unlocked(now + TIMEOUT_DURATION.seconds) } + } + + override fun lock() { + _sessionState.update { VaultSessionState.Locked } + } + + override fun refreshSession() { + val currentState = sessionState.value + if (currentState is VaultSessionState.Unlocked) { + val now = TimeSource.Monotonic.markNow() + if (currentState.expiredAt <= now) { + _sessionState.update { VaultSessionState.Locked } + } else { + _sessionState.update { VaultSessionState.Unlocked(now + TIMEOUT_DURATION.seconds) } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/longnh15/heimdall/di/AppModule.kt b/app/src/main/java/com/longnh15/heimdall/di/AppModule.kt new file mode 100644 index 0000000..fae9d2a --- /dev/null +++ b/app/src/main/java/com/longnh15/heimdall/di/AppModule.kt @@ -0,0 +1,40 @@ +package com.longnh15.heimdall.di + +import com.longnh15.heimdall.MainViewModel +import com.longnh15.heimdall.data.preferences.PreferenceDataSource +import com.longnh15.heimdall.data.repository.CredentialRepositoryImpl +import com.longnh15.heimdall.data.source.local.LocalDataSource +import com.longnh15.heimdall.data.vault.VaultSessionManagerImpl +import com.longnh15.heimdall.domain.repository.CredentialRepository +import com.longnh15.heimdall.domain.vault.VaultSessionManager +import com.longnh15.heimdall.ui.onboarding.OnboardingViewModel +import com.longnh15.heimdall.ui.vault.VaultViewModel +import com.longnh15.heimdall.ui.vault.edit.AddEditVaultViewModel +import org.koin.core.annotation.Module +import org.koin.core.annotation.Single +import org.koin.dsl.bind +import org.koin.dsl.module +import org.koin.plugin.module.dsl.single +import org.koin.plugin.module.dsl.viewModel + +//@Module +//class AppModule { +// @Single +// fun provideVaultSessionManager(): VaultSessionManager = VaultSessionManagerImpl() + +// @Single +// fun bindCredentialRepository( +// localDataSource: LocalDataSource +// ): CredentialRepository = CredentialRepositoryImpl(localDataSource) +//} + +val appModule = module { + single() bind VaultSessionManager::class + single() bind CredentialRepository::class + single() + + viewModel() + viewModel() + viewModel() + viewModel() +} \ No newline at end of file diff --git a/app/src/main/java/com/longnh15/heimdall/di/DatabaseModule.kt b/app/src/main/java/com/longnh15/heimdall/di/DatabaseModule.kt new file mode 100644 index 0000000..6ee555c --- /dev/null +++ b/app/src/main/java/com/longnh15/heimdall/di/DatabaseModule.kt @@ -0,0 +1,30 @@ +package com.longnh15.heimdall.di + +import android.content.Context +import androidx.room.Room +import com.longnh15.heimdall.data.source.local.CredentialDao +import com.longnh15.heimdall.data.source.local.HeimdallDatabase +import com.longnh15.heimdall.data.source.local.LocalDataSource +import org.koin.core.annotation.Module +import org.koin.core.annotation.Single + +@Module +class DatabaseModule { + @Single + fun provideDatabase(context: Context): HeimdallDatabase = + Room.databaseBuilder( + context, + HeimdallDatabase::class.java, + "heimdall.db" + ).build() + + @Single + fun provideCredentialDao(db: HeimdallDatabase): CredentialDao { + return db.credentialDao() + } + + @Single + fun provideLocalDataSource(credentialDao: CredentialDao): LocalDataSource { + return LocalDataSource(credentialDao) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/longnh15/heimdall/domain/model/Credential.kt b/app/src/main/java/com/longnh15/heimdall/domain/model/Credential.kt new file mode 100644 index 0000000..869f365 --- /dev/null +++ b/app/src/main/java/com/longnh15/heimdall/domain/model/Credential.kt @@ -0,0 +1,8 @@ +package com.longnh15.heimdall.domain.model + +data class Credential( + val name: String, + val url: String, + val username: String, + val password: String, +) diff --git a/app/src/main/java/com/longnh15/heimdall/domain/repository/CredentialRepository.kt b/app/src/main/java/com/longnh15/heimdall/domain/repository/CredentialRepository.kt new file mode 100644 index 0000000..b454a1e --- /dev/null +++ b/app/src/main/java/com/longnh15/heimdall/domain/repository/CredentialRepository.kt @@ -0,0 +1,9 @@ +package com.longnh15.heimdall.domain.repository + +import com.longnh15.heimdall.domain.model.Credential +import kotlinx.coroutines.flow.Flow + +interface CredentialRepository { + fun getAll(): Flow> + suspend fun save(credential: Credential) +} \ No newline at end of file diff --git a/app/src/main/java/com/longnh15/heimdall/domain/vault/VaultSessionManager.kt b/app/src/main/java/com/longnh15/heimdall/domain/vault/VaultSessionManager.kt new file mode 100644 index 0000000..62b54f9 --- /dev/null +++ b/app/src/main/java/com/longnh15/heimdall/domain/vault/VaultSessionManager.kt @@ -0,0 +1,12 @@ +package com.longnh15.heimdall.domain.vault + +import kotlinx.coroutines.flow.StateFlow +import org.koin.core.annotation.Singleton + +interface VaultSessionManager { + val sessionState: StateFlow + + fun unlock() + fun lock() + fun refreshSession() +} \ No newline at end of file diff --git a/app/src/main/java/com/longnh15/heimdall/domain/vault/VaultSessionState.kt b/app/src/main/java/com/longnh15/heimdall/domain/vault/VaultSessionState.kt new file mode 100644 index 0000000..9967d40 --- /dev/null +++ b/app/src/main/java/com/longnh15/heimdall/domain/vault/VaultSessionState.kt @@ -0,0 +1,8 @@ +package com.longnh15.heimdall.domain.vault + +import kotlin.time.TimeSource + +sealed class VaultSessionState { + data object Locked: VaultSessionState() + data class Unlocked(val expiredAt: TimeSource.Monotonic.ValueTimeMark): VaultSessionState() +} diff --git a/app/src/main/java/com/longnh15/heimdall/navigation/HeimdallNavGraph.kt b/app/src/main/java/com/longnh15/heimdall/navigation/HeimdallNavGraph.kt new file mode 100644 index 0000000..ebb5683 --- /dev/null +++ b/app/src/main/java/com/longnh15/heimdall/navigation/HeimdallNavGraph.kt @@ -0,0 +1,59 @@ +package com.longnh15.heimdall.navigation + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.navigation.NavHostController +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import com.longnh15.heimdall.ui.onboarding.OnboardingPage +import com.longnh15.heimdall.ui.onboarding.OnboardingViewModel +import com.longnh15.heimdall.ui.vault.VaultScreen +import com.longnh15.heimdall.ui.vault.VaultViewModel +import com.longnh15.heimdall.ui.vault.edit.AddEditVaultItemScreen +import com.longnh15.heimdall.ui.vault.edit.AddEditVaultViewModel +import org.koin.compose.viewmodel.koinViewModel + +@Composable +fun HeimdallNavGraph( + modifier: Modifier = Modifier, + navController: NavHostController = rememberNavController(), + startDestination: Screen = Screen.Onboarding, + isAutofillServiceEnabled: () -> Boolean, + onCompleteOnboarding: () -> Unit, +) { + NavHost( + navController = navController, + startDestination = startDestination, + modifier = modifier + ) { + composable { + OnboardingPage( + viewModel = koinViewModel(), + isAutofillEnabled = isAutofillServiceEnabled, + navigateToVault = { + onCompleteOnboarding() + navController.navigate(Screen.Vault) { + popUpTo(Screen.Onboarding) { inclusive = true } + } + } + ) + } + composable { + VaultScreen( + viewModel = koinViewModel(), + navigateToAddScreen = { + navController.navigate(Screen.NewVaultItem) + } + ) + } + composable { + AddEditVaultItemScreen( + viewModel = koinViewModel(), + popScreen = { + navController.navigateUp() + } + ) + } + } +} diff --git a/app/src/main/java/com/longnh15/heimdall/navigation/Screen.kt b/app/src/main/java/com/longnh15/heimdall/navigation/Screen.kt new file mode 100644 index 0000000..a47cb80 --- /dev/null +++ b/app/src/main/java/com/longnh15/heimdall/navigation/Screen.kt @@ -0,0 +1,13 @@ +package com.longnh15.heimdall.navigation + +import kotlinx.serialization.Serializable + +@Serializable +sealed class Screen { + @Serializable + data object Onboarding: Screen() + @Serializable + data object Vault: Screen() + @Serializable + data object NewVaultItem: Screen() +} \ No newline at end of file diff --git a/app/src/main/java/com/longnh15/heimdall/ui/common/AutofillNotEnabledDialog.kt b/app/src/main/java/com/longnh15/heimdall/ui/common/AutofillNotEnabledDialog.kt new file mode 100644 index 0000000..a69f107 --- /dev/null +++ b/app/src/main/java/com/longnh15/heimdall/ui/common/AutofillNotEnabledDialog.kt @@ -0,0 +1,32 @@ +package com.longnh15.heimdall.ui.common + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Warning +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable + +@Composable +fun AutofillNotEnabledDialog( + onRetry: () -> Unit, + onDismiss: () -> Unit +) { + AlertDialog( + onDismissRequest = onDismiss, + icon = { + Icon(Icons.Outlined.Warning, contentDescription = null) + }, + title = { Text("Autofill not enabled") }, + text = { + Text("Heimdall wasn't selected as your autofill provider. Open settings and tap Heimdall to enable it.") + }, + confirmButton = { + TextButton(onClick = onRetry) { Text("Open settings") } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text("Skip for now") } + } + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/longnh15/heimdall/ui/onboarding/OnboardingBadge.kt b/app/src/main/java/com/longnh15/heimdall/ui/onboarding/OnboardingBadge.kt new file mode 100644 index 0000000..7d16c6e --- /dev/null +++ b/app/src/main/java/com/longnh15/heimdall/ui/onboarding/OnboardingBadge.kt @@ -0,0 +1,17 @@ +package com.longnh15.heimdall.ui.onboarding + +import androidx.compose.foundation.layout.RowScope +import androidx.compose.material3.Badge +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier + +@Composable +fun OnboardingBadge(modifier: Modifier = Modifier, content: @Composable (RowScope.() -> Unit)? = null) { + Badge( + modifier = modifier, + containerColor = MaterialTheme.colorScheme.surfaceVariant, + contentColor = MaterialTheme.colorScheme.onSurfaceVariant, + content = content + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/longnh15/heimdall/ui/onboarding/OnboardingEvent.kt b/app/src/main/java/com/longnh15/heimdall/ui/onboarding/OnboardingEvent.kt new file mode 100644 index 0000000..f1d2050 --- /dev/null +++ b/app/src/main/java/com/longnh15/heimdall/ui/onboarding/OnboardingEvent.kt @@ -0,0 +1,6 @@ +package com.longnh15.heimdall.ui.onboarding + +sealed class OnboardingEvent { + data object OpenAutofillSettings : OnboardingEvent() + data object NavigateToVault : OnboardingEvent() +} \ No newline at end of file diff --git a/app/src/main/java/com/longnh15/heimdall/ui/onboarding/OnboardingFinalPage.kt b/app/src/main/java/com/longnh15/heimdall/ui/onboarding/OnboardingFinalPage.kt new file mode 100644 index 0000000..f743b92 --- /dev/null +++ b/app/src/main/java/com/longnh15/heimdall/ui/onboarding/OnboardingFinalPage.kt @@ -0,0 +1,62 @@ +package com.longnh15.heimdall.ui.onboarding + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +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.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.longnh15.heimdall.R + +@Composable +fun OnboardingFinalPage( + modifier: Modifier = Modifier, + navigateToVault: () -> Unit +) { + Column( + modifier = modifier.padding(48.dp, 32.dp).fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(32.dp) + ) { + OnboardingBadge { + Text("You're all set") + } + Box( + modifier = Modifier + .size(72.dp) + .background(Color(0xFF0A2E1A), shape = RoundedCornerShape(20.dp)), + contentAlignment = Alignment.Center + ) { + Image( + painterResource(R.drawable.tick_icon), + "Page icon", + ) + } + + Text("Heimdall is watching", style = MaterialTheme.typography.headlineMedium) + Text( + text = "Your vault is ready. Start adding passwords or import from your browser to get going.", + textAlign = TextAlign.Center + ) + + Spacer(Modifier.weight(1f)) + + Button(onClick = navigateToVault) { + Text("Enter my vault ↗") + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/longnh15/heimdall/ui/onboarding/OnboardingIntroducePage.kt b/app/src/main/java/com/longnh15/heimdall/ui/onboarding/OnboardingIntroducePage.kt new file mode 100644 index 0000000..33170af --- /dev/null +++ b/app/src/main/java/com/longnh15/heimdall/ui/onboarding/OnboardingIntroducePage.kt @@ -0,0 +1,99 @@ +package com.longnh15.heimdall.ui.onboarding + +import androidx.annotation.DrawableRes +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +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.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.longnh15.heimdall.R + +@Composable +fun OnboardingIntroducePage(modifier: Modifier = Modifier) { + Column( + modifier = modifier.padding(48.dp, 32.dp).fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(32.dp) + ) { + OnboardingBadge { + Text("What you get") + } + Text("Everything in one vault", style = MaterialTheme.typography.headlineMedium) + Text( + text = "Heimdall keeps your passwords safe and fills them in automatically", + textAlign = TextAlign.Center + ) + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + FeatureItem( + iconDrawableRes = R.drawable.lock_icon, + text = "End-to-end encrypted", + sub = "Only you can see your data" + ) + FeatureItem( + iconDrawableRes = R.drawable.crosshair_icon, + text = "Autofill anywhere", + sub = "Works across all your apps" + ) + FeatureItem( + iconDrawableRes = R.drawable.person_icon, + text = "Biometric unlock", + sub = "Fingerprint or face to open" + ) + } + } +} + +@Composable +fun FeatureItem( + modifier: Modifier = Modifier, + @DrawableRes iconDrawableRes: Int, + text: String, + sub: String +) { + Row( + modifier = modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.secondary, shape = RoundedCornerShape(8.dp)) + .border( + 0.5.dp, + MaterialTheme.colorScheme.tertiary, + RoundedCornerShape(8.dp) + ) + .padding(12.dp, 14.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Box( + modifier = Modifier + .size(32.dp) + .background(Color(0xFFEEEDFE), RoundedCornerShape(8.dp)), + contentAlignment = Alignment.Center + ) { + Image(painterResource(iconDrawableRes), "Icon") + } + Column { + Text(text = text, fontSize = 13.sp, fontWeight = FontWeight(500), color = MaterialTheme.colorScheme.onPrimary) + Text(text = sub, fontSize = 12.sp, color = MaterialTheme.colorScheme.onSecondary) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/longnh15/heimdall/ui/onboarding/OnboardingPage.kt b/app/src/main/java/com/longnh15/heimdall/ui/onboarding/OnboardingPage.kt new file mode 100644 index 0000000..f5816a1 --- /dev/null +++ b/app/src/main/java/com/longnh15/heimdall/ui/onboarding/OnboardingPage.kt @@ -0,0 +1,134 @@ +package com.longnh15.heimdall.ui.onboarding + +import android.content.Intent +import android.provider.Settings +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +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.draw.clip +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.core.net.toUri +import com.longnh15.heimdall.ui.common.AutofillNotEnabledDialog + +private sealed class OnboardingPages { + data object Welcome : OnboardingPages() + data object Introduce : OnboardingPages() + data object EnableAutofill : OnboardingPages() + data object Final : OnboardingPages() +} + +private val onboardingPages = listOf( + OnboardingPages.Welcome, + OnboardingPages.Introduce, + OnboardingPages.EnableAutofill, + OnboardingPages.Final +) + +@Composable +fun OnboardingPage( + modifier: Modifier = Modifier, + viewModel: OnboardingViewModel = viewModel(), + isAutofillEnabled: () -> Boolean, + navigateToVault: () -> Unit +) { + val pagerState = rememberPagerState { onboardingPages.size } + var showDialog by remember { mutableStateOf(false) } + val context = LocalContext.current + val launcher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.StartActivityForResult() + ) { + if (isAutofillEnabled()) { + pagerState.requestScrollToPage(3) + } else { + showDialog = true + } + } + + LaunchedEffect(Unit) { + viewModel.uiEvent.collect { event -> + when (event) { + OnboardingEvent.OpenAutofillSettings -> { + val intent = Intent(Settings.ACTION_REQUEST_SET_AUTOFILL_SERVICE).apply { + data = "package:${context.packageName}".toUri() + } + launcher.launch(intent) + } + OnboardingEvent.NavigateToVault -> navigateToVault() + } + } + } + + Scaffold(Modifier.fillMaxSize()) { paddingValues -> + Column(modifier.padding(paddingValues)) { + HorizontalPager( + modifier = Modifier.weight(1f), + state = pagerState + ) { pageState -> + when (onboardingPages[pageState]) { + OnboardingPages.Welcome -> OnboardingWelcomePage() + OnboardingPages.Introduce -> OnboardingIntroducePage() + OnboardingPages.EnableAutofill -> OnboardingRequestEnablePage(onOpenAutofillSettings = viewModel::onOpenAutofillSettings) + OnboardingPages.Final -> OnboardingFinalPage(navigateToVault = navigateToVault) + } + } + + // Indicator + Row( + Modifier.wrapContentHeight().padding(bottom = 32.dp).align(Alignment.CenterHorizontally), + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + repeat(pagerState.pageCount) { iteration -> + val isSelected = pagerState.currentPage == iteration + val width by animateDpAsState(targetValue = if (isSelected) 24.dp else 8.dp) + val color by animateColorAsState(targetValue = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.inversePrimary) + + Box( + modifier = Modifier + .clip(RoundedCornerShape(3.dp)) + .background(color) + .width(width).height(6.dp) + ) + } + } + } + } + + if (showDialog) { + AutofillNotEnabledDialog( + onRetry = { + showDialog = false + val intent = Intent(Settings.ACTION_REQUEST_SET_AUTOFILL_SERVICE).apply { + data = "package:${context.packageName}".toUri() + } + launcher.launch(intent) + }, + onDismiss = { showDialog = false } + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/longnh15/heimdall/ui/onboarding/OnboardingRequestEnablePage.kt b/app/src/main/java/com/longnh15/heimdall/ui/onboarding/OnboardingRequestEnablePage.kt new file mode 100644 index 0000000..8e95612 --- /dev/null +++ b/app/src/main/java/com/longnh15/heimdall/ui/onboarding/OnboardingRequestEnablePage.kt @@ -0,0 +1,63 @@ +package com.longnh15.heimdall.ui.onboarding + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Badge +import androidx.compose.material3.Button +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.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.longnh15.heimdall.R + +@Composable +fun OnboardingRequestEnablePage( + modifier: Modifier = Modifier, + onOpenAutofillSettings: () -> Unit +) { + Column( + modifier = modifier.padding(48.dp, 32.dp).fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(32.dp) + ) { + OnboardingBadge { + Text("Setup · 1 of 2") + } + Box( + modifier = Modifier + .size(72.dp) + .background(Color(0xFF1A1A2E), shape = RoundedCornerShape(20.dp)), + contentAlignment = Alignment.Center + ) { + Image( + painterResource(R.drawable.page_icon), + "Page icon", + ) + } + + Text("Enable autofill", style = MaterialTheme.typography.headlineMedium) + Text( + text = "Let Heimdall fill your passwords automatically. You'll be taken to Android settings — tap Heimdall and you're done.", + textAlign = TextAlign.Center + ) + + Spacer(Modifier.weight(1f)) + + Button(onClick = onOpenAutofillSettings) { + Text("Open settings") + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/longnh15/heimdall/ui/onboarding/OnboardingViewModel.kt b/app/src/main/java/com/longnh15/heimdall/ui/onboarding/OnboardingViewModel.kt new file mode 100644 index 0000000..3f52c1e --- /dev/null +++ b/app/src/main/java/com/longnh15/heimdall/ui/onboarding/OnboardingViewModel.kt @@ -0,0 +1,20 @@ +package com.longnh15.heimdall.ui.onboarding + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.launch +import org.koin.core.annotation.KoinViewModel + +@KoinViewModel +class OnboardingViewModel : ViewModel() { + private val _uiEvent = Channel() + val uiEvent = _uiEvent.receiveAsFlow() + + fun onOpenAutofillSettings() { + viewModelScope.launch { + _uiEvent.send(OnboardingEvent.OpenAutofillSettings) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/longnh15/heimdall/ui/onboarding/OnboardingWelcomePage.kt b/app/src/main/java/com/longnh15/heimdall/ui/onboarding/OnboardingWelcomePage.kt new file mode 100644 index 0000000..16e7100 --- /dev/null +++ b/app/src/main/java/com/longnh15/heimdall/ui/onboarding/OnboardingWelcomePage.kt @@ -0,0 +1,54 @@ +package com.longnh15.heimdall.ui.onboarding + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +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.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.longnh15.heimdall.R + +@Composable +fun OnboardingWelcomePage(modifier: Modifier = Modifier) { + Column( + modifier = modifier.padding(48.dp, 32.dp).fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(32.dp) + ) { + Box( + modifier = Modifier + .size(72.dp) + .background(Color(0xFF1A1A2E), shape = RoundedCornerShape(20.dp)), + contentAlignment = Alignment.Center + ) { + Image( + painterResource(R.drawable.shield_icon), + "Heimdall Logo", + ) + } + Text("Welcome to Heimdall", style = MaterialTheme.typography.headlineMedium) + Text( + text = "Your all-seeing guardian for passwords. Secure, private, and always watching over your credentials", + textAlign = TextAlign.Center + ) + } +} + +@Preview(showBackground = true, backgroundColor = 0xFFFFFFFF) +@Composable +fun OnboardingWelcomePagePreview() { + OnboardingWelcomePage(Modifier.background(Color.White)) +} \ No newline at end of file diff --git a/app/src/main/java/com/longnh15/heimdall/ui/theme/Color.kt b/app/src/main/java/com/longnh15/heimdall/ui/theme/Color.kt new file mode 100644 index 0000000..a5f03d9 --- /dev/null +++ b/app/src/main/java/com/longnh15/heimdall/ui/theme/Color.kt @@ -0,0 +1,67 @@ +package com.longnh15.heimdall.ui.theme + +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.ui.graphics.Color + +val HeimdallLightColors = lightColorScheme( + primary = Color(0xFF534AB7), + onPrimary = Color(0xFFFFFFFF), + primaryContainer = Color(0xFFEEEDFE), + onPrimaryContainer = Color(0xFF26215C), + + secondary = Color(0xFF1D9E75), + onSecondary = Color(0xFFFFFFFF), + secondaryContainer = Color(0xFFE1F5EE), + onSecondaryContainer = Color(0xFF04342C), + + tertiary = Color(0xFF7F77DD), + onTertiary = Color(0xFFFFFFFF), + tertiaryContainer = Color(0xFFCECBF6), + onTertiaryContainer = Color(0xFF26215C), + + error = Color(0xFFE24B4A), + onError = Color(0xFFFFFFFF), + errorContainer = Color(0xFFFCEBEB), + onErrorContainer = Color(0xFF501313), + + background = Color(0xFFF8F8FC), + onBackground = Color(0xFF1A1A2E), + surface = Color(0xFFFFFFFF), + onSurface = Color(0xFF1A1A2E), + surfaceVariant = Color(0xFFEEEDFE), + onSurfaceVariant = Color(0xFF534AB7), + outline = Color(0xFFAFA9EC), + outlineVariant = Color(0xFFCECBF6), +) + +val HeimdallDarkColors = darkColorScheme( + primary = Color(0xFF7F77DD), + onPrimary = Color(0xFF26215C), + primaryContainer = Color(0xFF3C3489), + onPrimaryContainer = Color(0xFFCECBF6), + + secondary = Color(0xFF5DCAA5), + onSecondary = Color(0xFF04342C), + secondaryContainer = Color(0xFF0F6E56), + onSecondaryContainer = Color(0xFF9FE1CB), + + tertiary = Color(0xFFAFA9EC), + onTertiary = Color(0xFF26215C), + tertiaryContainer = Color(0xFF534AB7), + onTertiaryContainer = Color(0xFFEEEDFE), + + error = Color(0xFFF09595), + onError = Color(0xFF501313), + errorContainer = Color(0xFFA32D2D), + onErrorContainer = Color(0xFFF7C1C1), + + background = Color(0xFF1A1A2E), + onBackground = Color(0xFFEEEDFE), + surface = Color(0xFF26215C), + onSurface = Color(0xFFEEEDFE), + surfaceVariant = Color(0xFF3C3489), + onSurfaceVariant = Color(0xFFAFA9EC), + outline = Color(0xFF7F77DD), + outlineVariant = Color(0xFF534AB7), +) \ No newline at end of file diff --git a/app/src/main/java/com/longnh15/heimdall/ui/theme/Theme.kt b/app/src/main/java/com/longnh15/heimdall/ui/theme/Theme.kt new file mode 100644 index 0000000..77ec77d --- /dev/null +++ b/app/src/main/java/com/longnh15/heimdall/ui/theme/Theme.kt @@ -0,0 +1,49 @@ +package com.longnh15.heimdall.ui.theme + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Typography +import androidx.compose.runtime.Composable +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp + +@Composable +fun HeimdallTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + content: @Composable () -> Unit +) { + val colorScheme = if (darkTheme) { + HeimdallDarkColors + } else { + HeimdallLightColors + } + + MaterialTheme( + colorScheme = colorScheme, + typography = HeimdallTypography, + content = content + ) +} + +// ui/theme/Type.kt +val HeimdallTypography = Typography( + headlineLarge = TextStyle( + fontWeight = FontWeight.SemiBold, + fontSize = 28.sp + ), + titleMedium = TextStyle( + fontWeight = FontWeight.Medium, + fontSize = 16.sp + ), + bodyMedium = TextStyle( + fontWeight = FontWeight.Normal, + fontSize = 14.sp, + lineHeight = 22.sp + ), + labelSmall = TextStyle( + fontWeight = FontWeight.Medium, + fontSize = 11.sp, + letterSpacing = 0.5.sp + ) +) \ No newline at end of file diff --git a/app/src/main/java/com/longnh15/heimdall/ui/theme/Type.kt b/app/src/main/java/com/longnh15/heimdall/ui/theme/Type.kt new file mode 100644 index 0000000..6fff4c5 --- /dev/null +++ b/app/src/main/java/com/longnh15/heimdall/ui/theme/Type.kt @@ -0,0 +1,34 @@ +package com.longnh15.heimdall.ui.theme + +import androidx.compose.material3.Typography +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp + +// Set of Material typography styles to start with +val Typography = Typography( + bodyLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.5.sp + ) + /* Other default text styles to override + titleLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 22.sp, + lineHeight = 28.sp, + letterSpacing = 0.sp + ), + labelSmall = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Medium, + fontSize = 11.sp, + lineHeight = 16.sp, + letterSpacing = 0.5.sp + ) + */ +) \ No newline at end of file diff --git a/app/src/main/java/com/longnh15/heimdall/ui/vault/LockOverlay.kt b/app/src/main/java/com/longnh15/heimdall/ui/vault/LockOverlay.kt new file mode 100644 index 0000000..d8f6d60 --- /dev/null +++ b/app/src/main/java/com/longnh15/heimdall/ui/vault/LockOverlay.kt @@ -0,0 +1,67 @@ +package com.longnh15.heimdall.ui.vault + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +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.size +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Fingerprint +import androidx.compose.material.icons.outlined.Lock +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.unit.dp + +@Composable +fun LockOverlay( + modifier: Modifier = Modifier, + requestBiometric: () -> Unit, +) { + Box( + modifier = modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Icon( + imageVector = Icons.Outlined.Lock, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(48.dp) + ) + Text( + text = "Vault is locked", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onBackground + ) + Text( + text = "Authenticate to access your credentials", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.height(8.dp)) + Button(onClick = requestBiometric) { + Icon( + Icons.Outlined.Fingerprint, + contentDescription = null, + modifier = Modifier.size(18.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text("Unlock with biometrics") + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/longnh15/heimdall/ui/vault/VaultContent.kt b/app/src/main/java/com/longnh15/heimdall/ui/vault/VaultContent.kt new file mode 100644 index 0000000..5b0f8dd --- /dev/null +++ b/app/src/main/java/com/longnh15/heimdall/ui/vault/VaultContent.kt @@ -0,0 +1,43 @@ +package com.longnh15.heimdall.ui.vault + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.padding +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.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.Scaffold +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.longnh15.heimdall.domain.model.Credential + +@Composable +fun VaultContent( + credentials: List, + navigateToAddScreen: () -> Unit, + modifier: Modifier = Modifier, +) { + Scaffold( + modifier = modifier, + topBar = { VaultHeader() }, + floatingActionButton = { + FloatingActionButton(onClick = navigateToAddScreen) { + Icon(Icons.Filled.Add, null) + } + }, + ) { paddingValues -> + LazyColumn( + modifier = Modifier + .padding(paddingValues) + .padding(14.dp), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + items(credentials) { credential -> + VaultItem(credential) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/longnh15/heimdall/ui/vault/VaultHeader.kt b/app/src/main/java/com/longnh15/heimdall/ui/vault/VaultHeader.kt new file mode 100644 index 0000000..769be45 --- /dev/null +++ b/app/src/main/java/com/longnh15/heimdall/ui/vault/VaultHeader.kt @@ -0,0 +1,54 @@ +package com.longnh15.heimdall.ui.vault + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.AccessTime +import androidx.compose.material.icons.outlined.Person +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.IconButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.unit.dp + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun VaultHeader() { + TopAppBar( + title = { + Text("My vault") + }, + colors = TopAppBarDefaults.topAppBarColors().copy( + containerColor = MaterialTheme.colorScheme.background + ), + actions = { + IconButton( + colors = IconButtonDefaults.iconButtonColors().copy( + containerColor = MaterialTheme.colorScheme.primaryContainer, + contentColor = MaterialTheme.colorScheme.onPrimaryContainer + ), + onClick = {} + ) { + Icon(Icons.Outlined.Person, null) + } + + IconButton( + colors = IconButtonDefaults.iconButtonColors().copy( + containerColor = MaterialTheme.colorScheme.primaryContainer, + contentColor = MaterialTheme.colorScheme.onPrimaryContainer + ), + onClick = {} + ) { + Icon(Icons.Outlined.AccessTime, null) + } + } + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/longnh15/heimdall/ui/vault/VaultItem.kt b/app/src/main/java/com/longnh15/heimdall/ui/vault/VaultItem.kt new file mode 100644 index 0000000..78ddf15 --- /dev/null +++ b/app/src/main/java/com/longnh15/heimdall/ui/vault/VaultItem.kt @@ -0,0 +1,93 @@ +package com.longnh15.heimdall.ui.vault + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.ContentCopy +import androidx.compose.material.icons.outlined.Lock +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.IconButtonDefaults +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.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.longnh15.heimdall.domain.model.Credential + +@Composable +fun VaultItem( + credential: Credential, + modifier: Modifier = Modifier +) { + Row( + modifier = modifier + .clip(RoundedCornerShape(12.dp)) + .background(MaterialTheme.colorScheme.onPrimary) + .border(0.5.dp, MaterialTheme.colorScheme.primaryContainer, shape = RoundedCornerShape(12.dp)) + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + Box( + modifier = Modifier + .size(36.dp) + .clip(RoundedCornerShape(10.dp)) + .background(Color(0xFF4A1B0C)), + contentAlignment = Alignment.Center + ) { + Text(credential.name.first().uppercase(), color = Color.White) + } + + Column(modifier = Modifier) { + Text( + text = credential.name, + fontSize = 13.sp, + color = MaterialTheme.colorScheme.onTertiaryContainer, + fontWeight = FontWeight(500) + ) + Text( + text = credential.username, + fontSize = 11.sp, + color = MaterialTheme.colorScheme.outline + ) + } + + Spacer(Modifier.weight(1f)) + + VaultItemAction(icon = Icons.Outlined.ContentCopy, onClick = {}) + VaultItemAction(icon = Icons.Outlined.Lock, onClick = {}) + } +} + +@Composable +fun VaultItemAction( + icon: ImageVector, + onClick: () -> Unit +) { + IconButton( + modifier = Modifier.size(32.dp), + onClick = onClick, + shape = RoundedCornerShape(8.dp), + colors = IconButtonDefaults.iconButtonColors().copy( + containerColor = MaterialTheme.colorScheme.surfaceVariant, + contentColor = MaterialTheme.colorScheme.onSurfaceVariant + ) + ) { + Icon(icon, null, modifier = Modifier.size(16.dp)) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/longnh15/heimdall/ui/vault/VaultScreen.kt b/app/src/main/java/com/longnh15/heimdall/ui/vault/VaultScreen.kt new file mode 100644 index 0000000..c6826a2 --- /dev/null +++ b/app/src/main/java/com/longnh15/heimdall/ui/vault/VaultScreen.kt @@ -0,0 +1,67 @@ +package com.longnh15.heimdall.ui.vault + +import androidx.biometric.AuthenticationRequest +import androidx.biometric.AuthenticationRequest.Companion.biometricRequest +import androidx.biometric.AuthenticationResult +import androidx.biometric.AuthenticationResultCallback +import androidx.biometric.compose.rememberAuthenticationLauncher +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel + +@Composable +fun VaultScreen( + modifier: Modifier = Modifier, + viewModel: VaultViewModel = viewModel(), + navigateToAddScreen: () -> Unit, +) { + val isUnlocked by viewModel.isUnlocked.collectAsStateWithLifecycle() + val credentials by viewModel.credentials.collectAsStateWithLifecycle() + val launcher = rememberAuthenticationLauncher( + resultCallback = object : AuthenticationResultCallback { + override fun onAuthResult(result: AuthenticationResult) { + viewModel.requestUnlock() + } + override fun onAuthAttemptFailed() { + } + } + ) + val requestBiometric = remember { { + val request = biometricRequest( + title = "Unlock Heimdall", + AuthenticationRequest.Biometric.Fallback.DeviceCredential, + ) { + setSubtitle("Confirm your identity to access your vault") + } + launcher.launch(request) + } } + + LaunchedEffect(isUnlocked) { + if (!isUnlocked) { + requestBiometric() + } + } + + VaultContent( + credentials = credentials, + navigateToAddScreen = navigateToAddScreen + ) + + AnimatedVisibility( + visible = !isUnlocked, + enter = fadeIn(), + exit = fadeOut() + ) { + LockOverlay( + modifier = modifier, + requestBiometric = requestBiometric + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/longnh15/heimdall/ui/vault/VaultViewModel.kt b/app/src/main/java/com/longnh15/heimdall/ui/vault/VaultViewModel.kt new file mode 100644 index 0000000..f7ae029 --- /dev/null +++ b/app/src/main/java/com/longnh15/heimdall/ui/vault/VaultViewModel.kt @@ -0,0 +1,32 @@ +package com.longnh15.heimdall.ui.vault + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.longnh15.heimdall.domain.repository.CredentialRepository +import com.longnh15.heimdall.domain.vault.VaultSessionManager +import com.longnh15.heimdall.domain.vault.VaultSessionState +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import org.koin.core.annotation.KoinViewModel + +@KoinViewModel +class VaultViewModel ( + private val vaultSessionManager: VaultSessionManager, + private val credentialRepository: CredentialRepository, +): ViewModel() { + val isUnlocked: StateFlow = vaultSessionManager.sessionState + .map { it is VaultSessionState.Unlocked } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), false) + val credentials = credentialRepository.getAll() + .stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(), + listOf() + ) + + fun requestUnlock() { + vaultSessionManager.unlock() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/longnh15/heimdall/ui/vault/edit/AddEditEvent.kt b/app/src/main/java/com/longnh15/heimdall/ui/vault/edit/AddEditEvent.kt new file mode 100644 index 0000000..9ac9efe --- /dev/null +++ b/app/src/main/java/com/longnh15/heimdall/ui/vault/edit/AddEditEvent.kt @@ -0,0 +1,6 @@ +package com.longnh15.heimdall.ui.vault.edit + +sealed class AddEditEvent { + data object SaveSuccess: AddEditEvent() + data class SaveError(val message: String): AddEditEvent() +} \ No newline at end of file diff --git a/app/src/main/java/com/longnh15/heimdall/ui/vault/edit/AddEditForm.kt b/app/src/main/java/com/longnh15/heimdall/ui/vault/edit/AddEditForm.kt new file mode 100644 index 0000000..f26d12d --- /dev/null +++ b/app/src/main/java/com/longnh15/heimdall/ui/vault/edit/AddEditForm.kt @@ -0,0 +1,146 @@ +package com.longnh15.heimdall.ui.vault.edit + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.tooling.preview.AndroidUiModes.UI_MODE_NIGHT_YES +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.longnh15.heimdall.ui.theme.HeimdallTheme + +@Composable +fun AddEditForm( + modifier: Modifier = Modifier, + content: @Composable ColumnScope.() -> Unit +) { + Column( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .background(MaterialTheme.colorScheme.onPrimary) + .border(0.5.dp, MaterialTheme.colorScheme.outline, RoundedCornerShape(12.dp)), + content = content + ) +} + +@Composable +fun AddEditFormTextField( + value: String, + onValueChange: (String) -> Unit, + label: String, + placeholder: String, + isHideContent: Boolean = false, + isLastField: Boolean = false, + autoCorrectEnabled: Boolean = true, + actions: @Composable RowScope.() -> Unit = {}, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = label, + modifier = Modifier.width(100.dp).padding(vertical = 12.dp), + color = MaterialTheme.colorScheme.primary, + fontSize = 14.sp + ) + BasicTextField( + value = value, + onValueChange = onValueChange, + modifier = Modifier.weight(1f), + textStyle = TextStyle( + color = MaterialTheme.colorScheme.onSurface, + fontSize = 16.sp + ), + keyboardOptions = if (isLastField) + KeyboardOptions.Default.copy( + autoCorrectEnabled = autoCorrectEnabled, + imeAction = ImeAction.Done + ) + else + KeyboardOptions.Default.copy( + autoCorrectEnabled = autoCorrectEnabled, + imeAction = ImeAction.Next + ), + singleLine = true, + visualTransformation = if (isHideContent) PasswordVisualTransformation() else VisualTransformation.None, + decorationBox = { innerTextField -> + if (value.isEmpty()) { + Text( + text = placeholder, + fontSize = 16.sp, + color = MaterialTheme.colorScheme.surfaceVariant + ) + } + innerTextField() + } + ) + CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.primary) { + actions() + } + } +} + +@Composable +fun AddEditFormHeader(text: String) { + Text( + text = text.uppercase(), + fontSize = 13.sp, + fontWeight = FontWeight(500), + color = MaterialTheme.colorScheme.primary, + letterSpacing = 0.8.sp, + ) +} + +@Composable +fun AddEditDivider() { + HorizontalDivider(thickness = 0.5.dp, color = MaterialTheme.colorScheme.outline) +} + +@Preview(uiMode = UI_MODE_NIGHT_YES) +@Composable +fun AddEditFormPreview() { + HeimdallTheme { + AddEditForm { + AddEditFormTextField( + value = "", + onValueChange = {}, + label = "Name", + placeholder = "e.g. Google" + ) + HorizontalDivider(thickness = 0.5.dp, color = MaterialTheme.colorScheme.outline) + AddEditFormTextField( + value = "abc", + onValueChange = {}, + label = "URL", + placeholder = "google.com" + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/longnh15/heimdall/ui/vault/edit/AddEditHeader.kt b/app/src/main/java/com/longnh15/heimdall/ui/vault/edit/AddEditHeader.kt new file mode 100644 index 0000000..30e890c --- /dev/null +++ b/app/src/main/java/com/longnh15/heimdall/ui/vault/edit/AddEditHeader.kt @@ -0,0 +1,76 @@ +package com.longnh15.heimdall.ui.vault.edit + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +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.automirrored.filled.ArrowBack +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +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.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AddEditHeader( + isSaveEnable: Boolean, + isSaving: Boolean, + popScreen: () -> Unit, + onSaveClick: () -> Unit, +) { + TopAppBar( + title = { + Text("New item") + }, + modifier = Modifier.padding(end = 16.dp), + colors = TopAppBarDefaults.topAppBarColors().copy( + containerColor = MaterialTheme.colorScheme.background + ), + navigationIcon = { IconButton(onClick = popScreen) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, null) + }}, + actions = { + Button( + onClick = onSaveClick, + contentPadding = PaddingValues(14.dp, 6.dp), + colors = ButtonDefaults.buttonColors().copy( + containerColor = MaterialTheme.colorScheme.tertiaryContainer, + contentColor = MaterialTheme.colorScheme.onTertiaryContainer, + disabledContainerColor = MaterialTheme.colorScheme.tertiaryContainer, + disabledContentColor = MaterialTheme.colorScheme.onTertiaryContainer, + ), + enabled = isSaveEnable, + modifier = Modifier.alpha(if (isSaveEnable) 1f else 0.4f) + ) { + if (isSaving) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp, + ) + Spacer(Modifier.width(8.dp)) + } + Text( + text = if (isSaving) "Saving…" else "Save", + fontSize = 13.sp, + fontWeight = FontWeight(500), + color = MaterialTheme.colorScheme.onTertiaryContainer + ) + } + } + ) + +} \ No newline at end of file diff --git a/app/src/main/java/com/longnh15/heimdall/ui/vault/edit/AddEditState.kt b/app/src/main/java/com/longnh15/heimdall/ui/vault/edit/AddEditState.kt new file mode 100644 index 0000000..940b18f --- /dev/null +++ b/app/src/main/java/com/longnh15/heimdall/ui/vault/edit/AddEditState.kt @@ -0,0 +1,49 @@ +package com.longnh15.heimdall.ui.vault.edit + +import com.longnh15.heimdall.domain.model.Credential + +data class AddEditState( + val name: String = "", + val url: String = "", + val username: String = "", + val password: String = "", + + val nameTouched: Boolean = false, + val urlTouched: Boolean = false, + val usernameTouched: Boolean = false, + val passwordTouched: Boolean = false, + + val isShowPassword: Boolean = false, + val isSaving: Boolean = false, +) { + val nameError: String? + get() = if (nameTouched && name.isBlank()) "Name is required" else null + val usernameError: String? + get() = if (urlTouched && url.isBlank()) "Url is required" else null + val passwordError: String? + get() = if (passwordTouched && password.isBlank()) "Password is required" else null + + val isFormValid: Boolean + get() = name.isNotBlank() && username.isNotBlank() && password.isNotBlank() + + val passwordStrength: PasswordStrength get() { + if (password.isBlank()) return PasswordStrength.NONE + var score = 0 + if (password.length >= 8) score++ + if (password.length >= 14) score++ + if (password.any { it.isUpperCase() } && password.any { it.isDigit() }) score++ + if (password.any { !it.isLetterOrDigit() }) score++ + return when (score) { + 1 -> PasswordStrength.WEAK + 2 -> PasswordStrength.FAIR + 3 -> PasswordStrength.GOOD + else -> PasswordStrength.STRONG + } + } + + fun toCredential(): Credential { + return Credential( + name, url, username, password + ) + } +} diff --git a/app/src/main/java/com/longnh15/heimdall/ui/vault/edit/AddEditVaultItemScreen.kt b/app/src/main/java/com/longnh15/heimdall/ui/vault/edit/AddEditVaultItemScreen.kt new file mode 100644 index 0000000..b2139f7 --- /dev/null +++ b/app/src/main/java/com/longnh15/heimdall/ui/vault/edit/AddEditVaultItemScreen.kt @@ -0,0 +1,136 @@ +package com.longnh15.heimdall.ui.vault.edit + +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.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.AccessTime +import androidx.compose.material.icons.outlined.Visibility +import androidx.compose.material.icons.outlined.VisibilityOff +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.focusProperties +import androidx.compose.ui.tooling.preview.AndroidUiModes.UI_MODE_NIGHT_YES +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.longnh15.heimdall.ui.theme.HeimdallTheme + +@Composable +fun AddEditVaultItemScreen( + modifier: Modifier = Modifier, + viewModel: AddEditVaultViewModel = viewModel(), + popScreen: () -> Unit, +) { + val snackbarHostState = remember { SnackbarHostState() } + + LaunchedEffect(Unit) { + viewModel.uiEvent.collect { event -> + when (event) { + AddEditEvent.SaveSuccess -> popScreen() + is AddEditEvent.SaveError -> snackbarHostState.showSnackbar(event.message) + } + } + } + + Scaffold( + modifier = Modifier + .fillMaxSize(), + topBar = { + AddEditHeader( + isSaveEnable = with(viewModel.state) { isFormValid && !isSaving }, + isSaving = viewModel.state.isSaving, + popScreen = popScreen, + onSaveClick = viewModel::save, + ) + }, + snackbarHost = { + SnackbarHost(snackbarHostState) + } + ) { paddingValues -> + Column( + modifier.padding(paddingValues).padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + AddEditFormHeader("details") + AddEditForm { + AddEditFormTextField( + value = viewModel.state.name, + onValueChange = viewModel::onNameChanged, + label = "Name", + placeholder = "e.g. Google" + ) + AddEditDivider() + AddEditFormTextField( + value = viewModel.state.url, + onValueChange = viewModel::onUrlChanged, + label = "URL", + placeholder = "google.com", + actions = { + IconButton( + onClick = {}, + modifier = Modifier.focusProperties { canFocus = false } + ) { + Icon(Icons.Outlined.AccessTime, null) + } + } + ) + } + AddEditFormHeader("credentials") + AddEditForm { + AddEditFormTextField( + viewModel.state.username, + onValueChange = viewModel::onUsernameChanged, + label = "Username", + placeholder = "you@email.com" + ) + AddEditDivider() + AddEditFormTextField( + value = viewModel.state.password, + onValueChange = viewModel::onPasswordChanged, + label = "Password", + placeholder = "••••••••", + isHideContent = viewModel.state.isShowPassword.not(), + isLastField = true, + autoCorrectEnabled = false, + actions = { + IconButton( + onClick = viewModel::toggleShowPassword, + modifier = Modifier.focusProperties { canFocus = false } + ) { + Icon( + imageVector = if (viewModel.state.isShowPassword) Icons.Outlined.VisibilityOff else Icons.Outlined.Visibility, + null + ) + } + } + ) + } + } + } +} + +@Preview( + showSystemUi = true, + uiMode = UI_MODE_NIGHT_YES +) +@Composable +fun AddEditVaultItemScreenPreview() { + HeimdallTheme { + AddEditVaultItemScreen(popScreen = {}) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/longnh15/heimdall/ui/vault/edit/AddEditVaultViewModel.kt b/app/src/main/java/com/longnh15/heimdall/ui/vault/edit/AddEditVaultViewModel.kt new file mode 100644 index 0000000..588e489 --- /dev/null +++ b/app/src/main/java/com/longnh15/heimdall/ui/vault/edit/AddEditVaultViewModel.kt @@ -0,0 +1,59 @@ +package com.longnh15.heimdall.ui.vault.edit + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.longnh15.heimdall.domain.repository.CredentialRepository +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.launch +import org.koin.core.annotation.KoinViewModel + +@KoinViewModel +class AddEditVaultViewModel( + private val credentialRepository: CredentialRepository +): ViewModel() { + var state by mutableStateOf(AddEditState()) + private set + private val _uiEvent = Channel() + val uiEvent = _uiEvent.receiveAsFlow() + + fun onNameChanged(value: String) { + state = state.copy(name = value, nameTouched = true) + } + + fun onUrlChanged(value: String) { + state = state.copy(url = value, urlTouched = true) + } + + fun onUsernameChanged(value: String) { + state = state.copy(username = value, usernameTouched = true) + } + + fun onPasswordChanged(value: String) { + state = state.copy(password = value, passwordTouched = true) + } + + fun toggleShowPassword() { + state = state.copy(isShowPassword = !state.isShowPassword) + } + + fun save() { + if (!state.isFormValid && state.isSaving) { + // Just defensive, UI must "blog" this action already + return + } + viewModelScope.launch { + state = state.copy(isSaving = true) + try { + credentialRepository.save(state.toCredential()) + _uiEvent.send(AddEditEvent.SaveSuccess) + } catch (e: Exception) { + state = state.copy(isSaving = false) + _uiEvent.send(AddEditEvent.SaveError(e.message ?: "Something went wrong.")) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/longnh15/heimdall/ui/vault/edit/PasswordStrength.kt b/app/src/main/java/com/longnh15/heimdall/ui/vault/edit/PasswordStrength.kt new file mode 100644 index 0000000..297c532 --- /dev/null +++ b/app/src/main/java/com/longnh15/heimdall/ui/vault/edit/PasswordStrength.kt @@ -0,0 +1,5 @@ +package com.longnh15.heimdall.ui.vault.edit + +enum class PasswordStrength { + NONE, WEAK, FAIR, GOOD, STRONG +} diff --git a/app/src/main/res/drawable/crosshair_icon.xml b/app/src/main/res/drawable/crosshair_icon.xml new file mode 100644 index 0000000..01905b7 --- /dev/null +++ b/app/src/main/res/drawable/crosshair_icon.xml @@ -0,0 +1,32 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..07d5da9 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..9c3a5da --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,29 @@ + + + + + + diff --git a/app/src/main/res/drawable/lock_icon.xml b/app/src/main/res/drawable/lock_icon.xml new file mode 100644 index 0000000..7374098 --- /dev/null +++ b/app/src/main/res/drawable/lock_icon.xml @@ -0,0 +1,29 @@ + + + + + diff --git a/app/src/main/res/drawable/page_icon.xml b/app/src/main/res/drawable/page_icon.xml new file mode 100644 index 0000000..e456c04 --- /dev/null +++ b/app/src/main/res/drawable/page_icon.xml @@ -0,0 +1,34 @@ + + + + + diff --git a/app/src/main/res/drawable/person_icon.xml b/app/src/main/res/drawable/person_icon.xml new file mode 100644 index 0000000..82f0049 --- /dev/null +++ b/app/src/main/res/drawable/person_icon.xml @@ -0,0 +1,24 @@ + + + + diff --git a/app/src/main/res/drawable/shield_icon.xml b/app/src/main/res/drawable/shield_icon.xml new file mode 100644 index 0000000..ba30eb0 --- /dev/null +++ b/app/src/main/res/drawable/shield_icon.xml @@ -0,0 +1,40 @@ + + + + + + + diff --git a/app/src/main/res/drawable/tick_icon.xml b/app/src/main/res/drawable/tick_icon.xml new file mode 100644 index 0000000..a4b5fc1 --- /dev/null +++ b/app/src/main/res/drawable/tick_icon.xml @@ -0,0 +1,35 @@ + + + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..ef49c99 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..ef49c99 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/app/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 0000000..f69df9f Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 0000000..16997ff Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/app/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 0000000..fa35a46 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp new file mode 100644 index 0000000..c543f27 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 0000000..94ad624 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..157762e Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp new file mode 100644 index 0000000..70d0244 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..11a004e Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp new file mode 100644 index 0000000..663d0bf Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..f9f2478 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..f8c6127 --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +1,10 @@ + + + #FFBB86FC + #FF6200EE + #FF3700B3 + #FF03DAC5 + #FF018786 + #FF000000 + #FFFFFFFF + \ No newline at end of file diff --git a/app/src/main/res/values/ic_launcher_background.xml b/app/src/main/res/values/ic_launcher_background.xml new file mode 100644 index 0000000..ed14948 --- /dev/null +++ b/app/src/main/res/values/ic_launcher_background.xml @@ -0,0 +1,4 @@ + + + #3D170B + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..0b3f407 --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + Heimdall + \ No newline at end of file diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..c3725c6 --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,5 @@ + + + +