diff --git a/app/src/main/java/com/joinself/app/demo/MainViewModel.kt b/app/src/main/java/com/joinself/app/demo/MainViewModel.kt index 8ccb3ea..67338ab 100644 --- a/app/src/main/java/com/joinself/app/demo/MainViewModel.kt +++ b/app/src/main/java/com/joinself/app/demo/MainViewModel.kt @@ -1,7 +1,6 @@ package com.joinself.app.demo import android.content.Context -import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.joinself.common.CredentialType @@ -28,8 +27,11 @@ import java.io.File private const val TAG = "MainViewModel" +private val appAddressKey = "ApplicationAddress" + sealed class InitializationState { data object None: ServerState() + data object UnRegistered: InitializationState() data object Loading: InitializationState() data object Success: InitializationState() data class Error(val message: String): InitializationState() @@ -69,7 +71,7 @@ sealed class SERVER_REQUESTS { // the main states of the app data class AppUiState( - var isRegistered: Boolean = false, +// var isRegistered: Boolean = false, var verificationStatus: Boolean = false, var initialization: InitializationState = InitializationState.Loading, var serverState: ServerState = ServerState.None, @@ -77,18 +79,20 @@ data class AppUiState( var backupRestoreState: BackupRestoreState = BackupRestoreState.None ) -class MainViewModel(context: Context): ViewModel() { +class MainViewModel(val context: Context): ViewModel() { + + private val sharedPreferences = context.getSharedPreferences("SelfDemoPref", Context.MODE_PRIVATE) + private val _appUiState = MutableStateFlow(AppUiState()) val appStateFlow: StateFlow = _appUiState.asStateFlow() - val account: Account + var account: Account? = null var serverInboxAddress: PublicKey? = null private var groupAddress: PublicKey? = null private var credentialRequest: CredentialRequest? = null private var verificationRequest: VerificationRequest? = null private var requestTimeoutJob: Job? = null -// private val receivedCredentials = mutableListOf() - + init { // init the sdk SelfSDK.initialize( @@ -97,6 +101,17 @@ class MainViewModel(context: Context): ViewModel() { log = { Timber.tag("SelfSDK").d(it) } ) + val appAddress = getApplicationAddress() + if (appAddress.isNotEmpty()) { + viewModelScope.launch(Dispatchers.IO) { + initAccount(appAddress) + } + } else { + _appUiState.update { it.copy(initialization = InitializationState.UnRegistered) } + } + } + + suspend fun initAccount(applicationAddress: String, onConnectCompletion: (() -> Unit)? = null) { // the sdk will store data in this directory, make sure it exists. val storagePath = File(context.filesDir.absolutePath + "/account1") if (!storagePath.exists()) storagePath.mkdirs() @@ -106,6 +121,7 @@ class MainViewModel(context: Context): ViewModel() { .setEnvironment(Environment.production) .setSandbox(true) .setStoragePath(storagePath.absolutePath) + .setApplicationAddress(applicationAddress) .setCallbacks(object : Account.Callbacks { override fun onMessage(message: Message) { Timber.tag("DemoApp").d("onMessage: ${message.id()}") @@ -132,6 +148,8 @@ class MainViewModel(context: Context): ViewModel() { } override fun onConnect() { Timber.tag("DemoApp").d("onConnect") + onConnectCompletion?.invoke() + _appUiState.update { it.copy( initialization = InitializationState.Success @@ -150,16 +168,16 @@ class MainViewModel(context: Context): ViewModel() { }) .build() - _appUiState.update { - it.copy( - isRegistered = account.registered(), - ) - } +// _appUiState.update { +// it.copy( +// isRegistered = account?.registered() ?: false, +// ) +// } } fun isRegistered() : Boolean { - return account.registered() + return account?.registered() ?: false } // connect with server using an inbox address @@ -168,7 +186,7 @@ class MainViewModel(context: Context): ViewModel() { _appUiState.update { it.copy(serverState = ServerState.Connecting) } serverInboxAddress = PublicKey(inboxAddress) - groupAddress = account.connectWith(serverInboxAddress!!, info = mapOf()) + groupAddress = account?.connectWith(serverInboxAddress!!, info = mapOf()) if (groupAddress != null) { _appUiState.update { it.copy(serverState = ServerState.Success) } } else { @@ -182,7 +200,7 @@ class MainViewModel(context: Context): ViewModel() { suspend fun connect(inboxAddress: PublicKey, qrCode: ByteArray) { try { - groupAddress = account.connectWith(qrCode) + groupAddress = account?.connectWith(qrCode) serverInboxAddress = inboxAddress if (groupAddress != null) { @@ -221,7 +239,7 @@ class MainViewModel(context: Context): ViewModel() { .build() // send chat to server - val messageId = account.send(toAddress = serverInboxAddress!!, chat) + val messageId = account?.send(toAddress = serverInboxAddress!!, chat) _appUiState.update { it.copy(requestState = ServerRequestState.RequestSent) } startRequestTimeout() @@ -241,4 +259,11 @@ class MainViewModel(context: Context): ViewModel() { private fun cancelRequestTimeout() { requestTimeoutJob?.cancel() } + + fun saveApplicationAddress(address: String) { + sharedPreferences.edit().putString(appAddressKey, address).apply() + } + fun getApplicationAddress(): String { + return sharedPreferences.getString(appAddressKey, "") ?: "" + } } \ No newline at end of file diff --git a/app/src/main/java/com/joinself/app/demo/SelfDemoApp.kt b/app/src/main/java/com/joinself/app/demo/SelfDemoApp.kt index c3d7785..321fc37 100644 --- a/app/src/main/java/com/joinself/app/demo/SelfDemoApp.kt +++ b/app/src/main/java/com/joinself/app/demo/SelfDemoApp.kt @@ -5,6 +5,7 @@ import android.util.Log import androidx.activity.compose.BackHandler import androidx.compose.animation.EnterTransition import androidx.compose.animation.ExitTransition +import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding @@ -27,6 +28,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController +import com.joinself.app.demo.ui.screens.ApplicationAddressEnterScreen import com.joinself.app.demo.ui.screens.AuthRequestResultScreen import com.joinself.app.demo.ui.screens.AuthRequestStartScreen import com.joinself.app.demo.ui.screens.BackupResultScreen @@ -81,6 +83,7 @@ private const val TAG = "SelfSDKDemoApp" sealed class MainRoute { @Serializable object Initializing @Serializable object Registration + @Serializable object EnterApplicationAddress @Serializable object ConnectToServerSelection @Serializable object ConnectToServerAddress @Serializable object ConnectingToServer @@ -155,6 +158,9 @@ fun SelfDemoApp( LaunchedEffect(appState.initialization) { when (val status = appState.initialization) { + is InitializationState.UnRegistered -> { + navController.navigate(MainRoute.Registration) + } is InitializationState.Success -> { val route = if (viewModel.isRegistered()) MainRoute.ConnectToServerSelection else MainRoute.Registration navController.navigate(route) @@ -171,11 +177,7 @@ fun SelfDemoApp( RegistrationIntroScreen( selfModifier = selfModifier, onStartRegistration = { coroutineScope.launch { - viewModel.account.openRegistrationFlow { isSuccess, error -> - coroutineScope.launch(Dispatchers.Main) { - if (isSuccess) navController.navigate(MainRoute.ConnectToServerSelection) - } - } + navController.navigate(MainRoute.EnterApplicationAddress) } }, onStartRestore = { @@ -184,6 +186,30 @@ fun SelfDemoApp( onOpenSettings = onOpenSettings ) } + composable { + ApplicationAddressEnterScreen( + onContinue = { address -> + coroutineScope.launch(Dispatchers.IO) { + viewModel.initAccount( + applicationAddress = address, + onConnectCompletion = { + coroutineScope.launch { + viewModel.account?.openRegistrationFlow { isSuccess, error -> + if (isSuccess) { + viewModel.saveApplicationAddress(address) + + coroutineScope.launch(Dispatchers.Main) { + navController.navigate(MainRoute.ConnectToServerSelection) + } + } + } + } + } + ) + } + } + ) + } composable { ServerConnectSelectionScreen( onAddress = { @@ -191,7 +217,7 @@ fun SelfDemoApp( }, onQRCode = { coroutineScope.launch { - viewModel.account.openQRCodeFlow( + viewModel.account?.openQRCodeFlow( onFinish = { qrCode, discoveryData -> if (discoveryData == null || !discoveryData.sandbox) { return@openQRCodeFlow @@ -298,7 +324,7 @@ fun SelfDemoApp( ) { when(request) { is CredentialRequest -> { - viewModel.account.DisplayRequestUI(selfModifier, request, onFinish = { isSent, status -> + viewModel.account?.DisplayRequestUI(selfModifier, request, onFinish = { isSent, status -> viewModel.resetState(requestState = if (isSent) ServerRequestState.ResponseSent(status) else ServerRequestState.RequestError("failed to respond")) }) } @@ -352,7 +378,7 @@ fun SelfDemoApp( VerifyEmailStartScreen( onStartVerification = { coroutineScope.launch { - viewModel.account.openEmailVerificationFlow(onFinish = { isSuccess, error -> + viewModel.account?.openEmailVerificationFlow(onFinish = { isSuccess, error -> viewModel.resetState(verificationStatus = isSuccess) navController.navigate(MainRoute.VerifyEmailResult) }) @@ -372,7 +398,7 @@ fun SelfDemoApp( VerifyDocumentStartScreen( onStartVerification = { coroutineScope.launch { - viewModel.account.openDocumentVerificationFlow( + viewModel.account?.openDocumentVerificationFlow( isDevMode = false, onFinish = { isSuccess, error -> viewModel.resetState(verificationStatus = isSuccess) @@ -411,7 +437,7 @@ fun SelfDemoApp( ) { when(request) { is CredentialMessage -> { - viewModel.account.DisplayRequestUI(selfModifier, request, onFinish = { isSent, status -> + viewModel.account?.DisplayRequestUI(selfModifier, request, onFinish = { isSent, status -> viewModel.resetState(requestState = if (isSent) ServerRequestState.ResponseSent(status) else ServerRequestState.RequestError("failed to respond")) }) } @@ -494,7 +520,7 @@ fun SelfDemoApp( ) { when(request) { is CredentialRequest -> { - viewModel.account.DisplayRequestUI(selfModifier, request, onFinish = { isSent, status -> + viewModel.account?.DisplayRequestUI(selfModifier, request, onFinish = { isSent, status -> viewModel.resetState(requestState = if (isSent) ServerRequestState.ResponseSent(status) else ServerRequestState.RequestError("failed to respond")) }) } @@ -552,7 +578,7 @@ fun SelfDemoApp( ) { when(request) { is VerificationRequest -> { - viewModel.account.DisplayRequestUI(selfModifier, request, onFinish = { isSent, status -> + viewModel.account?.DisplayRequestUI(selfModifier, request, onFinish = { isSent, status -> viewModel.resetState(requestState = if (isSent) ServerRequestState.ResponseSent(status) else ServerRequestState.RequestError("failed to respond")) }) } @@ -593,7 +619,7 @@ fun SelfDemoApp( backupState = appState.backupRestoreState, onStartBackup = { coroutineScope.launch(Dispatchers.Main) { - viewModel.account.openBackupFlow(onFinish = { isSuccess, error -> + viewModel.account?.openBackupFlow(onFinish = { isSuccess, error -> if (isSuccess) { viewModel.setBackupRestoreState(state = BackupRestoreState.Success) } else { @@ -621,7 +647,7 @@ fun SelfDemoApp( restoreState = appState.backupRestoreState, onStartRestore = { coroutineScope.launch(Dispatchers.Main) { - viewModel.account.openRestoreFlow(onFinish = { isSuccess, error -> + viewModel.account?.openRestoreFlow(onFinish = { isSuccess, error -> if (isSuccess) { viewModel.setBackupRestoreState(state = BackupRestoreState.Success) } else { diff --git a/app/src/main/java/com/joinself/app/demo/ui/screens/ApplicationAddressEnterScreen.kt b/app/src/main/java/com/joinself/app/demo/ui/screens/ApplicationAddressEnterScreen.kt new file mode 100644 index 0000000..83469d9 --- /dev/null +++ b/app/src/main/java/com/joinself/app/demo/ui/screens/ApplicationAddressEnterScreen.kt @@ -0,0 +1,147 @@ +package com.joinself.app.demo.ui.screens + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.PrivateConnectivity +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.tooling.preview.Preview +import com.joinself.app.demo.ui.theme.AppColors +import com.joinself.app.demo.ui.theme.AppFonts +import com.joinself.app.demo.ui.theme.AppSpacing +import com.joinself.app.demo.ui.theme.HeroSection +import com.joinself.app.demo.ui.theme.PrimaryButton + +@Composable +fun ApplicationAddressEnterScreen( + onContinue: (String) -> Unit, + modifier: Modifier = Modifier +) { + var serverAddress by remember { mutableStateOf("") } + val isValidAddress = isValidHexAddress(serverAddress) + val focusManager = LocalFocusManager.current + + Column( + modifier = modifier + .fillMaxSize() + .background(Color.White) + ) { + // Main content with LazyColumn for better keyboard handling + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(AppSpacing.screenPadding), + verticalArrangement = Arrangement.spacedBy(AppSpacing.sectionSpacing) + ) { + item { + // Hero Section + HeroSection( + icon = Icons.Filled.PrivateConnectivity, + title = "Register by Address", + subtitle = "Enter the application address to register your Self account." + ) + } + + item { + Spacer(modifier = Modifier.height(AppSpacing.heroTopPadding)) + } + + + item { + // Server address input + Column( + verticalArrangement = Arrangement.spacedBy(AppSpacing.componentSpacing) + ) { + Text( + text = "Application Address", + style = AppFonts.heading, + color = AppColors.textPrimary + ) + + Text( + text = "Enter the 66-character hexadecimal application address/ID.", + style = AppFonts.body, + color = AppColors.textSecondary + ) + + OutlinedTextField( + value = serverAddress, + onValueChange = { newValue -> + // Only allow hex characters (0-9, a-f, A-F) and limit to 66 chars + if (newValue.length <= 66 && newValue.all { it.isDigit() || it.lowercaseChar() in 'a'..'f' }) { + serverAddress = newValue + } + }, + label = { Text("Application Address/ID") }, + placeholder = { Text("Enter 66-character hex address...") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Text, + imeAction = if (isValidAddress) ImeAction.Done else ImeAction.Default + ), + keyboardActions = KeyboardActions( + onDone = { + if (isValidAddress) { + focusManager.clearFocus() + onContinue(serverAddress) + } + } + ), + supportingText = { + Text( + text = "${serverAddress.length}/66 characters", + color = if (isValidAddress) AppColors.success else AppColors.textSecondary + ) + }, + isError = serverAddress.isNotEmpty() && !isValidAddress + ) + } + } + + item { + // Primary Button - now part of scrollable content + Spacer(modifier = Modifier.height(AppSpacing.buttonTopSpacing)) + PrimaryButton( + title = "Register", + onClick = { + focusManager.clearFocus() + onContinue(serverAddress) + }, + isDisabled = !isValidAddress + ) + Spacer(modifier = Modifier.height(AppSpacing.buttonBottomPadding)) + } + } + } +} + +private fun isValidHexAddress(address: String): Boolean { + return address.length == 66 && address.all { it.isDigit() || it.lowercaseChar() in 'a'..'f' } +} + +@Preview +@Composable +fun ApplicationAddressEnterScreenPreview() { + ApplicationAddressEnterScreen(onContinue = {}) +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f07b51a..8347db3 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,17 +1,17 @@ [versions] -agp = "8.13.0" +agp = "8.13.2" kotlin = "2.2.21" coreKtx = "1.17.0" junit = "4.13.2" junitVersion = "1.3.0" espressoCore = "3.7.0" -lifecycleRuntimeKtx = "2.9.4" -activityCompose = "1.11.0" +lifecycleRuntimeKtx = "2.10.0" +activityCompose = "1.12.1" navigationCompose = "2.9.6" -composeBom = "2025.11.00" +composeBom = "2025.12.00" gradleAndroidGitVersion = "0.4.14" timber = "5.0.1" -sdkAndroid = "1.8.0-1-SNAPSHOT" +sdkAndroid = "1.8.0-8-SNAPSHOT" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }