diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/MainActivity.kt b/app/src/main/java/com/github/nacabaro/vbhelper/MainActivity.kt index 033f574..569662b 100644 --- a/app/src/main/java/com/github/nacabaro/vbhelper/MainActivity.kt +++ b/app/src/main/java/com/github/nacabaro/vbhelper/MainActivity.kt @@ -21,13 +21,11 @@ import com.github.nacabaro.vbhelper.di.VBHelper import com.github.nacabaro.vbhelper.domain.characters.Card import com.github.nacabaro.vbhelper.domain.Sprites import com.github.nacabaro.vbhelper.domain.characters.Character -import com.github.nacabaro.vbhelper.domain.characters.Dex import com.github.nacabaro.vbhelper.domain.device_data.BECharacterData import com.github.nacabaro.vbhelper.domain.device_data.UserCharacter import com.github.nacabaro.vbhelper.navigation.AppNavigationHandlers import com.github.nacabaro.vbhelper.screens.scanScreen.ScanScreenControllerImpl -import com.github.nacabaro.vbhelper.screens.SettingsScreenController -import com.github.nacabaro.vbhelper.source.ApkSecretsImporter +import com.github.nacabaro.vbhelper.screens.settingsScreen.SettingsScreenControllerImpl import com.github.nacabaro.vbhelper.ui.theme.VBHelperTheme import com.github.nacabaro.vbhelper.utils.DeviceType import kotlinx.coroutines.flow.MutableStateFlow @@ -57,21 +55,20 @@ class MainActivity : ComponentActivity() { registerFileActivityResult() val application = applicationContext as VBHelper - val settingsScreenController = SettingsScreenController.Factory(this, ApkSecretsImporter(), application.container.dataStoreSecretsRepository) - .buildSettingScreenHandlers() val scanScreenController = ScanScreenControllerImpl( application.container.dataStoreSecretsRepository.secretsFlow, this::handleReceivedNfcCharacter, this, this::registerActivityLifecycleListener, - this::unregisterActivityLifecycleListener) - + this::unregisterActivityLifecycleListener + ) + val settingsScreenController = SettingsScreenControllerImpl(this) super.onCreate(savedInstanceState) enableEdgeToEdge() setContent { VBHelperTheme { - MainApplication(settingsScreenController, scanScreenController) + MainApplication(scanScreenController, settingsScreenController) } } Log.i("MainActivity", "Activity onCreated") @@ -190,10 +187,16 @@ class MainActivity : ComponentActivity() { } @Composable - private fun MainApplication(settingsScreenController: SettingsScreenController, scanScreenController: ScanScreenControllerImpl) { + private fun MainApplication( + scanScreenController: ScanScreenControllerImpl, + settingsScreenController: SettingsScreenControllerImpl + ) { AppNavigation( - applicationNavigationHandlers = AppNavigationHandlers(settingsScreenController, scanScreenController), + applicationNavigationHandlers = AppNavigationHandlers( + settingsScreenController, + scanScreenController, + ), onClickImportCard = { val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply { addCategory(Intent.CATEGORY_OPENABLE) diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/navigation/AppNavigation.kt b/app/src/main/java/com/github/nacabaro/vbhelper/navigation/AppNavigation.kt index 6f5fb75..5d417b1 100644 --- a/app/src/main/java/com/github/nacabaro/vbhelper/navigation/AppNavigation.kt +++ b/app/src/main/java/com/github/nacabaro/vbhelper/navigation/AppNavigation.kt @@ -1,6 +1,5 @@ package com.github.nacabaro.vbhelper.navigation -import android.util.Log import androidx.compose.foundation.layout.padding import androidx.compose.material3.Scaffold import androidx.compose.runtime.Composable @@ -15,12 +14,15 @@ import com.github.nacabaro.vbhelper.screens.homeScreens.HomeScreen import com.github.nacabaro.vbhelper.screens.ItemsScreen import com.github.nacabaro.vbhelper.screens.scanScreen.ScanScreen import com.github.nacabaro.vbhelper.screens.scanScreen.ScanScreenControllerImpl -import com.github.nacabaro.vbhelper.screens.SettingsScreen -import com.github.nacabaro.vbhelper.screens.SettingsScreenController +import com.github.nacabaro.vbhelper.screens.settingsScreen.SettingsScreen import com.github.nacabaro.vbhelper.screens.SpriteViewer import com.github.nacabaro.vbhelper.screens.StorageScreen +import com.github.nacabaro.vbhelper.screens.settingsScreen.SettingsScreenControllerImpl -data class AppNavigationHandlers(val settingsScreenController: SettingsScreenController, val scanScreenController: ScanScreenControllerImpl) +data class AppNavigationHandlers( + val settingsScreenController: SettingsScreenControllerImpl, + val scanScreenController: ScanScreenControllerImpl, +) @Composable fun AppNavigation( @@ -72,7 +74,7 @@ fun AppNavigation( SettingsScreen( navController = navController, settingsScreenController = applicationNavigationHandlers.settingsScreenController, - onClickImportCard = onClickImportCard + onClickImportCard = onClickImportCard, ) } composable(NavigationItems.Viewer.route) { diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/screens/SettingsScreenController.kt b/app/src/main/java/com/github/nacabaro/vbhelper/screens/SettingsScreenController.kt deleted file mode 100644 index 4afb3d1..0000000 --- a/app/src/main/java/com/github/nacabaro/vbhelper/screens/SettingsScreenController.kt +++ /dev/null @@ -1,71 +0,0 @@ -package com.github.nacabaro.vbhelper.screens - -import android.net.Uri -import android.widget.Toast -import androidx.activity.ComponentActivity -import androidx.activity.result.ActivityResultLauncher -import androidx.activity.result.contract.ActivityResultContracts -import androidx.lifecycle.lifecycleScope -import com.github.nacabaro.vbhelper.source.SecretsImporter -import com.github.nacabaro.vbhelper.source.SecretsRepository -import com.github.nacabaro.vbhelper.source.proto.Secrets -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch - -data class SettingsScreenController(val apkFilePickLauncher: ActivityResultLauncher>) { - - class Factory(private val componentActivity: ComponentActivity, private val secretsImporter: SecretsImporter, private val secretsRepository: SecretsRepository) { - - fun buildSettingScreenHandlers(): SettingsScreenController { - return SettingsScreenController( - apkFilePickLauncher = buildFilePickerActivityLauncher(this::importApk) - ) - } - - private fun buildFilePickerActivityLauncher(onResult : (Uri?) ->Unit): ActivityResultLauncher> { - return componentActivity.registerForActivityResult(ActivityResultContracts.OpenDocument()) { - onResult.invoke(it) - } - } - - private fun importApk(uri: Uri?) { - if(uri == null) { - componentActivity.runOnUiThread { - Toast.makeText(componentActivity, "APK Import Cancelled", Toast.LENGTH_SHORT) - .show() - } - return - } - componentActivity.lifecycleScope.launch(Dispatchers.IO) { - componentActivity.contentResolver.openInputStream(uri).use { - if(it == null) { - componentActivity.runOnUiThread { - Toast.makeText( - componentActivity, - "Selected file is empty!", - Toast.LENGTH_SHORT - ).show() - } - return@launch - } - var secrets: Secrets? = null - try { - secrets = secretsImporter.importSecrets(it) - } catch (e: Exception) { - componentActivity.runOnUiThread { - Toast.makeText(componentActivity, "Secrets import failed. Please only select the official Vital Arena App 2.1.0 APK.", Toast.LENGTH_SHORT).show() - } - return@launch - } - componentActivity.lifecycleScope.launch(Dispatchers.IO) { - secretsRepository.updateSecrets(secrets) - }.invokeOnCompletion { - componentActivity.runOnUiThread { - Toast.makeText(componentActivity, "Secrets successfully imported. Connections with devices are now possible.", Toast.LENGTH_SHORT).show() - } - } - } - } - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/screens/SettingsScreen.kt b/app/src/main/java/com/github/nacabaro/vbhelper/screens/settingsScreen/SettingsScreen.kt similarity index 81% rename from app/src/main/java/com/github/nacabaro/vbhelper/screens/SettingsScreen.kt rename to app/src/main/java/com/github/nacabaro/vbhelper/screens/settingsScreen/SettingsScreen.kt index 104cded..6c0051f 100644 --- a/app/src/main/java/com/github/nacabaro/vbhelper/screens/SettingsScreen.kt +++ b/app/src/main/java/com/github/nacabaro/vbhelper/screens/settingsScreen/SettingsScreen.kt @@ -1,9 +1,5 @@ -package com.github.nacabaro.vbhelper.screens +package com.github.nacabaro.vbhelper.screens.settingsScreen -import android.net.Uri -import androidx.activity.ComponentActivity -import androidx.activity.result.ActivityResultLauncher -import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -26,7 +22,7 @@ import com.github.nacabaro.vbhelper.components.TopBanner @Composable fun SettingsScreen( navController: NavController, - settingsScreenController: SettingsScreenController, + settingsScreenController: SettingsScreenControllerImpl, onClickImportCard: () -> Unit ) { Scaffold ( @@ -49,7 +45,14 @@ fun SettingsScreen( ) { SettingsSection("NFC Communication") SettingsEntry(title = "Import APK", description = "Import Secrets From Vital Arean 2.1.0 APK") { - settingsScreenController.apkFilePickLauncher.launch(arrayOf("*/*")) + settingsScreenController.onClickImportApk() + } + SettingsSection("Data management") + SettingsEntry(title = "Export data", description = "Export application database") { + settingsScreenController.onClickOpenDirectory() + } + SettingsEntry(title = "Import data", description = "Import application database") { + settingsScreenController.onClickImportDatabase() } SettingsSection("DiM/BEm management") SettingsEntry(title = "Import DiM card", description = "Import DiM/BEm card file", onClick = onClickImportCard) @@ -61,12 +64,6 @@ fun SettingsScreen( } } -fun buildFilePickLauncher(activity: ComponentActivity, onItemPicked: (Uri?) -> Unit): ActivityResultLauncher> { - return activity.registerForActivityResult(ActivityResultContracts.OpenDocument()) { - onItemPicked.invoke(it) - } -} - @Composable fun SettingsEntry( title: String, diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/screens/settingsScreen/SettingsScreenControllerImpl.kt b/app/src/main/java/com/github/nacabaro/vbhelper/screens/settingsScreen/SettingsScreenControllerImpl.kt new file mode 100644 index 0000000..8cae654 --- /dev/null +++ b/app/src/main/java/com/github/nacabaro/vbhelper/screens/settingsScreen/SettingsScreenControllerImpl.kt @@ -0,0 +1,211 @@ +package com.github.nacabaro.vbhelper.screens.settingsScreen + +import androidx.lifecycle.lifecycleScope +import kotlinx.coroutines.launch +import android.net.Uri +import android.provider.OpenableColumns +import android.util.Log +import android.widget.Toast +import androidx.activity.ComponentActivity +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.contract.ActivityResultContracts +import com.github.nacabaro.vbhelper.di.VBHelper +import com.github.nacabaro.vbhelper.source.ApkSecretsImporter +import com.github.nacabaro.vbhelper.source.SecretsImporter +import com.github.nacabaro.vbhelper.source.SecretsRepository +import com.github.nacabaro.vbhelper.source.proto.Secrets +import kotlinx.coroutines.Dispatchers +import java.io.File +import java.io.InputStream +import java.io.OutputStream + + +class SettingsScreenControllerImpl( + private val context: ComponentActivity, +): SettingsScreenController { + private val roomDbName = "internalDb" + private val filePickerLauncher: ActivityResultLauncher + private val filePickerOpenerLauncher: ActivityResultLauncher> + private val filePickerApk: ActivityResultLauncher> + private val secretsImporter: SecretsImporter = ApkSecretsImporter() + private val application = context.applicationContext as VBHelper + private val secretsRepository: SecretsRepository = application.container.dataStoreSecretsRepository + + init { + filePickerLauncher = context.registerForActivityResult( + ActivityResultContracts.CreateDocument("application/octet-stream") + ) { uri -> + if (uri != null) { + exportDatabase(uri) + } else { + context.runOnUiThread { + Toast.makeText(context, "No destination selected", Toast.LENGTH_SHORT) + .show() + } + } + } + + filePickerOpenerLauncher = context.registerForActivityResult( + ActivityResultContracts.OpenDocument() + ) { uri -> + if (uri != null) { + importDatabase(uri) + } else { + context.runOnUiThread { + Toast.makeText(context, "No source selected", Toast.LENGTH_SHORT).show() + } + } + } + + filePickerApk = context.registerForActivityResult( + ActivityResultContracts.OpenDocument() + ) { uri -> + if (uri != null) { + importApk(uri) + } else { + context.runOnUiThread { + Toast.makeText(context, "APK import cancelled", Toast.LENGTH_SHORT).show() + } + } + } + } + + override fun onClickOpenDirectory() { + filePickerLauncher.launch("My application data.vbhelper") + } + + override fun onClickImportDatabase() { + filePickerOpenerLauncher.launch(arrayOf("application/octet-stream")) + } + + override fun onClickImportApk() { + filePickerApk.launch(arrayOf("*/*")) + } + + private fun exportDatabase(destinationUri: Uri) { + context.lifecycleScope.launch(Dispatchers.IO) { + try { + val dbFile = File(context.getDatabasePath(roomDbName).absolutePath) + if (!dbFile.exists()) { + throw IllegalStateException("Database file does not exist!") + } + + application.container.db.close() + + context.contentResolver.openOutputStream(destinationUri)?.use { outputStream -> + dbFile.inputStream().use { inputStream -> + copyFile(inputStream, outputStream) + } + } ?: throw IllegalArgumentException("Unable to open destination Uri for writing") + + context.runOnUiThread { + Toast.makeText(context, "Database exported successfully!", Toast.LENGTH_SHORT).show() + Toast.makeText(context, "Closing application to avoid changes.", Toast.LENGTH_LONG).show() + context.finishAffinity() + } + } catch (e: Exception) { + Log.e("ScanScreenController", "Error exporting database $e") + context.runOnUiThread { + Toast.makeText(context, "Error exporting database: ${e.message}", Toast.LENGTH_LONG).show() + } + } + } + } + + private fun importDatabase(sourceUri: Uri) { + context.lifecycleScope.launch(Dispatchers.IO) { + try { + if (!getFileNameFromUri(sourceUri)!!.endsWith(".vbhelper")) { + context.runOnUiThread { + Toast.makeText(context, "Invalid file format", Toast.LENGTH_SHORT).show() + } + return@launch + } + + application.container.db.close() + + val dbPath = context.getDatabasePath(roomDbName) + val shmFile = File(dbPath.parent, "$roomDbName-shm") + val walFile = File(dbPath.parent, "$roomDbName-wal") + + // Delete existing database files + if (dbPath.exists()) dbPath.delete() + if (shmFile.exists()) shmFile.delete() + if (walFile.exists()) walFile.delete() + + val dbFile = File(dbPath.absolutePath) + + context.contentResolver.openInputStream(sourceUri)?.use { inputStream -> + dbFile.outputStream().use { outputStream -> + copyFile(inputStream, outputStream) + } + } ?: throw IllegalArgumentException("Unable to open source Uri for reading") + + context.runOnUiThread { + Toast.makeText(context, "Database imported successfully!", Toast.LENGTH_SHORT).show() + Toast.makeText(context, "Reopen the app to finish import process!", Toast.LENGTH_LONG).show() + context.finishAffinity() + } + } catch (e: Exception) { + Log.e("ScanScreenController", "Error importing database $e") + context.runOnUiThread { + Toast.makeText(context, "Error importing database: ${e.message}", Toast.LENGTH_LONG).show() + } + } + } + } + + private fun getFileNameFromUri(uri: Uri): String? { + var fileName: String? = null + val cursor = context.contentResolver.query(uri, null, null, null, null) + cursor?.use { + if (it.moveToFirst()) { + val nameIndex = it.getColumnIndexOrThrow(OpenableColumns.DISPLAY_NAME) + fileName = it.getString(nameIndex) + } + } + return fileName + } + + private fun copyFile(inputStream: InputStream, outputStream: OutputStream) { + val buffer = ByteArray(1024) + var bytesRead: Int + while (inputStream.read(buffer).also { bytesRead = it } != -1) { + outputStream.write(buffer, 0, bytesRead) + } + outputStream.flush() + } + + private fun importApk(uri: Uri) { + context.lifecycleScope.launch(Dispatchers.IO) { + context.contentResolver.openInputStream(uri).use { + if(it == null) { + context.runOnUiThread { + Toast.makeText( + context, + "Selected file is empty!", + Toast.LENGTH_SHORT + ).show() + } + return@launch + } + val secrets: Secrets? + try { + secrets = secretsImporter.importSecrets(it) + } catch (e: Exception) { + context.runOnUiThread { + Toast.makeText(context, "Secrets import failed. Please only select the official Vital Arena App 2.1.0 APK.", Toast.LENGTH_SHORT).show() + } + return@launch + } + context.lifecycleScope.launch(Dispatchers.IO) { + secretsRepository.updateSecrets(secrets) + }.invokeOnCompletion { + context.runOnUiThread { + Toast.makeText(context, "Secrets successfully imported. Connections with devices are now possible.", Toast.LENGTH_SHORT).show() + } + } + } + } + } +} \ No newline at end of file