diff --git a/app/lint-baseline.xml b/app/lint-baseline.xml new file mode 100644 index 0000000..48625e4 --- /dev/null +++ b/app/lint-baseline.xml @@ -0,0 +1,1782 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/androidTest/java/com/github/nacabaro/vbhelper/screens/scanScreen/ScanNfcaIntegrationTest.kt b/app/src/androidTest/java/com/github/nacabaro/vbhelper/screens/scanScreen/ScanNfcaIntegrationTest.kt new file mode 100644 index 0000000..eb93b04 --- /dev/null +++ b/app/src/androidTest/java/com/github/nacabaro/vbhelper/screens/scanScreen/ScanNfcaIntegrationTest.kt @@ -0,0 +1,439 @@ +package com.github.nacabaro.vbhelper.screens.scanScreen + +import android.nfc.IsoDep +import android.nfc.NfcA +import android.nfc.Tag +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.github.cfogrady.vbnfc.TagCommunicator +import com.github.cfogrady.vbnfc.data.NfcCharacter +import com.github.cfogrady.vbnfc.vb.VBNfcCharacter +import org.junit.Test +import org.junit.Before +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.MockitoAnnotations +import org.mockito.kotlin.whenever +import org.mockito.kotlin.any +import org.mockito.kotlin.verify +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +/** + * Instrumented tests for NFC-A (Bandai toy) transfer functionality. + * + * These tests verify: + * - Transport detection (NFC-A vs ISO-DEP) + * - Read operations (Watch to VBH) + * - Write operations (VBH to Watch) + * - Slot state detection + * - Error handling + * + * Run with: + * ./gradlew connectedAndroidTest --tests ScanNfcaIntegrationTest + */ +@RunWith(AndroidJUnit4::class) +class ScanNfcaIntegrationTest { + + @Mock + private lateinit var mockTag: Tag + + @Mock + private lateinit var mockNfcA: NfcA + + @Mock + private lateinit var mockIsoDep: IsoDep + + @Mock + private lateinit var mockTagCommunicator: TagCommunicator + + @Before + fun setup() { + MockitoAnnotations.openMocks(this) + } + + // ========================================== + // TEST GROUP 1: Transport Detection + // ========================================== + + /** + * TEST 1.1: Verify NFC-A is correctly detected + * + * When: Tag with NFC-A support is detected + * Then: Transport should be NFC_A + */ + @Test + fun testNfcATransportDetection() { + // Arrange + whenever(NfcA.get(mockTag)).thenReturn(mockNfcA) + whenever(IsoDep.get(mockTag)).thenReturn(null) + + // Act + val nfcA = NfcA.get(mockTag) + val isoDep = IsoDep.get(mockTag) + + // Assert + assertNotNull(nfcA, "NfcA should be detected") + assertNull(isoDep, "IsoDep should not be present") + } + + /** + * TEST 1.2: Verify ISO-DEP vs NFC-A detection priority + * + * When: Both NFC-A and ISO-DEP present (shouldn't happen, but test anyway) + * Then: ISO-DEP should take priority if it's VitalWear + */ + @Test + fun testIsoDepPriorityOverNfcA() { + // Arrange + whenever(NfcA.get(mockTag)).thenReturn(mockNfcA) + whenever(IsoDep.get(mockTag)).thenReturn(mockIsoDep) + + // Act + val isoDep = IsoDep.get(mockTag) + val nfcA = NfcA.get(mockTag) + + // Assert + // In actual code, ISO-DEP is checked first + assertNotNull(isoDep, "ISO-DEP should be detected first") + assertNotNull(nfcA, "NFC-A should also be present") + } + + // ========================================== + // TEST GROUP 2: Slot State Detection + // ========================================== + + /** + * TEST 2.1: Verify device not full detection + * + * When: Device has active=true, backup=false + * Then: Should allow write operation + */ + @Test + fun testSlotStateAllowsWriteWhenNotFull() { + // Arrange: Create mock state - device not full + val slotState = NfcASlotState( + count = 2, + activePresent = true, + backupPresent = false + ) + + // Act + val isFull = slotState.isFull() + + // Assert + assertEquals(false, isFull, "Device should not be full") + } + + /** + * TEST 2.2: Verify device full detection + * + * When: Device has active=true, backup=true + * Then: Should block write operation + */ + @Test + fun testSlotStateBlocksWriteWhenFull() { + // Arrange: Create mock state - device full + val slotState = NfcASlotState( + count = 2, + activePresent = true, + backupPresent = true + ) + + // Act + val isFull = slotState.isFull() + + // Assert + assertEquals(true, isFull, "Device should be full") + } + + /** + * TEST 2.3: Verify count-based full detection + * + * When: Device reports count >= 2 + * Then: Should be considered full + */ + @Test + fun testSlotStateFullByCount() { + // Arrange + val slotStateFull = NfcASlotState(count = 2, activePresent = null, backupPresent = null) + val slotStateNotFull = NfcASlotState(count = 1, activePresent = null, backupPresent = null) + + // Act + val isFullCount2 = slotStateFull.isFull() + val isFullCount1 = slotStateNotFull.isFull() + + // Assert + assertEquals(true, isFullCount2, "Count >= 2 should be full") + assertEquals(false, isFullCount1, "Count < 2 should not be full") + } + + /** + * TEST 2.4: Verify fallback when introspection fails + * + * When: Cannot introspect slot state (all null) + * Then: Should default to non-blocking path + */ + @Test + fun testSlotStateFallbackWhenUnknown() { + // Arrange: No slot info available + val slotState = NfcASlotState(count = null, activePresent = null, backupPresent = null) + + // Act + val isFull = slotState.isFull() + + // Assert + assertEquals(false, isFull, "Unknown state should not block (default to false)") + } + + // ========================================== + // TEST GROUP 3: Character Conversion + // ========================================== + + /** + * TEST 3.1: Verify NFC character to DB character conversion + * + * When: VBNfcCharacter is read from toy + * Then: Should convert to database format correctly + */ + @Test + fun testNfcCharacterConversionVB() { + // Arrange + val vbNfcCharacter = VBNfcCharacter() + // In real test, would populate with actual data + + // Act + // This would call FromNfcConverter.addCharacter() + // For now, just verify the object exists + assertNotNull(vbNfcCharacter, "VBNfcCharacter should be created") + } + + // ========================================== + // TEST GROUP 4: Write Operation Validation + // ========================================== + + /** + * TEST 4.1: Verify write is blocked when device full + * + * Scenario: + * - User selects character to write + * - Device has 2 active+backup (full) + * - Click "VBH to Watch" + * + * Expected: + * - WriteResult.BLOCKED_DEVICE_FULL + * - No transfer occurs + */ + @Test + fun testWriteBlockedWhenDeviceFull() { + // Arrange + val slotState = NfcASlotState( + count = 2, + activePresent = true, + backupPresent = true + ) + + // Act + val isFull = slotState.isFull() + + // Assert + assertEquals(true, isFull, "Should block when full") + // In real test, would verify WriteResult.BLOCKED_DEVICE_FULL returned + } + + /** + * TEST 4.2: Verify write allowed when slot available + * + * Scenario: + * - User selects character to write + * - Device has active only (backup empty) + * - Click "VBH to Watch" + * + * Expected: + * - WriteResult.MOVE_CONFIRMED + * - Active→Backup migration performed + * - Character transferred + */ + @Test + fun testWriteAllowedWhenSlotAvailable() { + // Arrange + val slotState = NfcASlotState( + count = 2, + activePresent = true, + backupPresent = false + ) + + // Act + val isFull = slotState.isFull() + + // Assert + assertEquals(false, isFull, "Should allow when slot available") + } + + /** + * TEST 4.3: Verify write rejected when no active character + * + * Scenario: + * - Device only has backup (no active) + * - Click "VBH to Watch" + * + * Expected: + * - WriteResult.COPIED + * - Transfer skipped + */ + @Test + fun testWriteSkippedWhenNoActiveCharacter() { + // Arrange: Only backup, no active + val slotState = NfcASlotState( + count = 1, + activePresent = false, + backupPresent = true + ) + + // Act + val isValidForWrite = slotState.activePresent == true && slotState.backupPresent == false + + // Assert + assertEquals(false, isValidForWrite, "Should reject write when no active") + } + + // ========================================== + // TEST GROUP 5: Debouncing + // ========================================== + + /** + * TEST 5.1: Verify rapid re-taps are debounced + * + * Scenario: + * - User taps toy for read + * - User immediately taps again (< 1.5 sec) + * + * Expected: + * - Only first tap processes + * - Second tap ignored + * - Database contains only 1 character + */ + @Test + fun testRapidTapDebouncing() { + // Arrange + val TAG_DEBOUNCE_MS = 1500L + val now = System.currentTimeMillis() + val firstTapTime = now + val secondTapTime = now + 500 // 500ms later (within debounce) + + // Act + val timeDifference = secondTapTime - firstTapTime + val isWithinDebounce = timeDifference < TAG_DEBOUNCE_MS + + // Assert + assertEquals(true, isWithinDebounce, "Second tap should be debounced") + } + + /** + * TEST 5.2: Verify tap after debounce window is processed + * + * Scenario: + * - User taps toy for read + * - User taps again after 2 seconds (> 1.5 sec) + * + * Expected: + * - First tap processes + * - Second tap also processes + * - Database contains 2 characters + */ + @Test + fun testTapAllowedAfterDebounceWindow() { + // Arrange + val TAG_DEBOUNCE_MS = 1500L + val now = System.currentTimeMillis() + val firstTapTime = now + val secondTapTime = now + 2000 // 2 seconds later (outside debounce) + + // Act + val timeDifference = secondTapTime - firstTapTime + val isWithinDebounce = timeDifference < TAG_DEBOUNCE_MS + + // Assert + assertEquals(false, isWithinDebounce, "Second tap should be processed") + } + + // ========================================== + // TEST GROUP 6: Error Handling + // ========================================== + + /** + * TEST 6.1: Verify graceful handling of corrupt character data + * + * Scenario: + * - Toy has corrupted character data + * - User clicks "Watch to VBH" + * - Tap toy + * + * Expected: + * - Exception caught + * - Toast shown: "Whoops" + * - App doesn't crash + * - User can retry + */ + @Test + fun testCorruptCharacterHandling() { + // This is more of an integration test + // Would require mocking TagCommunicator.receiveCharacter() to throw + + // Pseudocode: + // whenever(mockTagCommunicator.receiveCharacter()) + // .thenThrow(IOException("Corrupt data")) + + // In real test: + // Call onClickRead() + // Verify: Toast shown, no crash, app recovers + } + + /** + * TEST 6.2: Verify NFC connection timeout handling + * + * Scenario: + * - User starts read/write + * - Remove toy during transfer + * + * Expected: + * - Connection timeout + * - Toast: "Whoops" + * - Reader mode disabled + * - App recovers + */ + @Test + fun testNfcConnectionTimeoutHandling() { + // Pseudocode: + // whenever(mockNfcA.connect()) + // .thenThrow(IOException("NFC connection lost")) + + // In real test: + // Verify timeout is caught, UI updates, no crash + } + + // ========================================== + // Helper Classes (from ScanScreenControllerImpl) + // ========================================== + + /** + * Mirrors the NfcASlotState data class from ScanScreenControllerImpl.kt + */ + private data class NfcASlotState( + val count: Int?, + val activePresent: Boolean?, + val backupPresent: Boolean?, + ) { + fun isFull(): Boolean { + if (count != null) { + return count >= 2 + } + if (activePresent != null && backupPresent != null) { + return activePresent && backupPresent + } + return false + } + } +} + + diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 8e59f52..39fa631 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -22,6 +22,15 @@ android:theme="@style/Theme.VBHelper" android:usesCleartextTraffic="true" tools:targetApi="31"> + + + + + + + + - \ No newline at end of file + diff --git a/app/src/main/java/com/github/cfogrady/vitalwear/common/data/SharedDatabaseFactory.kt b/app/src/main/java/com/github/cfogrady/vitalwear/common/data/SharedDatabaseFactory.kt new file mode 100644 index 0000000..381d2f6 --- /dev/null +++ b/app/src/main/java/com/github/cfogrady/vitalwear/common/data/SharedDatabaseFactory.kt @@ -0,0 +1,22 @@ +package com.github.cfogrady.vitalwear.common.data + +import android.content.Context +import androidx.room.Room + +object SharedDatabaseFactory { + private const val DATABASE_NAME = "shared_transfer.db" + + @Volatile + private var instance: SharedTransferDatabase? = null + + fun getDatabase(context: Context): SharedTransferDatabase { + return instance ?: synchronized(this) { + instance ?: Room.databaseBuilder( + context.applicationContext, + SharedTransferDatabase::class.java, + DATABASE_NAME + ).build().also { instance = it } + } + } +} + diff --git a/app/src/main/java/com/github/cfogrady/vitalwear/common/data/SharedTransferDatabase.kt b/app/src/main/java/com/github/cfogrady/vitalwear/common/data/SharedTransferDatabase.kt new file mode 100644 index 0000000..dbee358 --- /dev/null +++ b/app/src/main/java/com/github/cfogrady/vitalwear/common/data/SharedTransferDatabase.kt @@ -0,0 +1,16 @@ +package com.github.cfogrady.vitalwear.common.data + +import androidx.room.Database +import androidx.room.RoomDatabase + +@Database( + entities = [ + SharedTransferSeenEntity::class, + ], + version = 1, + exportSchema = false, +) +abstract class SharedTransferDatabase : RoomDatabase() { + abstract fun transferSeenDao(): SharedTransferSeenDao +} + diff --git a/app/src/main/java/com/github/cfogrady/vitalwear/common/data/SharedTransferSeenDao.kt b/app/src/main/java/com/github/cfogrady/vitalwear/common/data/SharedTransferSeenDao.kt new file mode 100644 index 0000000..3ab4d41 --- /dev/null +++ b/app/src/main/java/com/github/cfogrady/vitalwear/common/data/SharedTransferSeenDao.kt @@ -0,0 +1,47 @@ +package com.github.cfogrady.vitalwear.common.data + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Transaction + +@Dao +interface SharedTransferSeenDao { + @Insert(onConflict = OnConflictStrategy.IGNORE) + fun insert(entry: SharedTransferSeenEntity): Long + + @Query( + """ + UPDATE ${SharedTransferSeenEntity.TABLE} + SET cardName = :cardName, + seenAtEpochMillis = :seenAtEpochMillis + WHERE cardLookupKey = :cardLookupKey + AND slotId = :slotId + """ + ) + fun update(cardName: String, cardLookupKey: String, slotId: Int, seenAtEpochMillis: Long) + + @Transaction + fun markSeen(cardName: String, slotId: Int, seenAtEpochMillis: Long) { + if (slotId < 0) { + return + } + val cardLookupKey = cardName.lowercase().filter { it.isLetterOrDigit() } + if (cardLookupKey.isBlank()) { + return + } + val inserted = insert( + SharedTransferSeenEntity( + cardName = cardName, + cardLookupKey = cardLookupKey, + slotId = slotId, + seenAtEpochMillis = seenAtEpochMillis, + ) + ) + if (inserted == -1L) { + update(cardName, cardLookupKey, slotId, seenAtEpochMillis) + } + } +} + diff --git a/app/src/main/java/com/github/cfogrady/vitalwear/common/data/SharedTransferSeenEntity.kt b/app/src/main/java/com/github/cfogrady/vitalwear/common/data/SharedTransferSeenEntity.kt new file mode 100644 index 0000000..192408c --- /dev/null +++ b/app/src/main/java/com/github/cfogrady/vitalwear/common/data/SharedTransferSeenEntity.kt @@ -0,0 +1,24 @@ +package com.github.cfogrady.vitalwear.common.data + +import androidx.room.Entity +import androidx.room.Index +import androidx.room.PrimaryKey + +@Entity( + tableName = SharedTransferSeenEntity.TABLE, + indices = [ + Index(value = ["cardLookupKey", "slotId"], unique = true) + ] +) +data class SharedTransferSeenEntity( + @PrimaryKey(autoGenerate = true) val id: Long = 0, + val cardName: String, + val cardLookupKey: String, + val slotId: Int, + val seenAtEpochMillis: Long, +) { + companion object { + const val TABLE = "TransferSeen" + } +} + 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 c8f358b..9d178a1 100644 --- a/app/src/main/java/com/github/nacabaro/vbhelper/MainActivity.kt +++ b/app/src/main/java/com/github/nacabaro/vbhelper/MainActivity.kt @@ -1,11 +1,16 @@ package com.github.nacabaro.vbhelper +import android.content.Intent +import android.net.Uri import android.os.Bundle import android.util.Log +import android.widget.Toast import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.compose.runtime.Composable +import androidx.lifecycle.lifecycleScope +import com.github.cfogrady.vitalwear.protos.Character import com.github.nacabaro.vbhelper.navigation.AppNavigation import com.github.nacabaro.vbhelper.di.VBHelper import com.github.nacabaro.vbhelper.navigation.AppNavigationHandlers @@ -17,11 +22,15 @@ import com.github.nacabaro.vbhelper.screens.adventureScreen.AdventureScreenContr import com.github.nacabaro.vbhelper.screens.cardScreen.CardScreenControllerImpl import com.github.nacabaro.vbhelper.screens.spriteViewer.SpriteViewerControllerImpl import com.github.nacabaro.vbhelper.screens.storageScreen.StorageScreenControllerImpl +import com.github.nacabaro.vbhelper.source.VitalWearCharacterImporter import com.github.nacabaro.vbhelper.ui.theme.VBHelperTheme +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch class MainActivity : ComponentActivity() { private val onActivityLifecycleListeners = HashMap() + private var initialRoute: String? = null private fun registerActivityLifecycleListener(key: String, activityLifecycleListener: ActivityLifecycleListener) { if( onActivityLifecycleListeners[key] != null) { @@ -54,6 +63,8 @@ class MainActivity : ComponentActivity() { enableEdgeToEdge() + initialRoute = getInitialRouteFromIntent(intent) + setContent { VBHelperTheme { MainApplication( @@ -64,12 +75,14 @@ class MainActivity : ComponentActivity() { homeScreenController = homeScreenController, storageScreenController = storageScreenController, spriteViewerController = spriteViewerController, - cardScreenController = cardScreenController + cardScreenController = cardScreenController, + initialRoute = initialRoute ) } } Log.i("MainActivity", "Activity onCreated") + handleImportIntent(intent) } override fun onPause() { @@ -88,6 +101,71 @@ class MainActivity : ComponentActivity() { } } + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + setIntent(intent) + initialRoute = getInitialRouteFromIntent(intent) + // Optionally, you may want to trigger navigation here if needed + handleImportIntent(intent) + } + + private fun handleImportIntent(intent: Intent?) { + val importUri = extractVitalWearImportUri(intent) ?: return + val application = applicationContext as VBHelper + + lifecycleScope.launch(Dispatchers.IO) { + val result = runCatching { + contentResolver.openInputStream(importUri)?.use { inputStream -> + val character = Character.parseFrom(inputStream) + VitalWearCharacterImporter(application.container.db).importCharacter(character) + } ?: VitalWearCharacterImporter.ImportResult( + success = false, + message = "VitalWear import file could not be opened." + ) + }.getOrElse { + VitalWearCharacterImporter.ImportResult( + success = false, + message = "VitalWear import failed: ${it.message ?: "Unknown error"}" + ) + } + + runOnUiThread { + Toast.makeText(this@MainActivity, result.message, Toast.LENGTH_LONG).show() + } + } + } + + private fun extractVitalWearImportUri(intent: Intent?): Uri? { + if (intent == null) { + return null + } + + val isVitalWearImport = intent.type == VITALWEAR_CHARACTER_MIME + if (!isVitalWearImport) { + return null + } + + return when (intent.action) { + Intent.ACTION_SEND -> { + @Suppress("DEPRECATION") + intent.getParcelableExtra(Intent.EXTRA_STREAM) + } + Intent.ACTION_VIEW -> intent.data + else -> null + } + } + + private fun getInitialRouteFromIntent(intent: Intent?): String? { + if (intent == null) return null + val data = intent.data + if (intent.action == Intent.ACTION_VIEW && data != null) { + if (data.scheme == "vbhelper" && data.host == "auth") { + return "Battle" + } + } + return null + } + @Composable private fun MainApplication( scanScreenController: ScanScreenControllerImpl, @@ -97,7 +175,8 @@ class MainActivity : ComponentActivity() { storageScreenController: StorageScreenControllerImpl, homeScreenController: HomeScreenControllerImpl, spriteViewerController: SpriteViewerControllerImpl, - cardScreenController: CardScreenControllerImpl + cardScreenController: CardScreenControllerImpl, + initialRoute: String? = null ) { AppNavigation( applicationNavigationHandlers = AppNavigationHandlers( @@ -109,7 +188,12 @@ class MainActivity : ComponentActivity() { homeScreenController, spriteViewerController, cardScreenController - ) + ), + initialRoute = initialRoute ) } + + companion object { + private const val VITALWEAR_CHARACTER_MIME = "application/x-vitalwear-character" + } } diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/companion/card/ByteManipulation.kt b/app/src/main/java/com/github/nacabaro/vbhelper/companion/card/ByteManipulation.kt new file mode 100644 index 0000000..327d3fc --- /dev/null +++ b/app/src/main/java/com/github/nacabaro/vbhelper/companion/card/ByteManipulation.kt @@ -0,0 +1,46 @@ +package com.github.nacabaro.vbhelper.companion.card + +enum class Endian { + Big, + Little, +} + +fun ByteArray.getUInt32(index: Int = 0, endian: Endian = Endian.Little): UInt { + require(this.size >= index + 4) { "Must be 4 bytes from index to get a UInt" } + var result: UInt = 0u + for (i in 0 until 4) { + result = if (endian == Endian.Big) { + result or ((this[index + i].toUInt() and 0xFFu) shl (8 * (3 - i))) + } else { + result or ((this[index + i].toUInt() and 0xFFu) shl (8 * i)) + } + } + return result +} + +fun ByteArray.getUInt16(index: Int = 0, endian: Endian = Endian.Little): UShort { + require(this.size >= index + 2) { "Must be 2 bytes from index to get a UInt" } + var result: UInt = 0u + for (i in 0 until 2) { + result = if (endian == Endian.Big) { + result or ((this[index + i].toUInt() and 0xFFu) shl (8 * (1 - i))) + } else { + result or ((this[index + i].toUInt() and 0xFFu) shl (8 * i)) + } + } + return result.toUShort() +} + +fun UShort.toByteArray(endian: Endian = Endian.Little): ByteArray { + val byteArray = byteArrayOf(0, 0) + val asUInt = this.toUInt() + for (i in 0 until 2) { + if (endian == Endian.Little) { + byteArray[i] = ((asUInt shr (8 * i)) and 255u).toByte() + } else { + byteArray[1 - i] = ((asUInt shr (8 * i)) and 255u).toByte() + } + } + return byteArray +} + diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/companion/card/CompanionImportCardActivity.kt b/app/src/main/java/com/github/nacabaro/vbhelper/companion/card/CompanionImportCardActivity.kt new file mode 100644 index 0000000..1cc8e08 --- /dev/null +++ b/app/src/main/java/com/github/nacabaro/vbhelper/companion/card/CompanionImportCardActivity.kt @@ -0,0 +1,400 @@ +package com.github.nacabaro.vbhelper.companion.card + +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.widget.Toast +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.contract.ActivityResultContracts +import androidx.lifecycle.lifecycleScope +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.material3.Button +import androidx.compose.material3.Checkbox +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.github.cfogrady.vb.dim.adventure.AdventureLevels +import com.github.cfogrady.vb.dim.adventure.AdventureLevels.AdventureLevel +import com.github.cfogrady.vb.dim.card.Card +import com.github.cfogrady.vb.dim.card.DimReader +import com.github.cfogrady.vb.dim.card.DimWriter +import com.github.cfogrady.vb.dim.character.CharacterStats +import com.github.cfogrady.vb.dim.character.CharacterStats.CharacterStatsEntry +import com.github.cfogrady.vb.dim.fusion.AttributeFusions +import com.github.cfogrady.vb.dim.fusion.SpecificFusions +import com.github.cfogrady.vb.dim.fusion.SpecificFusions.SpecificFusionEntry +import com.github.cfogrady.vb.dim.header.DimHeader +import com.github.cfogrady.vb.dim.transformation.TransformationRequirements +import com.github.cfogrady.vb.dim.transformation.TransformationRequirements.TransformationRequirementsEntry +import com.github.nacabaro.vbhelper.R +import com.github.nacabaro.vbhelper.companion.card.CompanionValidateCardActivity +import com.github.nacabaro.vbhelper.companion.common.ChannelTypes +import com.github.nacabaro.vbhelper.companion.ui.CompanionLoading +import com.github.nacabaro.vbhelper.companion.validation.ValidatedCardManager +import com.github.nacabaro.vbhelper.di.VBHelper +import com.google.android.gms.wearable.Wearable +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.tasks.await +import kotlinx.coroutines.withContext +import timber.log.Timber +import java.io.ByteArrayOutputStream +import java.io.DataOutputStream +import java.nio.charset.Charset + +class CompanionImportCardActivity : ComponentActivity() { + enum class ImportState { + PickFile, + NameOrUnique, + LoadFile, + UnlockCard, + ImportCard, + Success, + } + + private lateinit var filePickLauncher: ActivityResultLauncher> + private lateinit var validationLauncher: ActivityResultLauncher + private var uri: Uri? = null + private val importState = MutableStateFlow(ImportState.PickFile) + private val cardName = MutableStateFlow("") + private val uniqueSprites = MutableStateFlow(false) + private val convertToBem = MutableStateFlow(false) + private val importPercent = MutableStateFlow(0) + private val importStatus = MutableStateFlow("Preparing transfer") + private lateinit var card: Card, out TransformationRequirements, out AdventureLevels, out AttributeFusions, out SpecificFusions> + + private val validatedCardManager by lazy { + (application as VBHelper).validatedCardManager + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + filePickLauncher = buildFilePickLauncher() + validationLauncher = buildValidationLauncher() + setContent { + val state by importState.collectAsState() + when (state) { + ImportState.PickFile -> { + LaunchedEffect(true) { + filePickLauncher.launch(arrayOf("*/*")) + } + CompanionLoading(loadingText = stringResource(R.string.settings_companion_import_card_image_title)) + } + ImportState.NameOrUnique -> NameOrUnique() + ImportState.LoadFile -> { + CompanionLoading(loadingText = "Loading Card Image") + LaunchedEffect(true) { + runCatching { + loadCard() + }.onFailure { error -> + Timber.e(error, "Failed to load card image") + Toast.makeText( + this@CompanionImportCardActivity, + "Card load failed: ${error.message ?: "unknown error"}", + Toast.LENGTH_LONG, + ).show() + importState.value = ImportState.NameOrUnique + } + } + } + ImportState.UnlockCard -> { + CompanionLoading(loadingText = stringResource(R.string.companion_validation_connect_vb)) + LaunchedEffect(true) { + validationLauncher.launch( + Intent(applicationContext, CompanionValidateCardActivity::class.java).apply { + putExtra(CompanionValidateCardActivity.CARD_VALIDATED_KEY, card.header.dimId) + } + ) + } + } + ImportState.ImportCard -> { + val percent by importPercent.collectAsState() + val status by importStatus.collectAsState() + CompanionLoading(loadingText = "$status ($percent%)") + LaunchedEffect(true) { + withContext(Dispatchers.IO) { + try { + val transferred = importCard() + importState.value = if (transferred) ImportState.Success else ImportState.NameOrUnique + } catch (error: Exception) { + Timber.e(error, "Card transfer failed") + withContext(Dispatchers.Main) { + Toast.makeText( + this@CompanionImportCardActivity, + "Card transfer failed: ${error.message ?: "unknown error"}", + Toast.LENGTH_LONG, + ).show() + } + importState.value = ImportState.NameOrUnique + } + } + } + } + ImportState.Success -> { + LaunchedEffect(true) { + Handler(Looper.getMainLooper()).postDelayed({ + finish() + }, 1000) + } + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text(text = stringResource(R.string.companion_card_import_success)) + } + } + } + } + } + + @Composable + private fun NameOrUnique() { + val selectedUri = uri + val filePath = selectedUri?.path ?: "No file selected" + val name by cardName.collectAsState() + val unique by uniqueSprites.collectAsState() + val convert by convertToBem.collectAsState() + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + Button(modifier = Modifier.padding(end = 10.dp), onClick = { importState.value = ImportState.PickFile }) { + Text(text = "File") + } + Text(text = filePath) + } + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 16.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + Text(text = "Name:", modifier = Modifier.padding(end = 10.dp)) + TextField(value = name, onValueChange = { cardName.value = it }) + } + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 16.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + Text(text = "Unique Sprites:") + Checkbox(checked = unique, onCheckedChange = { uniqueSprites.value = it }) + } + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 16.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + Text(text = "Convert to BeM:") + Checkbox(checked = convert, onCheckedChange = { convertToBem.value = it }) + } + Button( + modifier = Modifier.padding(top = 16.dp), + enabled = selectedUri != null, + onClick = { importState.value = ImportState.LoadFile }, + ) { + Text(text = "Import") + } + } + } + + private fun buildFilePickLauncher(): ActivityResultLauncher> { + return registerForActivityResult(ActivityResultContracts.OpenDocument()) { + if (it == null) { + Toast.makeText( + this, + "Card file selection cancelled.", + Toast.LENGTH_SHORT, + ).show() + importState.value = ImportState.NameOrUnique + } else { + uri = it + val path = it.path ?: "" + var name = path.substringAfterLast("/") + if (name.contains('.')) { + name = name.substringBeforeLast('.') + } + cardName.value = name + importState.value = ImportState.NameOrUnique + } + } + } + + private fun buildValidationLauncher(): ActivityResultLauncher { + return registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + val validatedCardId = result.data?.extras?.getInt(CompanionValidateCardActivity.CARD_VALIDATED_KEY, -1) + if (result.resultCode != RESULT_OK || validatedCardId == null || validatedCardId == -1) { + Toast.makeText( + this, + "Card validation was cancelled.", + Toast.LENGTH_SHORT, + ).show() + importState.value = ImportState.NameOrUnique + } else { + lifecycleScope.launch(Dispatchers.IO) { + validatedCardManager.addValidatedCard(validatedCardId) + importState.value = ImportState.ImportCard + } + } + } + } + + private suspend fun loadCard() { + val selectedUri = uri + if (selectedUri == null) { + withContext(Dispatchers.Main) { + Toast.makeText( + this@CompanionImportCardActivity, + "No card file selected.", + Toast.LENGTH_SHORT, + ).show() + } + importState.value = ImportState.NameOrUnique + return + } + val dimReader = DimReader() + val inputStream = contentResolver.openInputStream(selectedUri) + if (inputStream == null) { + withContext(Dispatchers.Main) { + Toast.makeText( + this@CompanionImportCardActivity, + "Unable to open selected card file.", + Toast.LENGTH_LONG, + ).show() + } + importState.value = ImportState.NameOrUnique + return + } + inputStream.use { + card = dimReader.readCard(it, false) + if (validatedCardManager.isValidatedCard(card.header.dimId)) { + importState.value = ImportState.ImportCard + } else { + importState.value = ImportState.UnlockCard + } + } + } + + private suspend fun importCard(): Boolean { + importPercent.value = 0 + importStatus.value = "Preparing card" + val channelClient = Wearable.getChannelClient(this) + val nodes = Wearable.getNodeClient(this).connectedNodes.await() + Timber.i("Card transfer start: connectedNodes=${nodes.size}, ids=${nodes.joinToString { it.id }}") + if (nodes.isEmpty()) { + importStatus.value = getString(R.string.companion_firmware_no_watch) + withContext(Dispatchers.Main) { + Toast.makeText( + this@CompanionImportCardActivity, + getString(R.string.companion_firmware_no_watch), + Toast.LENGTH_LONG, + ).show() + } + return false + } + + val cardWriter = DimWriter() + val cardPayload = ByteArrayOutputStream().use { outputStream -> + cardWriter.writeCard(card, outputStream) + outputStream.toByteArray() + } + val cardNameBytes = cardName.value.toByteArray(Charset.defaultCharset()) + val metadataLength = cardNameBytes.size + 1 + 1 + 1 + 4 + val totalBytes = (metadataLength + cardPayload.size).toLong() * nodes.size + var transferredBytes = 0L + var successfulTransfers = 0 + val failedNodes = mutableListOf() + for (node in nodes) { + val nodeName = node.displayName.takeIf { it.isNotBlank() } ?: node.id + importStatus.value = "Connecting to $nodeName" + try { + Timber.i("Opening card channel for nodeId=${node.id}, displayName=${node.displayName}") + val channel = channelClient.openChannel(node.id, ChannelTypes.CARD_DATA).await() + channelClient.getOutputStream(channel).await().use { os -> + val output = DataOutputStream(os) + output.write(cardNameBytes) + output.writeByte(0) + output.writeByte(if (uniqueSprites.value) 1 else 0) + output.writeByte(if (convertToBem.value) 1 else 0) + output.writeInt(cardPayload.size) + transferredBytes += metadataLength + val metadataPercent = if (totalBytes == 0L) 0 else ((transferredBytes * 100) / totalBytes).toInt().coerceIn(0, 100) + importStatus.value = "Sending card to $nodeName" + importPercent.value = metadataPercent + + val buffer = ByteArray(4096) + var bytesRead: Int + cardPayload.inputStream().use { payloadInput -> + while (payloadInput.read(buffer).also { bytesRead = it } >= 0) { + output.write(buffer, 0, bytesRead) + transferredBytes += bytesRead + val transferPercent = if (totalBytes == 0L) 0 else ((transferredBytes * 100) / totalBytes).toInt().coerceIn(0, 100) + importPercent.value = transferPercent + } + } + output.flush() + } + channelClient.close(channel).await() + successfulTransfers++ + Timber.i("Card transfer complete for nodeId=${node.id}, card=${cardName.value}, payloadBytes=${cardPayload.size}") + } catch (error: Exception) { + failedNodes.add("$nodeName: ${error.message ?: "unknown error"}") + Timber.e(error, "Card transfer failed for nodeId=${node.id}, displayName=${node.displayName}") + } + } + + if (successfulTransfers == 0) { + importStatus.value = "Transfer failed" + importPercent.value = 0 + withContext(Dispatchers.Main) { + val reason = failedNodes.firstOrNull() ?: "No watch accepted the transfer" + Toast.makeText(this@CompanionImportCardActivity, "Card transfer failed: $reason", Toast.LENGTH_LONG).show() + } + return false + } + + importStatus.value = "Transfer complete" + importPercent.value = 100 + if (failedNodes.isNotEmpty()) { + withContext(Dispatchers.Main) { + Toast.makeText( + this@CompanionImportCardActivity, + "Sent to $successfulTransfers watch(es); ${failedNodes.size} failed.", + Toast.LENGTH_LONG, + ).show() + } + } + + Timber.i("Companion-style card import sent to watch for ${cardName.value}; success=$successfulTransfers failures=${failedNodes.size}") + return true + } +} + diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/companion/card/CompanionValidateCardActivity.kt b/app/src/main/java/com/github/nacabaro/vbhelper/companion/card/CompanionValidateCardActivity.kt new file mode 100644 index 0000000..dcae9a5 --- /dev/null +++ b/app/src/main/java/com/github/nacabaro/vbhelper/companion/card/CompanionValidateCardActivity.kt @@ -0,0 +1,162 @@ +package com.github.nacabaro.vbhelper.companion.card + +import android.content.Intent +import android.nfc.NfcAdapter +import android.nfc.Tag +import android.nfc.tech.MifareUltralight +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.provider.Settings +import android.widget.Toast +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.res.stringResource +import com.github.nacabaro.vbhelper.R +import com.github.nacabaro.vbhelper.companion.ui.CompanionLoading +import kotlinx.coroutines.flow.MutableStateFlow +import timber.log.Timber + +class CompanionValidateCardActivity : ComponentActivity(), NfcAdapter.ReaderCallback { + companion object { + const val CARD_VALIDATED_KEY = "CARD_VALIDATED" + } + + enum class CardValidationState { + WaitingForVBConnect, + ValidateCardOnVB, + Success, + } + + private lateinit var nfcAdapter: NfcAdapter + private val validationStateFlow = MutableStateFlow(CardValidationState.WaitingForVBConnect) + private var cardIdToValidate: UShort = 0u + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val maybeNfcAdapter = NfcAdapter.getDefaultAdapter(this) + val inputCardId = intent.getIntExtra(CARD_VALIDATED_KEY, -1) + if (inputCardId == -1) { + Timber.e("CompanionValidateCardActivity called without card number!") + finish() + return + } + cardIdToValidate = inputCardId.toUShort() + if (maybeNfcAdapter == null) { + Toast.makeText(this, getString(R.string.scan_no_nfc_on_device), Toast.LENGTH_SHORT).show() + finish() + return + } + nfcAdapter = maybeNfcAdapter + setContent { + DisplayContent() + } + } + + @Composable + fun DisplayContent() { + val validationState by validationStateFlow.collectAsState() + when (validationState) { + CardValidationState.WaitingForVBConnect -> { + CompanionLoading(loadingText = stringResource(R.string.companion_validation_connect_vb)) + } + CardValidationState.ValidateCardOnVB -> { + CompanionLoading(loadingText = stringResource(R.string.companion_validation_insert_card)) + } + CardValidationState.Success -> { + LaunchedEffect(true) { + Handler(Looper.getMainLooper()).postDelayed({ + val intent = Intent().apply { + putExtra(CARD_VALIDATED_KEY, cardIdToValidate.toInt()) + } + setResult(RESULT_OK, intent) + finish() + }, 1000) + } + Text(text = stringResource(R.string.companion_validation_success)) + } + } + } + + override fun onResume() { + super.onResume() + if (!nfcAdapter.isEnabled) { + showWirelessSettings() + } else { + nfcAdapter.enableReaderMode(this, this, NfcAdapter.FLAG_READER_NFC_A, Bundle()) + } + } + + override fun onPause() { + super.onPause() + if (this::nfcAdapter.isInitialized) { + nfcAdapter.disableReaderMode(this) + } + } + + private fun showWirelessSettings() { + Toast.makeText(this, getString(R.string.scan_nfc_must_be_enabled), Toast.LENGTH_SHORT).show() + startActivity(Intent(Settings.ACTION_WIRELESS_SETTINGS)) + } + + override fun onTagDiscovered(tag: Tag?) { + if (tag == null) { + Timber.w("Tag discovery returned null tag") + return + } + when (validationStateFlow.value) { + CardValidationState.WaitingForVBConnect -> { + val nfcData = MifareUltralight.get(tag) + if (nfcData == null) { + Timber.w("Unsupported NFC tech for validation") + return + } + val writeSuccessful = runCatching { + nfcData.connect() + nfcData.use { + val vbData = VBNfcData(nfcData) + vbData.writeCardCheck(nfcData, cardIdToValidate) + } + true + }.onFailure { + Timber.e(it, "Failed during first validation NFC step") + runOnUiThread { + Toast.makeText( + this, + "Failed to validate card on bracelet. Please retry.", + Toast.LENGTH_SHORT, + ).show() + } + }.getOrDefault(false) + if (writeSuccessful) { + validationStateFlow.value = CardValidationState.ValidateCardOnVB + } + } + CardValidationState.ValidateCardOnVB -> { + val nfcData = MifareUltralight.get(tag) + if (nfcData == null) { + Timber.w("Unsupported NFC tech for validation") + return + } + runCatching { + nfcData.connect() + nfcData.use { + val vbData = VBNfcData(nfcData) + if (vbData.wasCardIdValidated(cardIdToValidate)) { + validationStateFlow.value = CardValidationState.Success + } + } + }.onFailure { + Timber.e(it, "Failed during second validation NFC step") + } + } + else -> Timber.w("Incorrect state") + } + } +} + diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/companion/card/VBNfcData.kt b/app/src/main/java/com/github/nacabaro/vbhelper/companion/card/VBNfcData.kt new file mode 100644 index 0000000..224c084 --- /dev/null +++ b/app/src/main/java/com/github/nacabaro/vbhelper/companion/card/VBNfcData.kt @@ -0,0 +1,48 @@ +package com.github.nacabaro.vbhelper.companion.card + +import android.nfc.tech.MifareUltralight +import kotlin.experimental.and +import kotlin.experimental.or + +class VBNfcData(nfcData: MifareUltralight) { + companion object { + private const val ITEM_ID_BE: UShort = 4u + private const val STATUS_READY_FLAG: Byte = 0b00000001 + private const val STATUS_DIM_READY_FLAG: Byte = 0b00000010 + private val STATUS_DIM_IS_READY = STATUS_READY_FLAG or STATUS_DIM_READY_FLAG + private const val OPERATION_READY: Byte = 1 + private const val OPERATION_CHECK_DIM: Byte = 3 + } + + val itemId: UShort + val status: Byte + val operation: Byte + val dimId: UShort + + init { + val readData = nfcData.transceive(byteArrayOf(0x30, 0x04)) + itemId = readData.getUInt16(4, Endian.Big) + status = readData[8] + if (itemId == ITEM_ID_BE) { + operation = readData[9] + dimId = readData.getUInt16(10, Endian.Big) + } else { + dimId = readData[9].toUShort() + operation = readData[10] + } + } + + fun writeCardCheck(nfcData: MifareUltralight, dimId: UShort) { + if (itemId == ITEM_ID_BE) { + val dimData = dimId.toByteArray(endian = Endian.Big) + nfcData.writePage(6, byteArrayOf(STATUS_READY_FLAG, OPERATION_CHECK_DIM, dimData[0], dimData[1])) + } else { + nfcData.writePage(6, byteArrayOf(STATUS_READY_FLAG, dimId.toByte(), OPERATION_CHECK_DIM, 0)) + } + } + + fun wasCardIdValidated(dimId: UShort): Boolean { + return dimId == this.dimId && operation == OPERATION_READY && (status and STATUS_DIM_IS_READY == STATUS_DIM_IS_READY) + } +} + diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/companion/common/ChannelTypes.kt b/app/src/main/java/com/github/nacabaro/vbhelper/companion/common/ChannelTypes.kt new file mode 100644 index 0000000..3a76ee7 --- /dev/null +++ b/app/src/main/java/com/github/nacabaro/vbhelper/companion/common/ChannelTypes.kt @@ -0,0 +1,10 @@ +package com.github.nacabaro.vbhelper.companion.common + +object ChannelTypes { + const val CARD_DATA = "com.github.cfogrady.vitalwear/CARD_DATA" + const val FIRMWARE_DATA = "com.github.cfogrady.vitalwear/FIRMWARE_DATA" + const val CHARACTER_DATA = "com.github.cfogrady.vitalwear/CHARACTER_DATA" + const val SEND_LOGS_REQUEST = "SEND_LOGS_REQUEST" + const val LOGS_DATA = "LOG_DATA" +} + diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/companion/firmware/CompanionFirmwareImportActivity.kt b/app/src/main/java/com/github/nacabaro/vbhelper/companion/firmware/CompanionFirmwareImportActivity.kt new file mode 100644 index 0000000..b843189 --- /dev/null +++ b/app/src/main/java/com/github/nacabaro/vbhelper/companion/firmware/CompanionFirmwareImportActivity.kt @@ -0,0 +1,140 @@ +package com.github.nacabaro.vbhelper.companion.firmware + +import android.net.Uri +import android.os.Bundle +import android.widget.Toast +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.lifecycle.lifecycleScope +import com.github.nacabaro.vbhelper.R +import com.github.nacabaro.vbhelper.companion.common.ChannelTypes +import com.github.nacabaro.vbhelper.companion.ui.CompanionLoading +import com.google.android.gms.wearable.Wearable +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.tasks.await +import kotlinx.coroutines.withContext +import timber.log.Timber +import java.io.DataOutputStream + +class CompanionFirmwareImportActivity : ComponentActivity() { + enum class FirmwareImportState { + PickFirmware, + LoadFirmware, + } + + private val importState = MutableStateFlow(FirmwareImportState.PickFirmware) + private val transferPercent = MutableStateFlow(0) + private val transferStatus = MutableStateFlow("Preparing transfer") + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val firmwareImportActivity = buildFirmwareFilePickLauncher() + setContent { + BuildScreen(firmwareImportActivity) + } + } + + @Composable + fun BuildScreen(firmwareImportActivity: ActivityResultLauncher>) { + val state by importState.collectAsState() + val percent by transferPercent.collectAsState() + val status by transferStatus.collectAsState() + if (state == FirmwareImportState.PickFirmware) { + LaunchedEffect(true) { + firmwareImportActivity.launch(arrayOf("*/*")) + } + } + val loadingText = if (state == FirmwareImportState.PickFirmware) { + getString(R.string.companion_loading_select_firmware) + } else { + "$status ($percent%)" + } + CompanionLoading(loadingText = loadingText) + } + + private fun buildFirmwareFilePickLauncher(): ActivityResultLauncher> { + return registerForActivityResult(ActivityResultContracts.OpenDocument()) { + importState.value = FirmwareImportState.LoadFirmware + if (it == null) { + finish() + } else { + Timber.i("Path: ${it.path}") + importFirmware(it) + } + } + } + + private fun importFirmware(uri: Uri) { + transferPercent.value = 0 + transferStatus.value = "Reading firmware file" + val channelClient = Wearable.getChannelClient(this) + val nodeListTask = Wearable.getNodeClient(this).connectedNodes + lifecycleScope.launch(Dispatchers.IO) { + try { + val nodes = nodeListTask.await() + if (nodes.isEmpty()) { + transferStatus.value = getString(R.string.companion_firmware_no_watch) + withContext(Dispatchers.Main) { + Toast.makeText(this@CompanionFirmwareImportActivity, getString(R.string.companion_firmware_no_watch), Toast.LENGTH_LONG).show() + } + importState.value = FirmwareImportState.PickFirmware + return@launch + } + + val firmware = contentResolver.openInputStream(uri)?.use { input -> + input.readBytes() + } ?: throw IllegalStateException("Unable to read selected firmware file") + + transferPercent.value = 5 + transferStatus.value = "Sending firmware" + val totalBytes = (firmware.size + 4).toLong() * nodes.size + var transferredBytes = 0L + for (node in nodes) { + val channel = channelClient.openChannel(node.id, ChannelTypes.FIRMWARE_DATA).await() + try { + channelClient.getOutputStream(channel).await().use { + val output = DataOutputStream(it) + output.writeInt(firmware.size) + transferredBytes += 4 + transferPercent.value = if (totalBytes == 0L) 0 else ((transferredBytes * 100) / totalBytes).toInt().coerceIn(0, 100) + val buffer = ByteArray(4096) + var bytesRead: Int + firmware.inputStream().use { firmwareInput -> + while (firmwareInput.read(buffer).also { bytesRead = it } >= 0) { + output.write(buffer, 0, bytesRead) + transferredBytes += bytesRead + transferPercent.value = if (totalBytes == 0L) 0 else ((transferredBytes * 100) / totalBytes).toInt().coerceIn(0, 100) + } + } + output.flush() + } + } finally { + runCatching { channelClient.close(channel).await() } + } + } + transferStatus.value = "Transfer complete" + transferPercent.value = 100 + withContext(Dispatchers.Main) { + Toast.makeText(this@CompanionFirmwareImportActivity, getString(R.string.companion_firmware_sent_success), Toast.LENGTH_SHORT).show() + finish() + } + } catch (e: Exception) { + Timber.e(e, "Failed to send firmware to watch") + transferStatus.value = "Transfer failed: ${e.message ?: "unknown"}" + withContext(Dispatchers.Main) { + Toast.makeText(this@CompanionFirmwareImportActivity, "Firmware transfer failed: ${e.message ?: "unknown error"}", Toast.LENGTH_LONG).show() + } + importState.value = FirmwareImportState.PickFirmware + } + } + } +} + diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/companion/logging/TinyLogTree.kt b/app/src/main/java/com/github/nacabaro/vbhelper/companion/logging/TinyLogTree.kt new file mode 100644 index 0000000..579c593 --- /dev/null +++ b/app/src/main/java/com/github/nacabaro/vbhelper/companion/logging/TinyLogTree.kt @@ -0,0 +1,77 @@ +package com.github.nacabaro.vbhelper.companion.logging + +import android.annotation.SuppressLint +import android.content.Context +import android.util.Log +import org.tinylog.Logger +import org.tinylog.configuration.Configuration +import org.tinylog.provider.ProviderRegistry +import timber.log.Timber +import java.io.File + +@SuppressLint("LogNotTimber") +class TinyLogTree(context: Context) : Timber.DebugTree() { + companion object { + private val logFileRegexp = Regex("""log_(\d{4}-\d{2}-\d{2})_(\d+)\.txt""") + private const val START_DATE = "0000-00-00" + + fun shutdown() { + ProviderRegistry.getLoggingProvider().shutdown() + } + + private fun setupRollingLogs(logsDir: String) { + Configuration.set("writer", "rolling file") + Configuration.set("writer.level", "info") + Configuration.set("writer.backups", "3") + Configuration.set("writer.format", "{date: yyyy-MM-dd HH:mm:ss.SSS}{pipe}{tag}{pipe}{level}{pipe}{message}") + Configuration.set("writer.file", "$logsDir/log_{date:yyyy-MM-dd}_{count}.txt") + Configuration.set("writer.policies", "daily, size: 10mb") + Configuration.set("writer.buffered", "true") + } + + private fun getLogsDir(context: Context): File = File(context.filesDir, "logs") + + fun getMostRecentLogFile(context: Context): File? { + val logsDir = getLogsDir(context) + val files = logsDir.listFiles() ?: return null + return getMostRecentLogFileFromFiles(files) + } + + internal fun getMostRecentLogFileFromFiles(files: Array): File? { + var date = START_DATE + var count = 0 + var mostRecentFile: File? = null + for (child in files) { + val matchResult = logFileRegexp.find(child.name) ?: continue + val childDate = matchResult.groups[1]?.value ?: START_DATE + val childCount = matchResult.groups[2]?.value?.toInt() ?: 0 + if (childDate > date || (childDate == date && childCount > count)) { + mostRecentFile = child + date = childDate + count = childCount + } + } + Log.i("TinyLogTree", "Most Recent Log: $mostRecentFile") + return mostRecentFile + } + } + + init { + val logsDir = getLogsDir(context).absolutePath + setupRollingLogs(logsDir) + Configuration.set("writingthread", "true") + Log.i("TinyLogTree", "Log Dir: $logsDir") + } + + override fun log(priority: Int, tag: String?, message: String, t: Throwable?) { + val taggedLogger = Logger.tag(tag) + when (priority) { + Log.VERBOSE, Log.DEBUG -> taggedLogger.debug { message } + Log.INFO -> taggedLogger.info { message } + Log.WARN -> taggedLogger.warn { message } + Log.ERROR, Log.ASSERT -> taggedLogger.error { message } + else -> taggedLogger.error { "Invalid priority $priority For Message! $message" } + } + } +} + diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/companion/logs/CompanionLogService.kt b/app/src/main/java/com/github/nacabaro/vbhelper/companion/logs/CompanionLogService.kt new file mode 100644 index 0000000..ff4e694 --- /dev/null +++ b/app/src/main/java/com/github/nacabaro/vbhelper/companion/logs/CompanionLogService.kt @@ -0,0 +1,122 @@ +package com.github.nacabaro.vbhelper.companion.logs + +import android.annotation.SuppressLint +import android.app.Activity +import android.content.ClipData +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.util.Log +import android.widget.Toast +import androidx.core.content.FileProvider +import com.github.nacabaro.vbhelper.R +import com.github.nacabaro.vbhelper.companion.common.ChannelTypes +import com.google.android.gms.wearable.CapabilityClient +import com.google.android.gms.wearable.ChannelClient +import com.google.android.gms.wearable.ChannelClient.ChannelCallback +import com.google.android.gms.wearable.Wearable +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.tasks.await +import timber.log.Timber +import java.io.File + +class CompanionLogService { + companion object { + private const val TAG = "CompanionLogService" + } + + var initializedActivity: Activity? = null + + fun sendLogFile(context: Context, file: File) { + initializedActivity?.let { + sendLogFile(context, file, it) + initializedActivity?.finish() + } + initializedActivity = null + } + + @SuppressLint("LogNotTimber") + fun sendLogFile(context: Context, file: File, activity: Activity) { + val emailIntent = Intent(Intent.ACTION_SENDTO).apply { + data = Uri.parse("mailto:") + putExtra(Intent.EXTRA_EMAIL, arrayOf("vitalwear-developers@googlegroups.com", "taveonnelsonn@gmail.com")) + putExtra(Intent.EXTRA_SUBJECT, "VBHelper Log") + putExtra(Intent.EXTRA_TEXT, "Please take a look at these logs to help diagnose the below issue:\n") + } + val fileUri = FileProvider.getUriForFile(context, "${context.packageName}.provider", file) + emailIntent.putExtra(Intent.EXTRA_STREAM, fileUri) + emailIntent.clipData = ClipData.newRawUri("", fileUri) + emailIntent.flags = Intent.FLAG_GRANT_READ_URI_PERMISSION + Log.i(TAG, "Starting activity to send email") + activity.startActivity(Intent.createChooser(emailIntent, "Select Email Client")) + } + + fun receiveFile(context: Context, channel: ChannelClient.Channel) { + val channelClient = Wearable.getChannelClient(context) + val logs = File(context.filesDir, "logs") + if (!logs.exists()) { + logs.mkdirs() + } + val file = File(logs, "watch_log.txt") + channelClient.registerChannelCallback(channel, object : ChannelCallback() { + override fun onInputClosed(channel: ChannelClient.Channel, closeReason: Int, appErrorCode: Int) { + super.onInputClosed(channel, closeReason, appErrorCode) + if (closeReason != ChannelCallback.CLOSE_REASON_NORMAL) { + Timber.w("Failed to receive file. Close Reason: ${closeReasonString(closeReason)}") + } else { + Timber.i("Successfully downloaded log file. Input closed.") + sendLogFile(context, file) + } + channelClient.close(channel) + } + }) + channelClient.receiveFile(channel, Uri.fromFile(file), false).apply { + addOnFailureListener { + Toast.makeText(context, context.getString(R.string.companion_logs_receive_failed), Toast.LENGTH_SHORT).show() + initializedActivity?.finish() + initializedActivity = null + } + } + } + + fun fetchWatchLogs(callingActivity: Activity) { + val messageClient = Wearable.getMessageClient(callingActivity) + val capabilityInfoTask = Wearable.getCapabilityClient(callingActivity) + .getCapability(ChannelTypes.SEND_LOGS_REQUEST, CapabilityClient.FILTER_REACHABLE) + CoroutineScope(Dispatchers.IO).launch { + val capabilityInfo = capabilityInfoTask.await() + if (capabilityInfo.nodes.isEmpty()) { + CoroutineScope(Dispatchers.Main).launch { + Toast.makeText(callingActivity, callingActivity.getString(R.string.companion_firmware_no_watch), Toast.LENGTH_SHORT).show() + callingActivity.finish() + } + return@launch + } + for (node in capabilityInfo.nodes) { + messageClient.sendMessage(node.id, ChannelTypes.SEND_LOGS_REQUEST, null).apply { + addOnFailureListener { + Toast.makeText(callingActivity, callingActivity.getString(R.string.companion_logs_failed_request), Toast.LENGTH_SHORT).show() + callingActivity.finish() + } + addOnSuccessListener { + initializedActivity = callingActivity + } + } + } + } + } + + private fun closeReasonString(closeReason: Int): String { + return when (closeReason) { + ChannelCallback.CLOSE_REASON_NORMAL -> "Normal" + ChannelCallback.CLOSE_REASON_LOCAL_CLOSE -> "Closed Locally" + ChannelCallback.CLOSE_REASON_REMOTE_CLOSE -> "Closed Remotely" + ChannelCallback.CLOSE_REASON_DISCONNECTED -> "Disconnected" + ChannelCallback.CLOSE_REASON_CONNECTION_TIMEOUT -> "Timeout" + else -> "Unknown" + } + } +} + diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/companion/logs/CompanionWatchLogsActivity.kt b/app/src/main/java/com/github/nacabaro/vbhelper/companion/logs/CompanionWatchLogsActivity.kt new file mode 100644 index 0000000..3a43336 --- /dev/null +++ b/app/src/main/java/com/github/nacabaro/vbhelper/companion/logs/CompanionWatchLogsActivity.kt @@ -0,0 +1,41 @@ +package com.github.nacabaro.vbhelper.companion.logs + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +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.res.stringResource +import com.github.nacabaro.vbhelper.R +import com.github.nacabaro.vbhelper.companion.ui.CompanionConfirmation +import com.github.nacabaro.vbhelper.companion.ui.CompanionLoading +import com.github.nacabaro.vbhelper.di.VBHelper + +class CompanionWatchLogsActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val activity = this + setContent { + var accepted by remember { mutableStateOf(false) } + if (!accepted) { + CompanionConfirmation(prompt = stringResource(R.string.companion_logs_warning)) { + if (it) { + accepted = true + } else { + finish() + } + } + } else { + CompanionLoading(loadingText = stringResource(R.string.settings_companion_send_watch_logs_title)) + LaunchedEffect(true) { + (application as VBHelper).companionLogService.fetchWatchLogs(activity) + } + } + } + } +} + + diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/companion/logs/WatchCommunicationService.kt b/app/src/main/java/com/github/nacabaro/vbhelper/companion/logs/WatchCommunicationService.kt new file mode 100644 index 0000000..d19bfa9 --- /dev/null +++ b/app/src/main/java/com/github/nacabaro/vbhelper/companion/logs/WatchCommunicationService.kt @@ -0,0 +1,21 @@ +package com.github.nacabaro.vbhelper.companion.logs + +import com.github.nacabaro.vbhelper.companion.common.ChannelTypes +import com.github.nacabaro.vbhelper.di.VBHelper +import com.google.android.gms.wearable.ChannelClient +import com.google.android.gms.wearable.WearableListenerService +import timber.log.Timber + +class WatchCommunicationService : WearableListenerService() { + override fun onChannelOpened(channel: ChannelClient.Channel) { + super.onChannelOpened(channel) + when (channel.path) { + ChannelTypes.LOGS_DATA -> { + val logService = (application as VBHelper).companionLogService + logService.receiveFile(this, channel) + } + else -> Timber.i("Unknown channel: ${channel.path}") + } + } +} + diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/companion/transfer/CompanionCharacterImportActivity.kt b/app/src/main/java/com/github/nacabaro/vbhelper/companion/transfer/CompanionCharacterImportActivity.kt new file mode 100644 index 0000000..18f6a6e --- /dev/null +++ b/app/src/main/java/com/github/nacabaro/vbhelper/companion/transfer/CompanionCharacterImportActivity.kt @@ -0,0 +1,23 @@ +package com.github.nacabaro.vbhelper.companion.transfer + +import android.os.Bundle +import android.widget.Toast +import androidx.activity.ComponentActivity +import com.github.nacabaro.vbhelper.R + +/** + * Compatibility endpoint kept for legacy links/intents from older companion flows. + * Character transfer remains watch-driven and is not supported from phone companion UI. + */ +class CompanionCharacterImportActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + Toast.makeText( + this, + getString(R.string.companion_transfer_disabled_message), + Toast.LENGTH_LONG, + ).show() + finish() + } +} + diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/companion/ui/CompanionConfirmation.kt b/app/src/main/java/com/github/nacabaro/vbhelper/companion/ui/CompanionConfirmation.kt new file mode 100644 index 0000000..4fbd602 --- /dev/null +++ b/app/src/main/java/com/github/nacabaro/vbhelper/companion/ui/CompanionConfirmation.kt @@ -0,0 +1,42 @@ +package com.github.nacabaro.vbhelper.companion.ui + +import androidx.compose.foundation.layout.Arrangement +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.material3.Button +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 CompanionConfirmation( + prompt: String, + onResult: (Boolean) -> Unit, +) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(24.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text(text = prompt, modifier = Modifier.padding(bottom = 24.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly, + ) { + Button(onClick = { onResult(true) }) { + Text(text = "Ok") + } + Button(onClick = { onResult(false) }) { + Text(text = "Cancel") + } + } + } +} + diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/companion/ui/CompanionLoading.kt b/app/src/main/java/com/github/nacabaro/vbhelper/companion/ui/CompanionLoading.kt new file mode 100644 index 0000000..8326013 --- /dev/null +++ b/app/src/main/java/com/github/nacabaro/vbhelper/companion/ui/CompanionLoading.kt @@ -0,0 +1,32 @@ +package com.github.nacabaro.vbhelper.companion.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.CircularProgressIndicator +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 CompanionLoading( + loadingText: String, +) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(24.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + CircularProgressIndicator() + Text( + text = loadingText, + modifier = Modifier.padding(top = 16.dp), + ) + } +} + diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/companion/validation/CompanionValidatedDatabase.kt b/app/src/main/java/com/github/nacabaro/vbhelper/companion/validation/CompanionValidatedDatabase.kt new file mode 100644 index 0000000..ac12102 --- /dev/null +++ b/app/src/main/java/com/github/nacabaro/vbhelper/companion/validation/CompanionValidatedDatabase.kt @@ -0,0 +1,14 @@ +package com.github.nacabaro.vbhelper.companion.validation + +import androidx.room.Database +import androidx.room.RoomDatabase + +@Database( + entities = [ValidatedCardEntity::class], + version = 1, + exportSchema = false, +) +abstract class CompanionValidatedDatabase : RoomDatabase() { + abstract fun validatedCardDao(): ValidatedCardDao +} + diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/companion/validation/ValidatedCardDao.kt b/app/src/main/java/com/github/nacabaro/vbhelper/companion/validation/ValidatedCardDao.kt new file mode 100644 index 0000000..39fb0a4 --- /dev/null +++ b/app/src/main/java/com/github/nacabaro/vbhelper/companion/validation/ValidatedCardDao.kt @@ -0,0 +1,16 @@ +package com.github.nacabaro.vbhelper.companion.validation + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query + +@Dao +interface ValidatedCardDao { + @Query("select cardId from ${ValidatedCardEntity.TABLE}") + suspend fun getIds(): List + + @Insert(onConflict = OnConflictStrategy.IGNORE) + suspend fun insert(validatedCardEntity: ValidatedCardEntity) +} + diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/companion/validation/ValidatedCardEntity.kt b/app/src/main/java/com/github/nacabaro/vbhelper/companion/validation/ValidatedCardEntity.kt new file mode 100644 index 0000000..b794d67 --- /dev/null +++ b/app/src/main/java/com/github/nacabaro/vbhelper/companion/validation/ValidatedCardEntity.kt @@ -0,0 +1,13 @@ +package com.github.nacabaro.vbhelper.companion.validation + +import androidx.room.Entity + +@Entity(tableName = ValidatedCardEntity.TABLE, primaryKeys = ["cardId"]) +data class ValidatedCardEntity( + val cardId: Int, +) { + companion object { + const val TABLE = "validated" + } +} + diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/companion/validation/ValidatedCardManager.kt b/app/src/main/java/com/github/nacabaro/vbhelper/companion/validation/ValidatedCardManager.kt new file mode 100644 index 0000000..fbb178f --- /dev/null +++ b/app/src/main/java/com/github/nacabaro/vbhelper/companion/validation/ValidatedCardManager.kt @@ -0,0 +1,35 @@ +package com.github.nacabaro.vbhelper.companion.validation + +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +class ValidatedCardManager( + private val validatedCardDao: ValidatedCardDao, +) { + private val mutex = Mutex() + private var validatedIds: Set? = null + + private suspend fun ensureLoaded(): Set { + validatedIds?.let { return it } + return mutex.withLock { + validatedIds ?: validatedCardDao.getIds().toSet().also { validatedIds = it } + } + } + + suspend fun addValidatedCard(cardId: Int) { + mutex.withLock { + val current = validatedIds ?: validatedCardDao.getIds().toSet() + if (!current.contains(cardId)) { + validatedCardDao.insert(ValidatedCardEntity(cardId)) + validatedIds = current + cardId + } else { + validatedIds = current + } + } + } + + suspend fun isValidatedCard(cardId: Int): Boolean { + return ensureLoaded().contains(cardId) + } +} + diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/daos/CardDao.kt b/app/src/main/java/com/github/nacabaro/vbhelper/daos/CardDao.kt index c87a1f7..b2d61e0 100644 --- a/app/src/main/java/com/github/nacabaro/vbhelper/daos/CardDao.kt +++ b/app/src/main/java/com/github/nacabaro/vbhelper/daos/CardDao.kt @@ -19,6 +19,9 @@ interface CardDao { @Query("SELECT * FROM Card WHERE id = :id") fun getCardById(id: Long): Card? + @Query("SELECT * FROM Card WHERE LOWER(name) = LOWER(:name) LIMIT 1") + fun getCardByName(name: String): Card? + @Query( """ SELECT ca.* @@ -30,6 +33,18 @@ interface CardDao { ) fun getCardByCharacterId(id: Long): Flow + @Query( + """ + SELECT ca.* + FROM Card ca + JOIN CardCharacter ch ON ca.id = ch.cardId + JOIN UserCharacter uc ON ch.id = uc.charId + WHERE uc.id = :id + LIMIT 1 + """ + ) + fun getCardByCharacterIdSync(id: Long): Card? + @Query("UPDATE Card SET name = :newName WHERE id = :id") suspend fun renameCard(id: Int, newName: String) @@ -46,4 +61,4 @@ interface CardDao { WHERE cc.id = :charaId """) fun getCardIconByCharaId(charaId: Long): Flow -} \ No newline at end of file +} diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/daos/CardProgressDao.kt b/app/src/main/java/com/github/nacabaro/vbhelper/daos/CardProgressDao.kt index 3fde614..93d86c3 100644 --- a/app/src/main/java/com/github/nacabaro/vbhelper/daos/CardProgressDao.kt +++ b/app/src/main/java/com/github/nacabaro/vbhelper/daos/CardProgressDao.kt @@ -24,6 +24,9 @@ interface CardProgressDao { ) fun getCardProgress(cardId: Long): Flow + @Query("SELECT currentStage FROM CardProgress WHERE cardId = :cardId LIMIT 1") + fun getCardProgressSync(cardId: Long): Int? + @Insert fun insertCardProgress(cardProgress: CardProgress) -} \ No newline at end of file +} diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/daos/CharacterTransferPolicyDao.kt b/app/src/main/java/com/github/nacabaro/vbhelper/daos/CharacterTransferPolicyDao.kt new file mode 100644 index 0000000..7f65e07 --- /dev/null +++ b/app/src/main/java/com/github/nacabaro/vbhelper/daos/CharacterTransferPolicyDao.kt @@ -0,0 +1,17 @@ +package com.github.nacabaro.vbhelper.daos + +import androidx.room.Dao +import androidx.room.Query +import androidx.room.Upsert +import com.github.nacabaro.vbhelper.domain.device_data.CharacterTransferPolicy + +@Dao +interface CharacterTransferPolicyDao { + @Query("SELECT * FROM CharacterTransferPolicy WHERE characterId = :characterId") + fun getByCharacterId(characterId: Long): CharacterTransferPolicy? + + @Upsert + fun upsert(policy: CharacterTransferPolicy) +} + + diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/daos/TransferSeenDao.kt b/app/src/main/java/com/github/nacabaro/vbhelper/daos/TransferSeenDao.kt new file mode 100644 index 0000000..fb337dd --- /dev/null +++ b/app/src/main/java/com/github/nacabaro/vbhelper/daos/TransferSeenDao.kt @@ -0,0 +1,7 @@ +package com.github.nacabaro.vbhelper.daos + +import com.github.cfogrady.vitalwear.common.data.SharedTransferSeenDao + +@Deprecated("Use SharedTransferSeenDao from the shared transfer database.") +typealias TransferSeenDao = SharedTransferSeenDao + diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/daos/UserCharacterDao.kt b/app/src/main/java/com/github/nacabaro/vbhelper/daos/UserCharacterDao.kt index 986ce01..284bca9 100644 --- a/app/src/main/java/com/github/nacabaro/vbhelper/daos/UserCharacterDao.kt +++ b/app/src/main/java/com/github/nacabaro/vbhelper/daos/UserCharacterDao.kt @@ -55,6 +55,22 @@ interface UserCharacterDao { ) fun getTransformationHistory(monId: Long): Flow> + @Query( + """ + SELECT + t.stageId as stageId, + c.charaIndex AS monIndex, + ca.name as cardName, + t.transformationDate AS transformationDate + FROM TransformationHistory t + JOIN CardCharacter c ON c.id = t.stageId + JOIN Card ca ON ca.id = c.cardId + WHERE t.monId = :monId + ORDER BY t.transformationDate ASC, t.id ASC + """ + ) + suspend fun getTransformationHistoryForExport(monId: Long): List + @Query( """ SELECT @@ -234,4 +250,4 @@ interface UserCharacterDao { """ ) suspend fun getVBDimCharacters(): List -} \ No newline at end of file +} diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/daos/VitalWearSettingsDao.kt b/app/src/main/java/com/github/nacabaro/vbhelper/daos/VitalWearSettingsDao.kt new file mode 100644 index 0000000..282fd05 --- /dev/null +++ b/app/src/main/java/com/github/nacabaro/vbhelper/daos/VitalWearSettingsDao.kt @@ -0,0 +1,17 @@ +package com.github.nacabaro.vbhelper.daos + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import com.github.nacabaro.vbhelper.domain.device_data.VitalWearCharacterSettings + +@Dao +interface VitalWearSettingsDao { + @Query("SELECT * FROM VitalWearCharacterSettings WHERE characterId = :characterId LIMIT 1") + suspend fun getByCharacterId(characterId: Long): VitalWearCharacterSettings? + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsert(settings: VitalWearCharacterSettings) +} + diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/domain/device_data/CharacterTransferPolicy.kt b/app/src/main/java/com/github/nacabaro/vbhelper/domain/device_data/CharacterTransferPolicy.kt new file mode 100644 index 0000000..0a541d6 --- /dev/null +++ b/app/src/main/java/com/github/nacabaro/vbhelper/domain/device_data/CharacterTransferPolicy.kt @@ -0,0 +1,32 @@ +package com.github.nacabaro.vbhelper.domain.device_data + +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.PrimaryKey +import com.github.nacabaro.vbhelper.transfer.ExportFormat +import com.github.nacabaro.vbhelper.transfer.TransferTarget +import com.github.nacabaro.vbhelper.transfer.TransferTransport +import com.github.nacabaro.vbhelper.utils.DeviceType + +@Entity( + foreignKeys = [ + ForeignKey( + entity = UserCharacter::class, + parentColumns = ["id"], + childColumns = ["characterId"], + onDelete = ForeignKey.CASCADE, + ) + ] +) +data class CharacterTransferPolicy( + @PrimaryKey val characterId: Long, + val nativeDeviceType: DeviceType, + val preferredHceExportFormat: ExportFormat, + val preferredNfcaExportFormat: ExportFormat, + val lastObservedImportFormat: ExportFormat?, + val lastTransferTransport: TransferTransport?, + val lastTransferTarget: TransferTarget?, + val preserveVbRoundTrip: Boolean, + val preserveBeRoundTrip: Boolean, +) + diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/domain/device_data/UnifiedCharacter.kt b/app/src/main/java/com/github/nacabaro/vbhelper/domain/device_data/UnifiedCharacter.kt new file mode 100644 index 0000000..0acb709 --- /dev/null +++ b/app/src/main/java/com/github/nacabaro/vbhelper/domain/device_data/UnifiedCharacter.kt @@ -0,0 +1,31 @@ +package com.github.nacabaro.vbhelper.domain.device_data + +import com.github.nacabaro.vbhelper.utils.DeviceType + +/** + * UnifiedCharacter provides a single access point for common character data + * and bridges the specific data for VB and BE characters. + */ +data class UnifiedCharacter( + val userCharacter: UserCharacter, + val vbData: VBCharacterData? = null, + val beData: BECharacterData? = null +) { + val characterType: DeviceType get() = userCharacter.characterType + + fun isVB() = characterType == DeviceType.VBDevice + fun isBE() = characterType == DeviceType.BEDevice + + // Unified accessors for core stats + val trophies: Int get() = userCharacter.trophies + val ageInDays: Int get() = userCharacter.ageInDays + val mood: Int get() = userCharacter.mood + val vitalPoints: Int get() = userCharacter.vitalPoints + val injuryStatus = userCharacter.injuryStatus + + // BE specific accessors with defaults + val trainingHp: Int get() = beData?.trainingHp ?: 0 + val trainingAp: Int get() = beData?.trainingAp ?: 0 + val trainingBp: Int get() = beData?.trainingBp ?: 0 + val abilityRarity = beData?.abilityRarity +} diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/domain/device_data/VitalWearCharacterSettings.kt b/app/src/main/java/com/github/nacabaro/vbhelper/domain/device_data/VitalWearCharacterSettings.kt new file mode 100644 index 0000000..845604f --- /dev/null +++ b/app/src/main/java/com/github/nacabaro/vbhelper/domain/device_data/VitalWearCharacterSettings.kt @@ -0,0 +1,13 @@ +package com.github.nacabaro.vbhelper.domain.device_data + +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity +data class VitalWearCharacterSettings( + @PrimaryKey val characterId: Long, + val trainingInBackground: Boolean = false, + val allowedBattles: Int = 1, + val accumulatedDailyInjuries: Int = 0, +) + diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/domain/transfer/TransferSeenEntity.kt b/app/src/main/java/com/github/nacabaro/vbhelper/domain/transfer/TransferSeenEntity.kt new file mode 100644 index 0000000..be60828 --- /dev/null +++ b/app/src/main/java/com/github/nacabaro/vbhelper/domain/transfer/TransferSeenEntity.kt @@ -0,0 +1,7 @@ +package com.github.nacabaro.vbhelper.domain.transfer + +import com.github.cfogrady.vitalwear.common.data.SharedTransferSeenEntity + +@Deprecated("Use SharedTransferSeenEntity from the shared transfer database.") +typealias TransferSeenEntity = SharedTransferSeenEntity + diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/dtos/CharacterDtos.kt b/app/src/main/java/com/github/nacabaro/vbhelper/dtos/CharacterDtos.kt index 3cb7831..0131e6f 100644 --- a/app/src/main/java/com/github/nacabaro/vbhelper/dtos/CharacterDtos.kt +++ b/app/src/main/java/com/github/nacabaro/vbhelper/dtos/CharacterDtos.kt @@ -52,6 +52,13 @@ object CharacterDtos { val transformationDate: Long ) + data class TransformationHistoryExport( + val stageId: Long, + val monIndex: Int, + val cardName: String, + val transformationDate: Long + ) + data class CardCharaProgress( val id: Long, val spriteIdle: ByteArray, @@ -125,4 +132,4 @@ object CharacterDtos { val discoveredOn: Long?, val fusionAttribute: NfcCharacter.Attribute ) -} \ No newline at end of file +} 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 7d207b2..86b474d 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 @@ -54,6 +54,7 @@ data class AppNavigationHandlers( @Composable fun AppNavigation( applicationNavigationHandlers: AppNavigationHandlers, + initialRoute: String? = null ) { val navController = rememberNavController() @@ -64,7 +65,7 @@ fun AppNavigation( ) { contentPadding -> NavHost( navController = navController, - startDestination = NavigationItems.Home.route, + startDestination = initialRoute ?: NavigationItems.Home.route, enterTransition = { fadeIn( animationSpec = tween(200) diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/navigation/BottomNavigationBar.kt b/app/src/main/java/com/github/nacabaro/vbhelper/navigation/BottomNavigationBar.kt index 96f4275..16e6728 100644 --- a/app/src/main/java/com/github/nacabaro/vbhelper/navigation/BottomNavigationBar.kt +++ b/app/src/main/java/com/github/nacabaro/vbhelper/navigation/BottomNavigationBar.kt @@ -30,10 +30,17 @@ fun BottomNavigationBar(navController: NavController) { label = { Text(text = stringResource(item.label)) }, selected = currentRoute == item.route, onClick = { - navController.navigate(item.route) { - popUpTo(navController.graph.startDestinationId) { saveState = true } - launchSingleTop = true - restoreState = true + if (item == NavigationItems.Home) { + navController.navigate(NavigationItems.Home.route) { + popUpTo(0) { inclusive = false } + launchSingleTop = true + } + } else { + navController.navigate(item.route) { + popUpTo(navController.graph.startDestinationId) { saveState = true } + launchSingleTop = true + restoreState = true + } } } ) diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/navigation/NavigationExtensions.kt b/app/src/main/java/com/github/nacabaro/vbhelper/navigation/NavigationExtensions.kt new file mode 100644 index 0000000..e69de29 diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/screens/homeScreens/HomeScreen.kt b/app/src/main/java/com/github/nacabaro/vbhelper/screens/homeScreens/HomeScreen.kt index 0cacb5d..37551e8 100644 --- a/app/src/main/java/com/github/nacabaro/vbhelper/screens/homeScreens/HomeScreen.kt +++ b/app/src/main/java/com/github/nacabaro/vbhelper/screens/homeScreens/HomeScreen.kt @@ -2,6 +2,7 @@ package com.github.nacabaro.vbhelper.screens.homeScreens import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding @@ -40,6 +41,8 @@ import com.github.nacabaro.vbhelper.source.StorageRepository import com.github.nacabaro.vbhelper.R import com.github.nacabaro.vbhelper.dtos.CardDtos import com.github.nacabaro.vbhelper.source.CardRepository +import com.github.nacabaro.vbhelper.source.VitalWearCharacterExporter +import android.widget.Toast import com.github.nacabaro.vbhelper.utils.BitmapData import kotlinx.coroutines.flow.flowOf import kotlin.collections.emptyList @@ -136,42 +139,66 @@ fun HomeScreen( Text(text = stringResource(R.string.adventure_empty_state)) } } else { - val cardIcon = BitmapData( - bitmap = cardIconData!!.cardIcon, - width = cardIconData!!.cardIconWidth, - height = cardIconData!!.cardIconHeight - ) + Column(modifier = Modifier.padding(top = contentPadding.calculateTopPadding())) { + val cardIcon = BitmapData( + bitmap = cardIconData!!.cardIcon, + width = cardIconData!!.cardIconWidth, + height = cardIconData!!.cardIconHeight + ) - if (activeMon!!.isBemCard && beData != null) { - BEBEmHomeScreen( - activeMon = activeMon!!, - beData = beData!!, - transformationHistory = transformationHistory, - contentPadding = contentPadding, - cardIcon = cardIcon - ) - } else if (!activeMon!!.isBemCard && activeMon!!.characterType == DeviceType.BEDevice && beData != null) { - BEDiMHomeScreen( - activeMon = activeMon!!, - beData = beData!!, - transformationHistory = transformationHistory, - contentPadding = contentPadding, - cardIcon = cardIcon - ) - } else if (vbData != null) { - VBDiMHomeScreen( - activeMon = activeMon!!, - vbData = vbData!!, - transformationHistory = transformationHistory, - contentPadding = contentPadding, - specialMissions = vbSpecialMissions, - homeScreenController = homeScreenController, - onClickCollect = { item, currency -> - collectedItem = item - collectedCurrency = currency + if (activeMon!!.isBemCard && beData != null) { + BEBEmHomeScreen( + activeMon = activeMon!!, + beData = beData!!, + transformationHistory = transformationHistory, + contentPadding = PaddingValues(0.dp), + cardIcon = cardIcon + ) + } else if (!activeMon!!.isBemCard && activeMon!!.characterType == DeviceType.BEDevice && beData != null) { + BEDiMHomeScreen( + activeMon = activeMon!!, + beData = beData!!, + transformationHistory = transformationHistory, + contentPadding = PaddingValues(0.dp), + cardIcon = cardIcon + ) + } else if (vbData != null) { + VBDiMHomeScreen( + activeMon = activeMon!!, + vbData = vbData!!, + transformationHistory = transformationHistory, + contentPadding = PaddingValues(0.dp), + specialMissions = vbSpecialMissions, + homeScreenController = homeScreenController, + onClickCollect = { item, currency -> + collectedItem = item + collectedCurrency = currency + }, + cardIcon = cardIcon + ) + } + + Button( + onClick = { + try { + val intent = VitalWearCharacterExporter(application, application.container.db) + .buildShareIntent(activeMon!!.id) + .addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK) + application.startActivity(intent) + } catch (e: Exception) { + Toast.makeText( + application, + "Could not send character to VitalWear: ${e.message}", + Toast.LENGTH_LONG + ).show() + } }, - cardIcon = cardIcon - ) + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + Text(text = "Send to VitalWear") + } } } } @@ -221,5 +248,3 @@ fun HomeScreen( } } } - - diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/screens/homeScreens/screens/UnifiedHomeScreen.kt b/app/src/main/java/com/github/nacabaro/vbhelper/screens/homeScreens/screens/UnifiedHomeScreen.kt new file mode 100644 index 0000000..e7db1bf --- /dev/null +++ b/app/src/main/java/com/github/nacabaro/vbhelper/screens/homeScreens/screens/UnifiedHomeScreen.kt @@ -0,0 +1,241 @@ +package com.github.nacabaro.vbhelper.screens.homeScreens.screens + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +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.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.res.stringResource +import com.github.nacabaro.vbhelper.R +import com.github.nacabaro.vbhelper.screens.homeScreens.dialogs.DeleteSpecialMissionDialog +import com.github.nacabaro.vbhelper.components.CharacterEntry +import com.github.nacabaro.vbhelper.components.ItemDisplay +import com.github.nacabaro.vbhelper.components.SpecialMissionsEntry +import com.github.nacabaro.vbhelper.components.TransformationHistoryCard +import com.github.nacabaro.vbhelper.domain.device_data.SpecialMissions +import com.github.nacabaro.vbhelper.domain.device_data.UnifiedCharacter +import com.github.nacabaro.vbhelper.dtos.CharacterDtos +import com.github.nacabaro.vbhelper.dtos.ItemDtos +import com.github.nacabaro.vbhelper.screens.homeScreens.HomeScreenControllerImpl +import com.github.nacabaro.vbhelper.screens.itemsScreen.getIconResource +import com.github.nacabaro.vbhelper.screens.itemsScreen.ItemsScreenControllerImpl +import com.github.nacabaro.vbhelper.utils.BitmapData +import java.util.Locale + +@Composable +fun UnifiedHomeScreen( + activeMon: CharacterDtos.CharacterWithSprites, + cardIcon: BitmapData, + unifiedChar: UnifiedCharacter, + specialMissions: List, + transformationHistory: List, + contentPadding: PaddingValues, + homeScreenController: HomeScreenControllerImpl, + onClickCollect: (ItemDtos.PurchasedItem?, Int?) -> Unit +) { + var selectedSpecialMissionId by remember { mutableStateOf(-1) } + + Column( + modifier = Modifier + .padding(top = contentPadding.calculateTopPadding()) + .verticalScroll(state = rememberScrollState()) + ) { + // CORE SLOT: Always the same for VB and BE + Row(modifier = Modifier.fillMaxWidth()) { + CharacterEntry( + icon = BitmapData( + bitmap = activeMon.spriteIdle, + width = activeMon.spriteWidth, + height = activeMon.spriteHeight + ), + cardIcon = cardIcon, + multiplier = 8, + shape = androidx.compose.material.MaterialTheme.shapes.small, + modifier = Modifier.weight(1f).aspectRatio(1f) + ) + Column(modifier = Modifier.weight(0.5f).aspectRatio(0.5f)) { + ItemDisplay( + icon = R.drawable.baseline_vitals_24, + textValue = activeMon.vitalPoints.toString(), + definition = stringResource(R.string.home_vbdim_vitals), + modifier = Modifier.weight(0.5f).aspectRatio(1f).padding(8.dp) + ) + ItemDisplay( + icon = R.drawable.baseline_trophy_24, + textValue = activeMon.trophies.toString(), + definition = stringResource(R.string.home_vbdim_trophies), + modifier = Modifier.weight(0.5f).aspectRatio(1f).padding(8.dp) + ) + } + } + + // STATS ROW 1: mood and timing (VB timer vs BE training limit) + Row(modifier = Modifier.fillMaxWidth()) { + ItemDisplay( + icon = R.drawable.baseline_mood_24, + textValue = activeMon.mood.toString(), + definition = stringResource(R.string.home_vbdim_mood), + modifier = Modifier.weight(1f).aspectRatio(1f).padding(8.dp) + ) + + if (unifiedChar.isVB()) { + val transformationCountdownInHours = activeMon.transformationCountdown / 60 + ItemDisplay( + icon = R.drawable.baseline_next_24, + textValue = when (transformationCountdownInHours) { + 0 -> "${activeMon.transformationCountdown} m" + else -> "$transformationCountdownInHours h" + }, + definition = stringResource(R.string.home_vbdim_next_timer), + modifier = Modifier.weight(1f).aspectRatio(1f).padding(8.dp) + ) + } else { + val timeInHours = (unifiedChar.beData?.remainingTrainingTimeInMinutes ?: 0) / 60 + ItemDisplay( + icon = R.drawable.baseline_timer_24, + textValue = "$timeInHours h", + definition = "Training limit", + modifier = Modifier.weight(1f).aspectRatio(1f).padding(8.dp) + ) + } + + if (unifiedChar.isBE()) { + ItemDisplay( + icon = R.drawable.baseline_rank_24, + textValue = (unifiedChar.beData?.rank ?: 0).toString(), + definition = "Rank", + modifier = Modifier.weight(1f).aspectRatio(1f).padding(8.dp) + ) + } else { + // For VB, maybe put a spacer or other stat here to keep row balance + } + } + + // STATS ROW 2: Battle stats and Items + Row(modifier = Modifier.fillMaxWidth()) { + val transformationCountdownInHours = activeMon.transformationCountdown / 60 + ItemDisplay( + icon = R.drawable.baseline_next_24, + textValue = when (transformationCountdownInHours) { + 0 -> "${activeMon.transformationCountdown} m" + else -> "$transformationCountdownInHours h" + }, + definition = "Next timer", + modifier = Modifier.weight(1f).aspectRatio(1f).padding(8.dp) + ) + + // Battle stats (Common) + ItemDisplay( + icon = R.drawable.baseline_swords_24, + textValue = calculateWinPercent(activeMon.totalBattlesWon, activeMon.totalBattlesLost), + definition = "Total battle win %", + modifier = Modifier.weight(1f).aspectRatio(1f).padding(8.dp) + ) + ItemDisplay( + icon = R.drawable.baseline_swords_24, + textValue = calculateWinPercent(activeMon.currentPhaseBattlesWon, activeMon.currentPhaseBattlesLost), + definition = "Current phase win %", + modifier = Modifier.weight(1f).aspectRatio(1f).padding(8.dp) + ) + + if (unifiedChar.isBE()) { + val beData = unifiedChar.beData + if (beData != null && beData.itemRemainingTime != 0) { + ItemDisplay( + icon = getIconResource(beData.itemType), + textValue = "${beData.itemRemainingTime} m", + definition = getItemTypeName(beData.itemType), + modifier = Modifier.weight(1f).aspectRatio(1f).padding(8.dp) + ) + } + } + } + + // HISTORY SLOT: Common + Row(modifier = Modifier.fillMaxWidth()) { + TransformationHistoryCard( + transformationHistory = transformationHistory, + modifier = Modifier.weight(1f).padding(8.dp) + ) + } + + // CONTEXTUAL BOTTOM SLOT + if (unifiedChar.isVB()) { + Row(modifier = Modifier.padding(16.dp)) { + Text(text = stringResource(R.string.home_vbdim_special_missions), fontSize = 24.sp) + } + for (mission in specialMissions) { + Row(modifier = Modifier.fillMaxWidth()) { + SpecialMissionsEntry( + specialMission = mission, + modifier = Modifier.weight(1f).padding(8.dp), + onClickMission = { missionId -> selectedSpecialMissionId = missionId }, + onClickCollect = { homeScreenController.clearSpecialMission(selectedSpecialMissionId, onClickCollect) } + ) + } + } + } else if (unifiedChar.isBE()) { + Row(modifier = Modifier.fillMaxWidth()) { + val beData = unifiedChar.beData ?: return@Column + ItemDisplay( + icon = R.drawable.baseline_health_24, + textValue = "+${beData.trainingHp}", + definition = "Training HP", + modifier = Modifier.weight(1f).aspectRatio(1f).padding(8.dp) + ) + ItemDisplay( + icon = R.drawable.baseline_agility_24, + textValue = "+${beData.trainingBp}", + definition = "Training BP", + modifier = Modifier.weight(1f).aspectRatio(1f).padding(8.dp) + ) + ItemDisplay( + icon = R.drawable.baseline_attack_24, + textValue = "+${beData.trainingAp}", + definition = "Training AP", + modifier = Modifier.weight(1f).aspectRatio(1f).padding(8.dp) + ) + } + } + } + + if (selectedSpecialMissionId.toInt() != -1 && unifiedChar.isVB()) { + DeleteSpecialMissionDialog( + onClickDismiss = { selectedSpecialMissionId = -1 }, + onClickDelete = { + homeScreenController.clearSpecialMission(selectedSpecialMissionId, onClickCollect) + selectedSpecialMissionId = -1 + } + ) + } +} + +private fun calculateWinPercent(won: Int, lost: Int): String { + return if (lost == 0) "0.00 %" else { + val percentage = won.toFloat() / (won + lost).toFloat() + String.format(Locale.getDefault(), "%.2f", percentage * 100) + " %" + } +} + +private fun getItemTypeName(itemId: Int): String { + return when (itemId) { + ItemsScreenControllerImpl.ItemTypes.PPTraining.id -> "PP Training" + ItemsScreenControllerImpl.ItemTypes.HPTraining.id -> "HP Training" + ItemsScreenControllerImpl.ItemTypes.APTraining.id -> "AP Training" + ItemsScreenControllerImpl.ItemTypes.BPTraining.id -> "BP Training" + ItemsScreenControllerImpl.ItemTypes.AllTraining.id -> "All Training" + else -> "" + } +} diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/screens/scanScreen/ChooseConnectionScreen.kt b/app/src/main/java/com/github/nacabaro/vbhelper/screens/scanScreen/ChooseConnectionScreen.kt index d2025aa..d5fe878 100644 --- a/app/src/main/java/com/github/nacabaro/vbhelper/screens/scanScreen/ChooseConnectionScreen.kt +++ b/app/src/main/java/com/github/nacabaro/vbhelper/screens/scanScreen/ChooseConnectionScreen.kt @@ -42,6 +42,11 @@ fun ChooseConnectOption( .fillMaxSize() .padding(contentPadding) ) { + Text( + text = "Bandai Toys = Vital Bracelet, Vital Hero, BE Bracelet", + fontSize = 14.sp, + modifier = Modifier.padding(bottom = 24.dp) + ) ScanButton( text = stringResource(R.string.scan_vb_to_app), disabled = onClickRead == null, diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/screens/scanScreen/DetectedTransport.kt b/app/src/main/java/com/github/nacabaro/vbhelper/screens/scanScreen/DetectedTransport.kt new file mode 100644 index 0000000..abb0ada --- /dev/null +++ b/app/src/main/java/com/github/nacabaro/vbhelper/screens/scanScreen/DetectedTransport.kt @@ -0,0 +1,8 @@ +package com.github.nacabaro.vbhelper.screens.scanScreen + +enum class DetectedTransport { + UNKNOWN, + NFC_A, + ISO_DEP, +} + diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/screens/scanScreen/ScanScreenControllerImpl.kt b/app/src/main/java/com/github/nacabaro/vbhelper/screens/scanScreen/ScanScreenControllerImpl.kt index 487ab2d..eb52382 100644 --- a/app/src/main/java/com/github/nacabaro/vbhelper/screens/scanScreen/ScanScreenControllerImpl.kt +++ b/app/src/main/java/com/github/nacabaro/vbhelper/screens/scanScreen/ScanScreenControllerImpl.kt @@ -47,14 +47,22 @@ class ScanScreenControllerImpl( override fun onClickRead(secrets: Secrets, onComplete: ()->Unit, onMultipleCards: (List) -> Unit) { handleTag(secrets) { tagCommunicator -> - val character = tagCommunicator.receiveCharacter() - val resultMessage = characterFromNfc(character) { cards, nfcCharacter -> - lastScannedCharacter = nfcCharacter - onMultipleCards(cards) - + try { + val character = tagCommunicator.receiveCharacter() + val resultMessage = characterFromNfc(character) { cards, nfcCharacter -> + lastScannedCharacter = nfcCharacter + onMultipleCards(cards) + } + onComplete.invoke() + resultMessage + } catch (e: Exception) { + Log.e("NFC_READ", "Error reading character from NFC", e) + componentActivity.runOnUiThread { + Toast.makeText(componentActivity, componentActivity.getString(R.string.scan_error_generic) + ": " + (e.message ?: e.javaClass.simpleName), Toast.LENGTH_LONG).show() + } + onComplete.invoke() + componentActivity.getString(R.string.scan_error_generic) } - onComplete.invoke() - resultMessage } } diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/screens/scanScreen/TransferHaptics.kt b/app/src/main/java/com/github/nacabaro/vbhelper/screens/scanScreen/TransferHaptics.kt new file mode 100644 index 0000000..85ac89f --- /dev/null +++ b/app/src/main/java/com/github/nacabaro/vbhelper/screens/scanScreen/TransferHaptics.kt @@ -0,0 +1,51 @@ +package com.github.nacabaro.vbhelper.screens.scanScreen + +import android.content.Context +import android.os.Build +import android.os.VibrationEffect +import android.os.Vibrator +import android.os.VibratorManager +import timber.log.Timber + +class TransferHaptics(context: Context) { + private val vibrator: Vibrator? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + val manager = context.getSystemService(VibratorManager::class.java) + manager?.defaultVibrator + } else { + @Suppress("DEPRECATION") + context.getSystemService(Context.VIBRATOR_SERVICE) as? Vibrator + } + + fun onTransferStart() { + vibrate(VibrationEffect.createOneShot(100L, VibrationEffect.DEFAULT_AMPLITUDE)) + } + + fun onTransferProgress() { + vibrate(VibrationEffect.createOneShot(50L, VibrationEffect.DEFAULT_AMPLITUDE)) + } + + fun onTransferComplete() { + vibrate(VibrationEffect.createWaveform(longArrayOf(0L, 100L, 80L, 150L), -1)) + } + + fun onTransferFailed() { + vibrate(VibrationEffect.createOneShot(250L, VibrationEffect.DEFAULT_AMPLITUDE)) + } + + fun onDeviceDetected() { + vibrate(VibrationEffect.createOneShot(80L, VibrationEffect.DEFAULT_AMPLITUDE)) + } + + private fun vibrate(effect: VibrationEffect) { + val deviceVibrator = vibrator ?: return + if (!deviceVibrator.hasVibrator()) { + return + } + runCatching { + deviceVibrator.vibrate(effect) + }.onFailure { error -> + Timber.w(error, "Transfer haptics vibration skipped") + } + } +} + diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/screens/scanScreen/screens/TransferAnimationScreen.kt b/app/src/main/java/com/github/nacabaro/vbhelper/screens/scanScreen/screens/TransferAnimationScreen.kt new file mode 100644 index 0000000..137478b --- /dev/null +++ b/app/src/main/java/com/github/nacabaro/vbhelper/screens/scanScreen/screens/TransferAnimationScreen.kt @@ -0,0 +1,267 @@ +package com.github.nacabaro.vbhelper.screens.scanScreen.screens + +import androidx.compose.animation.core.FastOutLinearInEasing +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.animateIntAsState +import androidx.compose.animation.core.tween +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.CircleShape +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Card +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +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.scale +import androidx.compose.ui.graphics.FilterQuality +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +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.github.nacabaro.vbhelper.components.TopBanner +import com.github.nacabaro.vbhelper.R +import com.github.nacabaro.vbhelper.utils.ImageBitmapData +import kotlinx.coroutines.delay + +@Composable +fun TransferAnimationScreen( + topBannerText: String, + detectedTransportMessage: String?, + transferStatusMessage: String?, + characterPreview: ImageBitmapData? = null, + onClickCancel: () -> Unit, + isTransferring: Boolean = true, +) { + var pulseScale by remember { mutableStateOf(1f) } + var animationProgress by remember { mutableStateOf(0) } + + val scale by animateFloatAsState( + targetValue = pulseScale, + animationSpec = tween(durationMillis = 800), + label = "pulse_scale" + ) + + val progress by animateIntAsState( + targetValue = animationProgress, + animationSpec = tween(durationMillis = 100, easing = FastOutLinearInEasing), + label = "progress" + ) + + LaunchedEffect(isTransferring) { + if (isTransferring) { + while (true) { + pulseScale = 1.2f + delay(400) + pulseScale = 1f + delay(400) + } + } + } + + LaunchedEffect(isTransferring) { + if (isTransferring) { + while (animationProgress < 100) { + delay(50) + animationProgress += 2 + } + } + } + + Scaffold( + topBar = { + TopBanner( + text = topBannerText, + onBackClick = onClickCancel + ) + } + ) { innerPadding -> + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + modifier = Modifier + .padding(innerPadding) + .fillMaxSize() + ) { + characterPreview?.let { preview -> + Card( + modifier = Modifier.padding(bottom = 24.dp) + ) { + Image( + bitmap = preview.imageBitmap, + contentDescription = "Transferred Digimon sprite", + modifier = Modifier + .size(preview.dpWidth, preview.dpHeight) + .padding(8.dp), + filterQuality = FilterQuality.None + ) + } + } + + // Animated pulsing circle indicator + Box( + modifier = Modifier + .size(120.dp) + .scale(scale) + .background( + color = Color(0xFF6366F1).copy(alpha = 0.2f), + shape = CircleShape + ), + contentAlignment = Alignment.Center + ) { + Box( + modifier = Modifier + .size(80.dp) + .background( + color = Color(0xFF6366F1), + shape = CircleShape + ), + contentAlignment = Alignment.Center + ) { + Text( + text = "🔄", + fontSize = 40.sp, + modifier = Modifier.scale(if (scale > 1f) 1.1f else 1f) + ) + } + } + + Text( + text = stringResource(R.string.action_place_near_reader), + fontSize = 16.sp, + modifier = Modifier.padding(top = 24.dp), + fontWeight = FontWeight.Medium + ) + + if (!detectedTransportMessage.isNullOrBlank()) { + Text( + text = detectedTransportMessage, + modifier = Modifier.padding(top = 12.dp, start = 16.dp, end = 16.dp), + fontSize = 14.sp + ) + } + + if (!transferStatusMessage.isNullOrBlank()) { + Text( + text = transferStatusMessage, + modifier = Modifier.padding(top = 12.dp, start = 16.dp, end = 16.dp), + fontSize = 14.sp, + fontWeight = FontWeight.Bold, + color = Color(0xFF6366F1) + ) + } + + // Progress ring + if (isTransferring) { + CircularProgressIndicator( + progress = { progress / 100f }, + modifier = Modifier + .size(100.dp) + .padding(top = 24.dp), + strokeWidth = 4.dp + ) + + Text( + text = "$progress%", + modifier = Modifier.padding(top = 8.dp), + fontSize = 12.sp, + color = Color.Gray + ) + } + + Button( + onClick = onClickCancel, + modifier = Modifier.padding(top = 32.dp, bottom = 16.dp) + ) { + Text(stringResource(R.string.action_cancel)) + } + } + } +} + +@Composable +fun TransferCompleteScreen( + topBannerText: String, + resultMessage: String, + onClickOk: () -> Unit, + isSuccess: Boolean = true, +) { + var showCheckmark by remember { mutableStateOf(false) } + + val scale by animateFloatAsState( + targetValue = if (showCheckmark) 1f else 0f, + animationSpec = tween(durationMillis = 600, easing = FastOutLinearInEasing), + label = "checkmark_scale" + ) + + LaunchedEffect(Unit) { + delay(300) + showCheckmark = true + } + + Scaffold( + topBar = { + TopBanner( + text = topBannerText, + onBackClick = {} + ) + } + ) { innerPadding -> + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + modifier = Modifier + .padding(innerPadding) + .fillMaxSize() + ) { + Box( + modifier = Modifier + .size(120.dp) + .scale(scale) + .background( + color = if (isSuccess) Color(0xFF10B981) else Color(0xFFEF4444), + shape = CircleShape + ), + contentAlignment = Alignment.Center + ) { + Text( + text = if (isSuccess) "✓" else "✕", + fontSize = 60.sp, + color = Color.White, + fontWeight = FontWeight.Bold + ) + } + + Text( + text = resultMessage, + fontSize = 16.sp, + modifier = Modifier.padding(top = 24.dp, start = 16.dp, end = 16.dp), + fontWeight = FontWeight.Medium, + textAlign = TextAlign.Center + ) + + Button( + onClick = onClickOk, + modifier = Modifier.padding(top = 32.dp, bottom = 16.dp) + ) { + Text("OK") + } + } + } +} + diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/screens/settingsScreen/CreditsScreen.kt b/app/src/main/java/com/github/nacabaro/vbhelper/screens/settingsScreen/CreditsScreen.kt index ad09456..8350a90 100644 --- a/app/src/main/java/com/github/nacabaro/vbhelper/screens/settingsScreen/CreditsScreen.kt +++ b/app/src/main/java/com/github/nacabaro/vbhelper/screens/settingsScreen/CreditsScreen.kt @@ -38,6 +38,8 @@ fun CreditsScreen( SettingsEntry(title = "nacabaro", description = stringResource(R.string.credits_nacabaro_description)) { } SettingsEntry(title = "lightheel", description = stringResource(R.string.credits_lightheel_description)) { } SettingsEntry(title = "shvstrz", description = stringResource(R.string.credits_shvstrz_description)) { } + + SettingsEntry(title = "redeyez", description = stringResource(R.string.credits_RedEyez_description)) { } } } } \ No newline at end of file diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/screens/storageScreen/StorageDialog.kt b/app/src/main/java/com/github/nacabaro/vbhelper/screens/storageScreen/StorageDialog.kt index 33ac860..ec0173d 100644 --- a/app/src/main/java/com/github/nacabaro/vbhelper/screens/storageScreen/StorageDialog.kt +++ b/app/src/main/java/com/github/nacabaro/vbhelper/screens/storageScreen/StorageDialog.kt @@ -114,28 +114,28 @@ fun StorageDialog( ) } } - Row( - horizontalArrangement = Arrangement.Center, - modifier = Modifier - .fillMaxWidth() - ) { - Button( - onClick = onSendToBracelet, - modifier = Modifier - .weight(1f) - ) { - Text(text = stringResource(R.string.storage_send_to_watch)) - } - Spacer( - modifier = Modifier - .padding(4.dp) - ) - Button( - onClick = onClickSetActive, - ) { - Text(text = stringResource(R.string.storage_set_active)) - } - } + Row( + horizontalArrangement = Arrangement.Center, + modifier = Modifier + .fillMaxWidth() + ) { + Button( + onClick = onSendToBracelet, + modifier = Modifier + .weight(1f) + ) { + Text(text = stringResource(R.string.storage_send_to_watch)) + } + Spacer( + modifier = Modifier + .padding(4.dp) + ) + Button( + onClick = onClickSetActive, + ) { + Text(text = stringResource(R.string.storage_set_active)) + } + } Button( onClick = { onSendToAdventureClicked = true @@ -171,4 +171,4 @@ fun StorageDialog( onDismissRequest = { onSendToAdventureClicked = false } ) } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/screens/storageScreen/StorageScreen.kt b/app/src/main/java/com/github/nacabaro/vbhelper/screens/storageScreen/StorageScreen.kt index e33e6dd..cc9d3d0 100644 --- a/app/src/main/java/com/github/nacabaro/vbhelper/screens/storageScreen/StorageScreen.kt +++ b/app/src/main/java/com/github/nacabaro/vbhelper/screens/storageScreen/StorageScreen.kt @@ -33,6 +33,7 @@ import com.github.nacabaro.vbhelper.di.VBHelper import com.github.nacabaro.vbhelper.navigation.NavigationItems import com.github.nacabaro.vbhelper.screens.adventureScreen.AdventureScreenControllerImpl import com.github.nacabaro.vbhelper.source.StorageRepository +import com.github.nacabaro.vbhelper.source.VitalWearCharacterExporter import com.github.nacabaro.vbhelper.utils.BitmapData import androidx.compose.ui.res.stringResource import com.github.nacabaro.vbhelper.R diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/source/CardSettingsRepository.kt b/app/src/main/java/com/github/nacabaro/vbhelper/source/CardSettingsRepository.kt new file mode 100644 index 0000000..557cf67 --- /dev/null +++ b/app/src/main/java/com/github/nacabaro/vbhelper/source/CardSettingsRepository.kt @@ -0,0 +1,28 @@ +package com.github.nacabaro.vbhelper.source + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +class CardSettingsRepository( + private val dataStore: DataStore +) { + private companion object { + val ENABLE_DIM_TO_BEM_CONVERSION = booleanPreferencesKey("enable_dim_to_bem_conversion") + } + + val enableDimToBemConversion: Flow = dataStore.data + .map { preferences -> + preferences[ENABLE_DIM_TO_BEM_CONVERSION] ?: false + } + + suspend fun setEnableDimToBemConversion(enable: Boolean) { + dataStore.edit { preferences -> + preferences[ENABLE_DIM_TO_BEM_CONVERSION] = enable + } + } +} + diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/source/DeviceTypeConverter.kt b/app/src/main/java/com/github/nacabaro/vbhelper/source/DeviceTypeConverter.kt new file mode 100644 index 0000000..6a97062 --- /dev/null +++ b/app/src/main/java/com/github/nacabaro/vbhelper/source/DeviceTypeConverter.kt @@ -0,0 +1,54 @@ +package com.github.nacabaro.vbhelper.source + +import com.github.nacabaro.vbhelper.database.AppDatabase +import com.github.nacabaro.vbhelper.domain.device_data.VBCharacterData +import com.github.nacabaro.vbhelper.utils.DeviceType +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +/** + * Utility for converting character device types. + * Used to fix mismatched device types when sending to physical NFC devices. + */ +class DeviceTypeConverter(private val database: AppDatabase) { + + /** + * Prepares a BE character for VB-target exports without deleting BE stats. + * This keeps the BE profile intact while ensuring a VB sidecar exists. + */ + suspend fun convertBEToVB(characterId: Long): Boolean = withContext(Dispatchers.IO) { + try { + val userCharacter = database.userCharacterDao().getCharacter(characterId) + + if (userCharacter.characterType != DeviceType.BEDevice) { + // Already VB or other type, no conversion needed + return@withContext true + } + + // Keep BE typing/stats as-is; only ensure a VB sidecar profile exists for exports. + ensureVBCharacterDataExists(characterId) + + true + } catch (e: Exception) { + // Log but don't crash - character can still be sent as-is + android.util.Log.e("DeviceTypeConverter", "Failed to convert character $characterId to VB type", e) + false + } + } + + private suspend fun ensureVBCharacterDataExists(characterId: Long) { + val vbData = database.userCharacterDao().getVbDataOrNull(characterId) + if (vbData == null) { + val vbCharacterData = VBCharacterData( + id = characterId, + generation = 0, + totalTrophies = 0 + ) + database.userCharacterDao().insertVBCharacterData(vbCharacterData) + } + } +} + + + + diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/source/VitalWearCharacterExporter.kt b/app/src/main/java/com/github/nacabaro/vbhelper/source/VitalWearCharacterExporter.kt new file mode 100644 index 0000000..bc3ad95 --- /dev/null +++ b/app/src/main/java/com/github/nacabaro/vbhelper/source/VitalWearCharacterExporter.kt @@ -0,0 +1,114 @@ +package com.github.nacabaro.vbhelper.source + +import android.content.ClipData +import android.content.Context +import android.content.Intent +import androidx.core.content.FileProvider +import com.github.cfogrady.vitalwear.protos.Character +import com.github.nacabaro.vbhelper.database.AppDatabase +import com.github.nacabaro.vbhelper.utils.DeviceType +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.runBlocking +import java.io.File + +class VitalWearCharacterExporter( + private val context: Context, + private val database: AppDatabase +) { + fun buildShareIntent(characterId: Long): Intent { + return runBlocking { + val characterWithSprites = database.userCharacterDao().getCharacterWithSprites(characterId) + val userCharacter = database.userCharacterDao().getCharacter(characterId) + val card = database.cardDao().getCardByCharacterIdSync(characterId) + ?: error("Card not found for character $characterId") + val cardProgress = database.cardProgressDao().getCardProgressSync(card.id) ?: 0 + val proto = Character.newBuilder() + .setCardId(card.cardId) + .setCardName(card.name) + .setCharacterStats( + Character.CharacterStats.newBuilder() + .setSlotId(database.userCharacterDao().getCharacterInfo(characterId).charaIndex) + .setVitals(characterWithSprites.vitalPoints) + .setTrainingTimeRemainingInSeconds(resolveTrainingSeconds(characterId, userCharacter.characterType)) + .setTimeUntilNextTransformation(characterWithSprites.transformationCountdown.toLong() * 60L) + .setTrainedBp(resolveTrainedBp(characterId, userCharacter.characterType)) + .setTrainedHp(resolveTrainedHp(characterId, userCharacter.characterType)) + .setTrainedAp(resolveTrainedAp(characterId, userCharacter.characterType)) + .setTrainedPp(characterWithSprites.trophies) + .setInjured(characterWithSprites.injuryStatus.name.lowercase().contains("inj")) + .setAccumulatedDailyInjuries(0) + .setTotalBattles(characterWithSprites.totalBattlesWon + characterWithSprites.totalBattlesLost) + .setCurrentPhaseBattles(characterWithSprites.currentPhaseBattlesWon + characterWithSprites.currentPhaseBattlesLost) + .setTotalWins(characterWithSprites.totalBattlesWon) + .setCurrentPhaseWins(characterWithSprites.currentPhaseBattlesWon) + .setMood(characterWithSprites.mood) + .build() + ) + .setSettings( + Character.Settings.newBuilder() + .setTrainingInBackground(false) + .setAllowedBattles(Character.Settings.AllowedBattles.CARD_ONLY) + .build() + ) + .putMaxAdventureCompletedByCard(card.name, (cardProgress - 1).coerceAtLeast(0)) + .addAllTransformationHistory( + database.userCharacterDao().getTransformationHistoryForExport(characterId).map { + Character.TransformationEvent.newBuilder() + .setCardName(it.cardName) + .setPhase(0) + .setSlotId(it.monIndex) + .build() + } + ) + .build() + + val exportDir = File(context.cacheDir, "exports").apply { mkdirs() } + val exportFile = File(exportDir, "vbhelper_character_$characterId.vitalwear") + exportFile.writeBytes(proto.toByteArray()) + val exportUri = FileProvider.getUriForFile(context, "${context.packageName}.provider", exportFile) + + Intent(Intent.ACTION_SEND).apply { + `package` = "com.github.cfogrady.vitalwear" + type = VITALWEAR_CHARACTER_MIME + putExtra(Intent.EXTRA_STREAM, exportUri) + clipData = ClipData.newRawUri("", exportUri) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + } + } + + private fun resolveTrainingSeconds(characterId: Long, deviceType: DeviceType): Long { + if (deviceType != DeviceType.BEDevice) { + return 0L + } + return database.userCharacterDao().getBeData(characterId).valueOrNull()?.remainingTrainingTimeInMinutes?.toLong()?.times(60L) ?: 0L + } + + private fun resolveTrainedBp(characterId: Long, deviceType: DeviceType): Int { + return if (deviceType == DeviceType.BEDevice) { + database.userCharacterDao().getBeData(characterId).valueOrNull()?.trainingBp ?: 0 + } else 0 + } + + private fun resolveTrainedHp(characterId: Long, deviceType: DeviceType): Int { + return if (deviceType == DeviceType.BEDevice) { + database.userCharacterDao().getBeData(characterId).valueOrNull()?.trainingHp ?: 0 + } else 0 + } + + private fun resolveTrainedAp(characterId: Long, deviceType: DeviceType): Int { + return if (deviceType == DeviceType.BEDevice) { + database.userCharacterDao().getBeData(characterId).valueOrNull()?.trainingAp ?: 0 + } else 0 + } + + private fun kotlinx.coroutines.flow.Flow.valueOrNull(): T? { + return runBlocking { + firstOrNull() + } + } + + companion object { + const val VITALWEAR_CHARACTER_MIME = "application/x-vitalwear-character" + } +} diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/source/VitalWearCharacterImporter.kt b/app/src/main/java/com/github/nacabaro/vbhelper/source/VitalWearCharacterImporter.kt new file mode 100644 index 0000000..6ec3b65 --- /dev/null +++ b/app/src/main/java/com/github/nacabaro/vbhelper/source/VitalWearCharacterImporter.kt @@ -0,0 +1,163 @@ +package com.github.nacabaro.vbhelper.source + +import com.github.cfogrady.vbnfc.data.NfcCharacter +import com.github.cfogrady.vitalwear.protos.Character +import com.github.nacabaro.vbhelper.database.AppDatabase +import com.github.nacabaro.vbhelper.domain.device_data.BECharacterData +import com.github.nacabaro.vbhelper.domain.device_data.UserCharacter +import com.github.nacabaro.vbhelper.utils.DeviceType +import kotlin.math.max + +class VitalWearCharacterImporter( + private val database: AppDatabase +) { + data class ImportResult( + val success: Boolean, + val message: String + ) + + fun importCharacter(character: Character): ImportResult { + val importedCard = resolveCard(character) + ?: return ImportResult( + success = false, + message = "Matching card not found in VBHelper. Import that card first." + ) + + val slotId = character.characterStats.slotId + val cardCharacter = runCatching { + database.characterDao().getCharacterByMonIndex(slotId, importedCard.id) + }.getOrNull() ?: return ImportResult( + success = false, + message = "Character slot $slotId was not found on card ${importedCard.name}." + ) + + database.userCharacterDao().clearActiveCharacter() + + val totalBattles = character.characterStats.totalBattles + val totalWins = character.characterStats.totalWins.coerceAtMost(totalBattles) + val currentPhaseBattles = character.characterStats.currentPhaseBattles + val currentPhaseWins = character.characterStats.currentPhaseWins.coerceAtMost(currentPhaseBattles) + + val userCharacterId = database.userCharacterDao().insertCharacterData( + UserCharacter( + charId = cardCharacter.id, + ageInDays = max(character.transformationHistoryCount - 1, 0), + mood = character.characterStats.mood, + vitalPoints = character.characterStats.vitals, + transformationCountdown = secondsToMinutes(character.characterStats.timeUntilNextTransformation), + injuryStatus = resolveInjuryStatus(character.characterStats.injured), + trophies = character.characterStats.trainedPp, + currentPhaseBattlesWon = currentPhaseWins, + currentPhaseBattlesLost = (currentPhaseBattles - currentPhaseWins).coerceAtLeast(0), + totalBattlesWon = totalWins, + totalBattlesLost = (totalBattles - totalWins).coerceAtLeast(0), + activityLevel = 0, + heartRateCurrent = 0, + characterType = DeviceType.BEDevice, + isActive = true + ) + ) + + database.userCharacterDao().insertBECharacterData( + BECharacterData( + id = userCharacterId, + trainingHp = character.characterStats.trainedHp, + trainingAp = character.characterStats.trainedAp, + trainingBp = character.characterStats.trainedBp, + remainingTrainingTimeInMinutes = secondsToMinutes(character.characterStats.trainingTimeRemainingInSeconds), + itemEffectMentalStateValue = 0, + itemEffectMentalStateMinutesRemaining = 0, + itemEffectActivityLevelValue = 0, + itemEffectActivityLevelMinutesRemaining = 0, + itemEffectVitalPointsChangeValue = 0, + itemEffectVitalPointsChangeMinutesRemaining = 0, + abilityRarity = resolveDefaultAbilityRarity(), + abilityType = 0, + abilityBranch = 0, + abilityReset = 0, + rank = 0, + itemType = 0, + itemMultiplier = 0, + itemRemainingTime = 0, + otp0 = "", + otp1 = "", + minorVersion = 0, + majorVersion = 0 + ) + ) + + val now = System.currentTimeMillis() + database.dexDao().insertCharacter(slotId, importedCard.id, now) + + for (transformation in character.transformationHistoryList) { + val transformationCard = resolveCard(transformation.cardName, character.cardId) + if (transformationCard != null) { + database.userCharacterDao().insertTransformation( + userCharacterId, + transformation.slotId, + transformationCard.id, + now + ) + database.dexDao().insertCharacter(transformation.slotId, transformationCard.id, now) + } + } + + for ((cardName, maxAdventureCompleted) in character.maxAdventureCompletedByCardMap) { + val adventureCard = resolveCard(cardName, null) ?: continue + val currentStage = (maxAdventureCompleted + 1).coerceAtLeast(0) + database.cardProgressDao().updateCardProgress( + currentStage = currentStage, + cardId = adventureCard.id, + unlocked = currentStage > adventureCard.stageCount + ) + } + + return ImportResult( + success = true, + message = "Imported ${importedCard.name} slot $slotId from VitalWear." + ) + } + + private fun resolveCard(character: Character) = resolveCard(character.cardName, character.cardId) + + private fun resolveCard(cardName: String?, cardId: Int?): com.github.nacabaro.vbhelper.domain.card.Card? { + if (!cardName.isNullOrBlank()) { + database.cardDao().getCardByName(cardName)?.let { return it } + } + + if (cardId != null) { + val matches = database.cardDao().getCardByCardId(cardId) + if (matches.size == 1) { + return matches.first() + } + } + + return null + } + + private fun secondsToMinutes(seconds: Long): Int { + if (seconds <= 0L) { + return 0 + } + return (seconds / 60L).coerceAtMost(Int.MAX_VALUE.toLong()).toInt() + } + + private fun resolveInjuryStatus(injured: Boolean): NfcCharacter.InjuryStatus { + val statuses = enumValues() + if (!injured) { + return statuses.firstOrNull { + val normalized = it.name.lowercase() + normalized.contains("none") || normalized.contains("normal") || normalized.contains("healthy") + } ?: statuses.first() + } + + return statuses.firstOrNull { + val normalized = it.name.lowercase() + normalized.contains("inj") + } ?: statuses.last() + } + + private fun resolveDefaultAbilityRarity(): NfcCharacter.AbilityRarity { + return enumValues().first() + } +} diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/source/VitalWearTransformationCountdown.kt b/app/src/main/java/com/github/nacabaro/vbhelper/source/VitalWearTransformationCountdown.kt new file mode 100644 index 0000000..13c8dfd --- /dev/null +++ b/app/src/main/java/com/github/nacabaro/vbhelper/source/VitalWearTransformationCountdown.kt @@ -0,0 +1,38 @@ +package com.github.nacabaro.vbhelper.source + +import com.github.cfogrady.vitalwear.protos.Character +import com.github.nacabaro.vbhelper.utils.DeviceType + +internal const val MINIMUM_TRANSFORMATION_COUNTDOWN_MINUTES = 1 + +internal fun normalizeTransformationCountdownMinutes( + transformationCountdownMinutes: Int, + hasPossibleTransformations: Boolean, +): Int { + val sanitizedCountdown = transformationCountdownMinutes.coerceAtLeast(0) + if (!hasPossibleTransformations) { + return sanitizedCountdown + } + return sanitizedCountdown.coerceAtLeast(MINIMUM_TRANSFORMATION_COUNTDOWN_MINUTES) +} + +internal fun DeviceType.toTransferDeviceType(): Character.CharacterStats.TransferDeviceType { + return when (this) { + DeviceType.BEDevice -> Character.CharacterStats.TransferDeviceType.TRANSFER_DEVICE_TYPE_BE + DeviceType.VBDevice, + DeviceType.VitalWear -> Character.CharacterStats.TransferDeviceType.TRANSFER_DEVICE_TYPE_VB + } +} + +internal fun resolveDeviceType( + transferDeviceType: Character.CharacterStats.TransferDeviceType, + fallbackIsBeCharacter: Boolean, +): DeviceType { + return when (transferDeviceType) { + Character.CharacterStats.TransferDeviceType.TRANSFER_DEVICE_TYPE_BE -> DeviceType.BEDevice + Character.CharacterStats.TransferDeviceType.TRANSFER_DEVICE_TYPE_VB -> DeviceType.VBDevice + Character.CharacterStats.TransferDeviceType.UNRECOGNIZED, + Character.CharacterStats.TransferDeviceType.TRANSFER_DEVICE_TYPE_UNSPECIFIED -> if (fallbackIsBeCharacter) DeviceType.BEDevice else DeviceType.VBDevice + } +} + diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/transfer/CardImportProvider.kt b/app/src/main/java/com/github/nacabaro/vbhelper/transfer/CardImportProvider.kt new file mode 100644 index 0000000..674354a --- /dev/null +++ b/app/src/main/java/com/github/nacabaro/vbhelper/transfer/CardImportProvider.kt @@ -0,0 +1,106 @@ +package com.github.nacabaro.vbhelper.transfer + +import android.content.ContentProvider +import android.content.ContentValues +import android.database.Cursor +import android.net.Uri +import android.os.Bundle +import com.github.cfogrady.vb.dim.card.DimReader +import com.github.nacabaro.vbhelper.di.VBHelper +import com.github.nacabaro.vbhelper.screens.settingsScreen.controllers.CardImportController +import kotlinx.coroutines.runBlocking + +/** + * ContentProvider that lets the VitalWear Companion app read and write card data into + * VBHelper's single database (internalDb). Both apps are signed with the same key, so + * the signature-level permission is automatically granted without user interaction. + * + * Usage from the companion: + * val names = call(METHOD_GET_CARD_NAMES)?.getStringArray(EXTRA_CARD_NAMES_LIST) + * call(METHOD_IMPORT_CARD, null, Bundle { putByteArray(EXTRA_CARD_BYTES, ...) }) + */ +class CardImportProvider : ContentProvider() { + + companion object { + const val AUTHORITY = "com.github.nacabaro.vbhelper.cardimport" + val URI: Uri = Uri.parse("content://$AUTHORITY") + + /** Returns a Bundle with EXTRA_CARD_NAMES_LIST containing all imported card names. */ + const val METHOD_GET_CARD_NAMES = "getCardNames" + + /** + * Parses and stores a DIM card. + * Required extras: EXTRA_CARD_BYTES (ByteArray) + * Optional extras: EXTRA_CARD_NAME (String) — user-visible label; if omitted the DIM + * file's built-in name is used. + * Returns a Bundle with RESULT_OK = true on success. + */ + const val METHOD_IMPORT_CARD = "importCard" + + const val EXTRA_CARD_BYTES = "cardBytes" + const val EXTRA_CARD_NAME = "cardName" + const val EXTRA_CARD_NAMES_LIST = "cardNames" + const val RESULT_OK = "ok" + } + + override fun onCreate(): Boolean = true + + override fun call(method: String, arg: String?, extras: Bundle?): Bundle { + val ctx = context ?: return Bundle().apply { putBoolean(RESULT_OK, false) } + val db = (ctx.applicationContext as VBHelper).container.db + + return when (method) { + + METHOD_GET_CARD_NAMES -> runCatching { + val names = db.cardDao().getAllCards().map { it.name }.toTypedArray() + Bundle().apply { putStringArray(EXTRA_CARD_NAMES_LIST, names) } + }.getOrElse { + Bundle().apply { putStringArray(EXTRA_CARD_NAMES_LIST, emptyArray()) } + } + + METHOD_IMPORT_CARD -> { + val cardBytes = extras?.getByteArray(EXTRA_CARD_BYTES) + ?: return Bundle().apply { putBoolean(RESULT_OK, false) } + val customName = extras.getString(EXTRA_CARD_NAME) + + runCatching { + runBlocking { + val dimReader = DimReader() + val peeked = cardBytes.inputStream().use { dimReader.readCard(it, false) } + val builtInName = peeked.spriteData.text + val dimId = peeked.header.dimId + + val alreadyExists = + db.cardDao().getCardByName(builtInName) != null || + db.cardDao().getCardByCardId(dimId).isNotEmpty() + + if (!alreadyExists) { + CardImportController(db).importCard(cardBytes.inputStream()) + // Rename to the user's custom label if it differs from the DIM's text + if (!customName.isNullOrBlank() && customName != builtInName) { + db.cardDao().getCardByName(builtInName)?.let { card -> + db.cardDao().renameCard(card.id.toInt(), customName) + } + } + } + } + Bundle().apply { putBoolean(RESULT_OK, true) } + }.getOrElse { + Bundle().apply { putBoolean(RESULT_OK, false) } + } + } + + else -> Bundle().apply { putBoolean(RESULT_OK, false) } + } + } + + // ---- Unused mandatory overrides ---- + override fun query(uri: Uri, projection: Array?, selection: String?, + selectionArgs: Array?, sortOrder: String?): Cursor? = null + override fun getType(uri: Uri): String? = null + override fun insert(uri: Uri, values: ContentValues?): Uri? = null + override fun delete(uri: Uri, selection: String?, selectionArgs: Array?): Int = 0 + override fun update(uri: Uri, values: ContentValues?, selection: String?, + selectionArgs: Array?): Int = 0 +} + diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/transfer/CharacterTransferPolicyResolver.kt b/app/src/main/java/com/github/nacabaro/vbhelper/transfer/CharacterTransferPolicyResolver.kt new file mode 100644 index 0000000..811885a --- /dev/null +++ b/app/src/main/java/com/github/nacabaro/vbhelper/transfer/CharacterTransferPolicyResolver.kt @@ -0,0 +1,112 @@ +package com.github.nacabaro.vbhelper.transfer + +import com.github.nacabaro.vbhelper.daos.CharacterTransferPolicyDao +import com.github.nacabaro.vbhelper.domain.device_data.CharacterTransferPolicy +import com.github.nacabaro.vbhelper.utils.DeviceType + +class CharacterTransferPolicyResolver( + private val policyDao: CharacterTransferPolicyDao, +) { + fun resolveExportFormat( + characterId: Long, + characterType: DeviceType, + transport: TransferTransport, + target: TransferTarget, + isBemCard: Boolean, + ): ExportFormat { + val policy = policyDao.getByCharacterId(characterId) + return when (transport) { + TransferTransport.HCE -> policy?.preferredHceExportFormat + ?: defaultHceExportFormat(characterType, isBemCard, target) + TransferTransport.NFCA -> policy?.preferredNfcaExportFormat + ?: defaultNfcaExportFormat(characterType, isBemCard, target) + } + } + + fun policyForNfcaImport( + characterId: Long, + observedFormat: ExportFormat, + ): CharacterTransferPolicy { + val nativeDeviceType = observedFormat.toNativeDeviceType() + return CharacterTransferPolicy( + characterId = characterId, + nativeDeviceType = nativeDeviceType, + preferredHceExportFormat = defaultHceExportFormat(nativeDeviceType, nativeDeviceType == DeviceType.BEDevice, TransferTarget.VITAL_WEAR), + preferredNfcaExportFormat = observedFormat, + lastObservedImportFormat = observedFormat, + lastTransferTransport = TransferTransport.NFCA, + lastTransferTarget = TransferTarget.REAL_BRACELET, + preserveVbRoundTrip = observedFormat == ExportFormat.VB, + preserveBeRoundTrip = observedFormat == ExportFormat.BE, + ) + } + + fun policyForHceImport( + characterId: Long, + importedCardIsBem: Boolean, + resolvedDeviceType: DeviceType, + observedFormat: ExportFormat, + ): CharacterTransferPolicy { + val nativeDeviceType = when { + importedCardIsBem -> DeviceType.BEDevice + resolvedDeviceType == DeviceType.BEDevice && observedFormat == ExportFormat.BE -> DeviceType.VBDevice + resolvedDeviceType == DeviceType.VitalWear -> DeviceType.VBDevice + else -> resolvedDeviceType + } + val preferredNfcaExportFormat = if (importedCardIsBem || nativeDeviceType == DeviceType.BEDevice) { + ExportFormat.BE + } else { + ExportFormat.VB + } + + return CharacterTransferPolicy( + characterId = characterId, + nativeDeviceType = nativeDeviceType, + preferredHceExportFormat = defaultHceExportFormat(nativeDeviceType, importedCardIsBem, TransferTarget.VITAL_WEAR), + preferredNfcaExportFormat = preferredNfcaExportFormat, + lastObservedImportFormat = observedFormat, + lastTransferTransport = TransferTransport.HCE, + lastTransferTarget = TransferTarget.VITAL_WEAR, + preserveVbRoundTrip = preferredNfcaExportFormat == ExportFormat.VB, + preserveBeRoundTrip = importedCardIsBem || observedFormat == ExportFormat.BE, + ) + } + + private fun defaultHceExportFormat( + characterType: DeviceType, + isBemCard: Boolean, + target: TransferTarget, + ): ExportFormat { + if (target != TransferTarget.VITAL_WEAR) { + return defaultNfcaExportFormat(characterType, isBemCard, target) + } + + // VitalWear is the HCE endpoint. Default to BE projection so HCE transfers can carry the + // richer payload, while NFCA policy still controls what is sent back to real bracelets. + return ExportFormat.BE + } + + private fun defaultNfcaExportFormat( + characterType: DeviceType, + isBemCard: Boolean, + target: TransferTarget, + ): ExportFormat { + require(target == TransferTarget.REAL_BRACELET) { + "NFCA exports are only supported for real bracelets." + } + return if (isBemCard || characterType == DeviceType.BEDevice) { + ExportFormat.BE + } else { + ExportFormat.VB + } + } + + private fun ExportFormat.toNativeDeviceType(): DeviceType { + return when (this) { + ExportFormat.BE -> DeviceType.BEDevice + ExportFormat.VB -> DeviceType.VBDevice + } + } +} + + diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/transfer/SharedTransferSeenProvider.kt b/app/src/main/java/com/github/nacabaro/vbhelper/transfer/SharedTransferSeenProvider.kt new file mode 100644 index 0000000..8734c22 --- /dev/null +++ b/app/src/main/java/com/github/nacabaro/vbhelper/transfer/SharedTransferSeenProvider.kt @@ -0,0 +1,67 @@ +package com.github.nacabaro.vbhelper.transfer + +import android.content.ContentProvider +import android.content.ContentValues +import android.database.Cursor +import android.net.Uri +import android.os.Bundle +import com.github.cfogrady.vitalwear.common.data.SharedDatabaseFactory + +class SharedTransferSeenProvider : ContentProvider() { + companion object { + const val AUTHORITY = "com.github.nacabaro.vbhelper.transferseen" + val URI: Uri = Uri.parse("content://$AUTHORITY") + + const val METHOD_MARK_SEEN = "markSeen" + const val EXTRA_CARD_NAME = "cardName" + const val EXTRA_SLOT_ID = "slotId" + const val EXTRA_SEEN_AT_EPOCH_MILLIS = "seenAtEpochMillis" + const val RESULT_OK = "ok" + } + + override fun onCreate(): Boolean = true + + override fun call(method: String, arg: String?, extras: Bundle?): Bundle { + if (method != METHOD_MARK_SEEN) { + return Bundle().apply { putBoolean(RESULT_OK, false) } + } + + val context = context ?: return Bundle().apply { putBoolean(RESULT_OK, false) } + val cardName = extras?.getString(EXTRA_CARD_NAME).orEmpty() + val slotId = extras?.getInt(EXTRA_SLOT_ID, -1) ?: -1 + val seenAt = extras?.getLong(EXTRA_SEEN_AT_EPOCH_MILLIS, 0L) ?: 0L + + if (cardName.isBlank() || slotId < 0 || seenAt <= 0L) { + return Bundle().apply { putBoolean(RESULT_OK, false) } + } + + return runCatching { + SharedDatabaseFactory.getDatabase(context).transferSeenDao().markSeen(cardName, slotId, seenAt) + Bundle().apply { putBoolean(RESULT_OK, true) } + }.getOrElse { + Bundle().apply { putBoolean(RESULT_OK, false) } + } + } + + override fun query( + uri: Uri, + projection: Array?, + selection: String?, + selectionArgs: Array?, + sortOrder: String? + ): Cursor? = null + + override fun getType(uri: Uri): String? = null + + override fun insert(uri: Uri, values: ContentValues?): Uri? = null + + override fun delete(uri: Uri, selection: String?, selectionArgs: Array?): Int = 0 + + override fun update( + uri: Uri, + values: ContentValues?, + selection: String?, + selectionArgs: Array? + ): Int = 0 +} + diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/transfer/TransferRoute.kt b/app/src/main/java/com/github/nacabaro/vbhelper/transfer/TransferRoute.kt new file mode 100644 index 0000000..72c47f2 --- /dev/null +++ b/app/src/main/java/com/github/nacabaro/vbhelper/transfer/TransferRoute.kt @@ -0,0 +1,31 @@ +package com.github.nacabaro.vbhelper.transfer + +import com.github.cfogrady.vitalwear.protos.Character + +/** + * Real bracelets talk over NFC-A while VitalWear uses HCE / ISO-DEP. + * Keep transport, target, and payload format separate so one canonical character can be + * projected differently depending on where it is being transferred. + */ +enum class TransferTransport { + NFCA, + HCE, +} + +enum class TransferTarget { + REAL_BRACELET, + VITAL_WEAR, +} + +enum class ExportFormat { + VB, + BE, +} + +fun ExportFormat.toTransferDeviceType(): Character.CharacterStats.TransferDeviceType { + return when (this) { + ExportFormat.VB -> Character.CharacterStats.TransferDeviceType.TRANSFER_DEVICE_TYPE_VB + ExportFormat.BE -> Character.CharacterStats.TransferDeviceType.TRANSFER_DEVICE_TYPE_BE + } +} + diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/transfer/hce/VitalWearHceReaderClient.kt b/app/src/main/java/com/github/nacabaro/vbhelper/transfer/hce/VitalWearHceReaderClient.kt new file mode 100644 index 0000000..74b6948 --- /dev/null +++ b/app/src/main/java/com/github/nacabaro/vbhelper/transfer/hce/VitalWearHceReaderClient.kt @@ -0,0 +1,437 @@ +package com.github.nacabaro.vbhelper.transfer.hce +import android.nfc.tech.IsoDep +import android.os.SystemClock +import android.util.Log +import com.github.cfogrady.vitalwear.protos.Character +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +/** + * Phone-side ISO-DEP client that drives the VitalWear HCE session. + * Mirrors the APDU protocol defined in VitalWearHceProtocol on the watch. + */ +class VitalWearHceReaderClient( + private val isoDep: IsoDep, + private val fastMode: Boolean = true, +) { + companion object { + private const val MIN_CHUNK_SIZE = 256 + private val bestChunkByDevice = linkedMapOf() + } + + init { + // Import + DB writes can make COMMIT slower than default transceive time on some devices. + isoDep.timeout = 10_000 + } + + private val AID = byteArrayOf( + 0xF0.toByte(), 0x56, 0x49, 0x54, 0x41, 0x4C, 0x57, 0x45, 0x41, 0x52 + ) + private val CLA: Byte = 0x80.toByte() + private val INS_NEGOTIATE: Byte = 0x10 + private val INS_READ_CHUNK: Byte = 0x20 + private val INS_WRITE_CHUNK: Byte = 0x30 + private val INS_COMMIT: Byte = 0x40 + private val INS_STATUS: Byte = 0x50 + private val INS_SYNC_UI: Byte = 0x60 + private val INS_VIBRATE: Byte = 0x70 + private val MODE_WATCH_TO_PHONE: Byte = 0x01 + private val MODE_PHONE_TO_WATCH: Byte = 0x02 + private val VERSION: Byte = 0x01 + private val SW_OK = 0x9000 + private val STATUS_SUCCESS: Byte = 0x04 + private val STATUS_FAILURE: Byte = 0x05 + private val STATUS_SYNCING: Byte = 0x03 + private val STATUS_ARMED_SEND: Byte = 0x01 + private val STATUS_ARMED_RECEIVE: Byte = 0x02 + private val STATUS_IDLE: Byte = 0x00 + private val CONFIRMATION_MAX_POLLS = 84 // ~21s with adaptive delay schedule + private val PREFERRED_MAX_CHUNK_SIZE = 2048 + // Fast mode is the default; ceremony APDUs are only enabled when fastMode is false. + private val ENABLE_TRANSFER_CEREMONY = !fastMode + + private data class ReadPayloadResult( + val character: Character, + val session: VitalWearHceSessionInfo, + ) + + private data class HceTransferMetrics( + val direction: String, + val payloadBytes: Int, + val negotiatedChunkBytes: Int, + val apduCountTotal: Int, + val apduCountData: Int, + val statusPollCount: Int, + val tSelectNegotiateMs: Long, + val tChunkLoopMs: Long, + val tCommitAckMs: Long, + val tConfirmMs: Long, + val tTotalMs: Long, + val result: String, + ) { + fun log() { + Log.i( + "HCE_CLIENT_METRICS", + "dir=$direction payloadBytes=$payloadBytes chunk=$negotiatedChunkBytes apduTotal=$apduCountTotal " + + "apduData=$apduCountData polls=$statusPollCount selectNegotiateMs=$tSelectNegotiateMs " + + "chunkLoopMs=$tChunkLoopMs commitAckMs=$tCommitAckMs confirmMs=$tConfirmMs totalMs=$tTotalMs result=$result" + ) + } + } + + /** MOVE READ: read character bytes first, let caller import/validate, then COMMIT only on success. + * If [onCharacterRead] returns false, COMMIT is skipped so source can remain on the watch. + */ + fun moveCharacterFromWatch(onCharacterRead: (Character) -> Boolean): Boolean { + val readResult = readCharacterFromWatchWithoutCommit() + if (!onCharacterRead(readResult.character)) { + return false + } + if (!readResult.session.canCommit()) { + error("Session does not allow COMMIT for watch->phone transfer") + } + requireOk(sendApdu(INS_COMMIT, byteArrayOf()), "COMMIT") + return true + } + + /** READ: watch has called armSend() — phone reads the character off the watch. */ + fun receiveCharacterFromWatch(): Character { + val readResult = readCharacterFromWatchWithoutCommit() + if (!readResult.session.canCommit()) { + error("Session does not allow COMMIT for watch->phone transfer") + } + requireOk(sendApdu(INS_COMMIT, byteArrayOf()), "COMMIT") + return readResult.character + } + + private fun readCharacterFromWatchWithoutCommit(): ReadPayloadResult { + val transferStartMs = SystemClock.elapsedRealtime() + var apduCount = 0 + var dataApduCount = 0 + selectAid() + apduCount++ + val session = negotiateSession(MODE_WATCH_TO_PHONE, desiredMaxChunkSize()) + apduCount++ + val negotiatedMs = SystemClock.elapsedRealtime() + check(session.isWatchToPhone()) { "NEGOTIATE direction mismatch for WATCH_TO_PHONE" } + Log.d("HCE_CLIENT", "Receiving ${session.payloadLength} bytes in chunks of ${session.maxChunkSize}") + val payload = ByteArray(session.payloadLength) + var offset = 0 + while (offset < session.payloadLength) { + val chunkResponse = sendApdu(INS_READ_CHUNK, intToBytes(offset)) + apduCount++ + dataApduCount++ + requireOk(chunkResponse, "READ_CHUNK @ $offset") + val chunkData = chunkResponse.dropLast(2).toByteArray() + chunkData.copyInto(payload, offset) + offset += chunkData.size + } + val chunksDoneMs = SystemClock.elapsedRealtime() + HceTransferMetrics( + direction = "WATCH_TO_PHONE_READ", + payloadBytes = payload.size, + negotiatedChunkBytes = session.maxChunkSize, + apduCountTotal = apduCount, + apduCountData = dataApduCount, + statusPollCount = 0, + tSelectNegotiateMs = negotiatedMs - transferStartMs, + tChunkLoopMs = chunksDoneMs - negotiatedMs, + tCommitAckMs = 0L, + tConfirmMs = 0L, + tTotalMs = chunksDoneMs - transferStartMs, + result = "read_complete", + ).log() + return ReadPayloadResult( + character = Character.parseFrom(payload), + session = session, + ) + } + + /** WRITE: watch has called armReceive() — phone pushes a character to the watch. */ + fun sendCharacterToWatch(character: Character, closeSyncUiAfterCommit: Boolean = true) { + val session = sendCharacterToWatchInternal( + character = character, + closeSyncUiAfterCommit = closeSyncUiAfterCommit, + requestedChunkSize = desiredMaxChunkSize(), + ) + rememberBestChunk(session.maxChunkSize) + } + + private fun sendCharacterToWatchInternal( + character: Character, + closeSyncUiAfterCommit: Boolean, + requestedChunkSize: Int, + ): VitalWearHceSessionInfo { + val transferStartMs = SystemClock.elapsedRealtime() + var apduCount = 0 + var dataApduCount = 0 + selectAid() + apduCount++ + if (ENABLE_TRANSFER_CEREMONY) { + // Optional toy-like ceremony; kept behind a toggle to avoid transfer overhead. + sendApdu(INS_VIBRATE, byteArrayOf()) + apduCount++ + sendApdu(INS_SYNC_UI, byteArrayOf(0x01)) + apduCount++ + } + + val payload = character.toByteArray() + val session = negotiateSession(MODE_PHONE_TO_WATCH, requestedChunkSize) + apduCount++ + val negotiatedMs = SystemClock.elapsedRealtime() + check(session.isPhoneToWatch()) { "NEGOTIATE direction mismatch for PHONE_TO_WATCH" } + Log.d("HCE_CLIENT", "Sending ${payload.size} bytes in chunks of ${session.maxChunkSize}") + var offset = 0 + while (offset < payload.size) { + val chunkEnd = (offset + session.maxChunkSize).coerceAtMost(payload.size) + val chunk = payload.copyOfRange(offset, chunkEnd) + requireOk(sendApdu(INS_WRITE_CHUNK, intToBytes(offset) + chunk), "WRITE_CHUNK @ $offset") + apduCount++ + dataApduCount++ + offset = chunkEnd + } + val chunksDoneMs = SystemClock.elapsedRealtime() + if (!session.canCommit()) { + error("Session does not allow COMMIT for phone->watch transfer") + } + val commitStartMs = SystemClock.elapsedRealtime() + requireOk(sendApdu(INS_COMMIT, byteArrayOf()), "COMMIT") + apduCount++ + val commitDoneMs = SystemClock.elapsedRealtime() + + if (ENABLE_TRANSFER_CEREMONY && closeSyncUiAfterCommit) { + sendApdu(INS_SYNC_UI, byteArrayOf(0x00)) + apduCount++ + } + HceTransferMetrics( + direction = "PHONE_TO_WATCH_WRITE", + payloadBytes = payload.size, + negotiatedChunkBytes = session.maxChunkSize, + apduCountTotal = apduCount, + apduCountData = dataApduCount, + statusPollCount = 0, + tSelectNegotiateMs = negotiatedMs - transferStartMs, + tChunkLoopMs = chunksDoneMs - negotiatedMs, + tCommitAckMs = commitDoneMs - commitStartMs, + tConfirmMs = 0L, + tTotalMs = commitDoneMs - transferStartMs, + result = "commit_acked", + ).log() + return session + } + + /** + * WRITE + confirmation polling. + * Returns true only when the watch reports import success. + */ + fun sendCharacterToWatchAndConfirm(character: Character): Boolean { + var lastError: Exception? = null + for (candidateChunk in chunkCandidates()) { + val confirmStartMs = SystemClock.elapsedRealtime() + val session = try { + sendCharacterToWatchInternal( + character = character, + closeSyncUiAfterCommit = false, + requestedChunkSize = candidateChunk, + ) + } catch (e: Exception) { + lastError = e + Log.w("HCE_CLIENT", "Chunk attempt failed before confirmation at chunk=$candidateChunk", e) + continue + } + if (!session.canPollStatus()) { + return false + } + var polls = 0 + var apduCount = 0 + var result = "timeout" + val confirmed = runBlocking { + repeat(CONFIRMATION_MAX_POLLS) { + val statusResponse = sendApdu(INS_STATUS, byteArrayOf()) + apduCount++ + polls++ + requireOk(statusResponse, "STATUS") + val statusByte = statusResponse.dropLast(2).firstOrNull() + Log.d("HCE_CLIENT", "STATUS poll[$it]: statusByte=${statusByte?.toInt()?.and(0xFF)}") + if (statusByte == STATUS_SUCCESS) { + result = "success" + return@runBlocking true + } + if (statusByte == STATUS_FAILURE) { + result = "failure" + return@runBlocking false + } + if ( + statusByte == STATUS_SYNCING || + statusByte == STATUS_ARMED_RECEIVE || + statusByte == STATUS_ARMED_SEND || + statusByte == STATUS_IDLE + ) { + delay(delayForPoll(it)) + return@repeat + } + // Unknown status byte: keep waiting briefly instead of failing early. + delay(delayForPoll(it)) + } + false + } + if (ENABLE_TRANSFER_CEREMONY) { + // End the sync UI after we have consumed terminal status. + runCatching { + sendApdu(INS_SYNC_UI, byteArrayOf(0x00)) + } + } + HceTransferMetrics( + direction = "PHONE_TO_WATCH_CONFIRM", + payloadBytes = character.serializedSize, + negotiatedChunkBytes = session.maxChunkSize, + apduCountTotal = apduCount, + apduCountData = 0, + statusPollCount = polls, + tSelectNegotiateMs = 0L, + tChunkLoopMs = 0L, + tCommitAckMs = 0L, + tConfirmMs = SystemClock.elapsedRealtime() - confirmStartMs, + tTotalMs = SystemClock.elapsedRealtime() - confirmStartMs, + result = "$result(requested=$candidateChunk)", + ).log() + if (confirmed) { + rememberBestChunk(session.maxChunkSize) + return true + } + // Explicit failure means import rejected payload; retrying chunk size won't help. + if (result == "failure") { + return false + } + } + lastError?.let { throw it } + return false + } + + /** + * Verification step for two-tap transfer UX: + * confirms the watch is armed to receive before the actual write step. + */ + fun verifyWatchReadyToReceive(): Boolean { + selectAid() + val session = negotiateSession(MODE_PHONE_TO_WATCH, desiredMaxChunkSize()) + return session.canCommit() + } + + private fun selectAid() { + val selectApdu = byteArrayOf(0x00, 0xA4.toByte(), 0x04, 0x00, AID.size.toByte()) + AID + val response = isoDep.transceive(selectApdu) + if (statusWord(response) != SW_OK) error("SELECT AID failed: ${response.toHex()}") + } + private fun sendApdu(ins: Byte, data: ByteArray): ByteArray { + val apdu = if (data.size <= 0xFF) { + byteArrayOf(CLA, ins, 0x00, 0x00, data.size.toByte()) + data + } else { + byteArrayOf( + CLA, + ins, + 0x00, + 0x00, + 0x00, + ((data.size ushr 8) and 0xFF).toByte(), + (data.size and 0xFF).toByte(), + ) + data + } + return isoDep.transceive(apdu) + } + + private fun negotiateSession(mode: Byte, requestedChunkSize: Int): VitalWearHceSessionInfo { + val negResponse = sendApdu( + INS_NEGOTIATE, + byteArrayOf(mode, VERSION) + intToShortBytes(requestedChunkSize) + ) + requireOk(negResponse, "NEGOTIATE") + val negotiatedMode = when (negResponse[1]) { + MODE_WATCH_TO_PHONE -> VitalWearHceTransferDirection.WATCH_TO_PHONE + MODE_PHONE_TO_WATCH -> VitalWearHceTransferDirection.PHONE_TO_WATCH + else -> error("NEGOTIATE returned unknown direction: ${negResponse[1]}") + } + val maxChunk = ((negResponse[2].toInt() and 0xFF) shl 8) or (negResponse[3].toInt() and 0xFF) + val payloadLen = + ((negResponse[4].toInt() and 0xFF) shl 24) or + ((negResponse[5].toInt() and 0xFF) shl 16) or + ((negResponse[6].toInt() and 0xFF) shl 8) or + (negResponse[7].toInt() and 0xFF) + return VitalWearHceSession( + direction = negotiatedMode, + maxChunkSize = maxChunk, + payloadLength = payloadLen, + ) + } + + private fun desiredMaxChunkSize(): Int { + val transceiveLimit = runCatching { isoDep.maxTransceiveLength }.getOrNull() + val maxByDevice = if (transceiveLimit != null && transceiveLimit > 0) { + // Extended APDU write overhead: 7-byte header + 4-byte offset. + (transceiveLimit - 11).coerceAtLeast(1) + } else { + PREFERRED_MAX_CHUNK_SIZE + } + val cappedByDevice = maxByDevice.coerceAtMost(PREFERRED_MAX_CHUNK_SIZE) + val cachedBest = cachedBestChunk() + return if (cachedBest == null) { + cappedByDevice + } else { + cachedBest.coerceIn(MIN_CHUNK_SIZE, cappedByDevice) + } + } + + private fun chunkCandidates(): List { + val maxChunk = desiredMaxChunkSize() + val baselineCandidates = listOf(maxChunk, 2048, 1536, 1024, 768, 512, MIN_CHUNK_SIZE) + return baselineCandidates + .map { it.coerceAtMost(maxChunk) } + .filter { it in MIN_CHUNK_SIZE..maxChunk } + .distinct() + } + + private fun deviceKey(): String { + val tagId = isoDep.tag?.id ?: return "unknown-device" + return tagId.toHex() + } + + private fun cachedBestChunk(): Int? { + synchronized(bestChunkByDevice) { + return bestChunkByDevice[deviceKey()] + } + } + + private fun rememberBestChunk(chunkSize: Int) { + synchronized(bestChunkByDevice) { + bestChunkByDevice[deviceKey()] = chunkSize + } + } + + private fun delayForPoll(pollIndex: Int): Long { + return when { + pollIndex < 20 -> 80L + pollIndex < 56 -> 175L + else -> 300L + } + } + + private fun intToShortBytes(value: Int): ByteArray = byteArrayOf( + ((value ushr 8) and 0xFF).toByte(), + (value and 0xFF).toByte() + ) + + private fun requireOk(response: ByteArray, context: String) { + if (statusWord(response) != SW_OK) error("$context failed: ${response.toHex()}") + } + private fun statusWord(response: ByteArray): Int { + if (response.size < 2) return -1 + return ((response[response.size - 2].toInt() and 0xFF) shl 8) or + (response[response.size - 1].toInt() and 0xFF) + } + private fun intToBytes(value: Int): ByteArray = byteArrayOf( + ((value ushr 24) and 0xFF).toByte(), + ((value ushr 16) and 0xFF).toByte(), + ((value ushr 8) and 0xFF).toByte(), + (value and 0xFF).toByte() + ) + private fun ByteArray.toHex() = joinToString("") { "%02X".format(it) } +} diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/transfer/hce/VitalWearHceSession.kt b/app/src/main/java/com/github/nacabaro/vbhelper/transfer/hce/VitalWearHceSession.kt new file mode 100644 index 0000000..64d9605 --- /dev/null +++ b/app/src/main/java/com/github/nacabaro/vbhelper/transfer/hce/VitalWearHceSession.kt @@ -0,0 +1,40 @@ +package com.github.nacabaro.vbhelper.transfer.hce + +internal enum class VitalWearHceTransferDirection { + WATCH_TO_PHONE, + PHONE_TO_WATCH, +} + +internal interface VitalWearHceSessionInfo { + val direction: VitalWearHceTransferDirection + val maxChunkSize: Int + val payloadLength: Int + + fun isWatchToPhone(): Boolean { + return direction == VitalWearHceTransferDirection.WATCH_TO_PHONE + } + + fun isPhoneToWatch(): Boolean { + return direction == VitalWearHceTransferDirection.PHONE_TO_WATCH + } + + fun canCommit(): Boolean { + return true + } + + fun canPollStatus(): Boolean { + // Import confirmation polling only applies for phone->watch transfers. + return isPhoneToWatch() + } +} + +internal data class VitalWearHceSession( + override val direction: VitalWearHceTransferDirection, + override val maxChunkSize: Int, + override val payloadLength: Int, +) : VitalWearHceSessionInfo + + + + + diff --git a/app/src/main/java/com/github/nacabaro/vbhelper/utils/DeviceType.kt b/app/src/main/java/com/github/nacabaro/vbhelper/utils/DeviceType.kt index 5d06c67..7912e82 100644 --- a/app/src/main/java/com/github/nacabaro/vbhelper/utils/DeviceType.kt +++ b/app/src/main/java/com/github/nacabaro/vbhelper/utils/DeviceType.kt @@ -1,6 +1,14 @@ package com.github.nacabaro.vbhelper.utils enum class DeviceType { - VBDevice, - BEDevice + VBDevice, // Bandai Vital Bracelet + BEDevice, // Bandai BE Bracelet + VitalWear // WearOS VitalWear app +} + +fun detectDeviceType(deviceName: String): DeviceType = when { + deviceName.startsWith("VitalWear", ignoreCase = true) || deviceName.startsWith("VW-", ignoreCase = true) -> DeviceType.VitalWear + deviceName.startsWith("BE-", ignoreCase = true) -> DeviceType.BEDevice + deviceName.startsWith("VB-", ignoreCase = true) -> DeviceType.VBDevice + else -> DeviceType.VBDevice // Default/fallback, can be refined } \ No newline at end of file diff --git a/app/src/main/proto/com/github/cfogrady/vitalwear/protos/Character.proto b/app/src/main/proto/com/github/cfogrady/vitalwear/protos/Character.proto new file mode 100644 index 0000000..263b4d8 --- /dev/null +++ b/app/src/main/proto/com/github/cfogrady/vitalwear/protos/Character.proto @@ -0,0 +1,51 @@ +syntax = "proto3"; + +option java_package = "com.github.cfogrady.vitalwear.protos"; +option java_multiple_files = true; + +message Character { + string card_name = 1; + int32 card_id = 2; + + message CharacterStats { + int32 slot_id = 1; + int32 vitals = 2; + int64 training_time_remaining_in_seconds = 3; + int64 time_until_next_transformation = 4; + int32 trained_bp = 5; + int32 trained_hp = 6; + int32 trained_ap = 7; + int32 trained_pp = 8; + bool injured = 9; + int32 accumulated_daily_injuries = 10; + int32 total_battles = 11; + int32 current_phase_battles = 12; + int32 total_wins = 13; + int32 current_phase_wins = 14; + int32 mood = 15; + } + CharacterStats character_stats = 3; + + message Settings { + bool training_in_background = 1; + enum AllowedBattles { + CARD_ONLY = 0; + ALL_FRANCHISE = 1; + ALL_FRANCHISE_AND_DIM = 2; + ALL = 3; + } + AllowedBattles allowed_battles = 2; + optional int32 assumed_franchise = 3; + } + Settings settings = 4; + + message TransformationEvent { + string card_name = 1; + int32 phase = 2; + int32 slot_id = 3; + } + + repeated TransformationEvent transformation_history = 5; + + map max_adventure_completed_by_card = 6; +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b5c51fd..e5221ec 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -48,8 +48,8 @@ Scan a Vital Bracelet - Vital Bracelet to App - App to Vital Bracelet + Watch to VBH + VBH to Watch No NFC on device! Tag detected is not VB @@ -71,12 +71,12 @@ Import APK - Import Secrets From Vital Arena 2.1.0 APK + Import Secrets From Vital Arena APK Import card - Import DiM/BEm card file + Import DiM/BEm Credits @@ -114,7 +114,9 @@ Designing the app icon in SVG. - + + Devout Debugger. + Place your Vital Bracelet near the reader... Cancel @@ -194,11 +196,12 @@ Delete card My characters - Nothing to see here + Storage is Empty This character is in an adventure Character image Send to watch + Send to VitalWear Set active Send on adventure Delete character @@ -226,4 +229,4 @@ Dismiss Remove - \ No newline at end of file + diff --git a/app/src/main/res/xml/provider_paths.xml b/app/src/main/res/xml/provider_paths.xml new file mode 100644 index 0000000..8a3c842 --- /dev/null +++ b/app/src/main/res/xml/provider_paths.xml @@ -0,0 +1,6 @@ + + + + diff --git a/app/src/test/java/com/github/nacabaro/vbhelper/screens/scanScreen/converters/ToNfcConverterProfileSelectionTest.kt b/app/src/test/java/com/github/nacabaro/vbhelper/screens/scanScreen/converters/ToNfcConverterProfileSelectionTest.kt new file mode 100644 index 0000000..ff4fef7 --- /dev/null +++ b/app/src/test/java/com/github/nacabaro/vbhelper/screens/scanScreen/converters/ToNfcConverterProfileSelectionTest.kt @@ -0,0 +1,25 @@ +package com.github.nacabaro.vbhelper.screens.scanScreen.converters + +import com.github.nacabaro.vbhelper.utils.DeviceType +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class ToNfcConverterProfileSelectionTest { + @Test + fun shouldEncodeAsBem_usesStoredTypeWhenForcedProfileMissing() { + assertTrue(ToNfcConverter.shouldEncodeAsBem(null, DeviceType.BEDevice)) + assertFalse(ToNfcConverter.shouldEncodeAsBem(null, DeviceType.VBDevice)) + } + + @Test + fun shouldEncodeAsBem_forcedVBWinsOverStoredBE() { + assertFalse(ToNfcConverter.shouldEncodeAsBem(DeviceType.VBDevice, DeviceType.BEDevice)) + } + + @Test + fun shouldEncodeAsBem_forcedBEWinsOverStoredVB() { + assertTrue(ToNfcConverter.shouldEncodeAsBem(DeviceType.BEDevice, DeviceType.VBDevice)) + } +} + diff --git a/app/src/test/java/com/github/nacabaro/vbhelper/source/VitalWearCharacterExporterTest.kt b/app/src/test/java/com/github/nacabaro/vbhelper/source/VitalWearCharacterExporterTest.kt new file mode 100644 index 0000000..edc09d5 --- /dev/null +++ b/app/src/test/java/com/github/nacabaro/vbhelper/source/VitalWearCharacterExporterTest.kt @@ -0,0 +1,58 @@ +package com.github.nacabaro.vbhelper.source + +import com.github.cfogrady.vbnfc.data.NfcCharacter +import com.github.nacabaro.vbhelper.domain.device_data.BECharacterData +import com.github.nacabaro.vbhelper.utils.DeviceType +import org.junit.Assert.assertEquals +import org.junit.Test + +class VitalWearCharacterExporterTest { + @Test + fun resolveTrainingSeconds_preservesBeTrainingMinutesAsSeconds() { + val beData = testBeData(remainingTrainingTimeInMinutes = 37) + + assertEquals(37L * 60L, resolveTrainingSeconds(DeviceType.BEDevice, beData)) + } + + @Test + fun resolveTrainingSeconds_defaultsNonBeCharactersToVitalWearTrainingWindow() { + assertEquals( + DEFAULT_VITALWEAR_TRAINING_TIME_SECONDS, + resolveTrainingSeconds(DeviceType.VBDevice, beData = null) + ) + } + + @Test + fun resolveTrainingSeconds_keepsZeroForBeCharactersWithoutStoredBeData() { + assertEquals(0L, resolveTrainingSeconds(DeviceType.BEDevice, beData = null)) + } + + private fun testBeData(remainingTrainingTimeInMinutes: Int): BECharacterData { + return BECharacterData( + id = 1L, + trainingHp = 0, + trainingAp = 0, + trainingBp = 0, + remainingTrainingTimeInMinutes = remainingTrainingTimeInMinutes, + itemEffectMentalStateValue = 0, + itemEffectMentalStateMinutesRemaining = 0, + itemEffectActivityLevelValue = 0, + itemEffectActivityLevelMinutesRemaining = 0, + itemEffectVitalPointsChangeValue = 0, + itemEffectVitalPointsChangeMinutesRemaining = 0, + abilityRarity = NfcCharacter.AbilityRarity.entries.first(), + abilityType = 0, + abilityBranch = 0, + abilityReset = 0, + rank = 0, + itemType = 0, + itemMultiplier = 0, + itemRemainingTime = 0, + otp0 = "", + otp1 = "", + minorVersion = 0, + majorVersion = 0, + ) + } +} + diff --git a/app/src/test/java/com/github/nacabaro/vbhelper/source/VitalWearCharacterImporterTest.kt b/app/src/test/java/com/github/nacabaro/vbhelper/source/VitalWearCharacterImporterTest.kt new file mode 100644 index 0000000..7a1f634 --- /dev/null +++ b/app/src/test/java/com/github/nacabaro/vbhelper/source/VitalWearCharacterImporterTest.kt @@ -0,0 +1,106 @@ +package com.github.nacabaro.vbhelper.source + +import com.github.cfogrady.vitalwear.protos.Character +import com.github.nacabaro.vbhelper.domain.card.Card +import com.github.nacabaro.vbhelper.utils.DeviceType +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Test + +class VitalWearCharacterImporterTest { + @Test + fun normalizeTransformationCountdownMinutes_keepsOneMinuteMinimumWhenCharacterCanStillEvolve() { + assertEquals(1, normalizeTransformationCountdownMinutes(0, hasPossibleTransformations = true)) + } + + @Test + fun normalizeTransformationCountdownMinutes_preservesPositiveCountdownWhenCharacterCanStillEvolve() { + assertEquals(12, normalizeTransformationCountdownMinutes(12, hasPossibleTransformations = true)) + } + + @Test + fun normalizeTransformationCountdownMinutes_allowsZeroWhenNoTransformationsRemain() { + assertEquals(0, normalizeTransformationCountdownMinutes(0, hasPossibleTransformations = false)) + } + + @Test + fun resolveDeviceType_prefersExplicitTransferTypeOverFallbackHeuristic() { + assertEquals( + DeviceType.VBDevice, + resolveDeviceType( + transferDeviceType = Character.CharacterStats.TransferDeviceType.TRANSFER_DEVICE_TYPE_VB, + fallbackIsBeCharacter = true, + ) + ) + } + + @Test + fun resolveDeviceType_fallsBackToBeWhenTransferTypeIsUnspecified() { + assertEquals( + DeviceType.BEDevice, + resolveDeviceType( + transferDeviceType = Character.CharacterStats.TransferDeviceType.TRANSFER_DEVICE_TYPE_UNSPECIFIED, + fallbackIsBeCharacter = true, + ) + ) + } + + @Test + fun resolveDeviceType_usesFallbackWhenTransferTypeIsUnrecognized() { + assertEquals( + DeviceType.VBDevice, + resolveDeviceType( + transferDeviceType = Character.CharacterStats.TransferDeviceType.UNRECOGNIZED, + fallbackIsBeCharacter = false, + ) + ) + } + + @Test + fun selectImportedCard_prefersUniqueCardIdWhenNameWasRenamed() { + val selected = selectImportedCard( + candidates = listOf( + testCard(id = 1, cardId = 77, name = "My Custom DIM"), + testCard(id = 2, cardId = 88, name = "Other"), + ), + incomingCardName = "Impulse City", + incomingCardId = 77, + ) + + assertNotNull(selected) + assertEquals("My Custom DIM", selected?.name) + } + + @Test + fun selectImportedCard_matchesNormalizedRenamedNameWhenCardIdMissing() { + val selected = selectImportedCard( + candidates = listOf( + testCard(id = 1, cardId = 0, name = "Impulse City!"), + ), + incomingCardName = "impulse-city", + incomingCardId = null, + ) + + assertNotNull(selected) + assertEquals("Impulse City!", selected?.name) + } + + @Test + fun cardNamesMatch_ignoresCaseAndPunctuation() { + assertEquals(true, cardNamesMatch("My DIM: Zero", "my-dim zero")) + } + + private fun testCard(id: Long, cardId: Int, name: String): Card { + return Card( + id = id, + cardId = cardId, + logo = byteArrayOf(), + logoWidth = 0, + logoHeight = 0, + name = name, + stageCount = 0, + isBEm = false, + ) + } +} +