mirror of
https://github.com/nacabaro/vbhelper.git
synced 2026-07-30 00:31:54 +00:00
This patch enables transfer between vitalwear and VBH
This commit is contained in:
parent
e787ea4a19
commit
e0a086d9aa
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -54,11 +54,6 @@
|
|||||||
<data android:scheme="http" android:host="localhost" android:port="8080" android:pathPrefix="/authenticate" />
|
<data android:scheme="http" android:host="localhost" android:port="8080" android:pathPrefix="/authenticate" />
|
||||||
<data android:scheme="http" android:host="127.0.0.1" android:port="8080" android:pathPrefix="/authenticate" />
|
<data android:scheme="http" android:host="127.0.0.1" android:port="8080" android:pathPrefix="/authenticate" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
<intent-filter>
|
|
||||||
<action android:name="android.intent.action.SEND" />
|
|
||||||
<category android:name="android.intent.category.DEFAULT" />
|
|
||||||
<data android:mimeType="application/x-vitalwear-character" />
|
|
||||||
</intent-filter>
|
|
||||||
</activity>
|
</activity>
|
||||||
</application>
|
</application>
|
||||||
|
|
||||||
|
|||||||
@ -1,16 +1,12 @@
|
|||||||
package com.github.nacabaro.vbhelper
|
package com.github.nacabaro.vbhelper
|
||||||
|
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.net.Uri
|
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import android.widget.Toast
|
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
import androidx.compose.runtime.Composable
|
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.navigation.AppNavigation
|
||||||
import com.github.nacabaro.vbhelper.di.VBHelper
|
import com.github.nacabaro.vbhelper.di.VBHelper
|
||||||
import com.github.nacabaro.vbhelper.navigation.AppNavigationHandlers
|
import com.github.nacabaro.vbhelper.navigation.AppNavigationHandlers
|
||||||
@ -22,10 +18,7 @@ import com.github.nacabaro.vbhelper.screens.adventureScreen.AdventureScreenContr
|
|||||||
import com.github.nacabaro.vbhelper.screens.cardScreen.CardScreenControllerImpl
|
import com.github.nacabaro.vbhelper.screens.cardScreen.CardScreenControllerImpl
|
||||||
import com.github.nacabaro.vbhelper.screens.spriteViewer.SpriteViewerControllerImpl
|
import com.github.nacabaro.vbhelper.screens.spriteViewer.SpriteViewerControllerImpl
|
||||||
import com.github.nacabaro.vbhelper.screens.storageScreen.StorageScreenControllerImpl
|
import com.github.nacabaro.vbhelper.screens.storageScreen.StorageScreenControllerImpl
|
||||||
import com.github.nacabaro.vbhelper.source.VitalWearCharacterImporter
|
|
||||||
import com.github.nacabaro.vbhelper.ui.theme.VBHelperTheme
|
import com.github.nacabaro.vbhelper.ui.theme.VBHelperTheme
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
|
|
||||||
|
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
@ -82,7 +75,6 @@ class MainActivity : ComponentActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Log.i("MainActivity", "Activity onCreated")
|
Log.i("MainActivity", "Activity onCreated")
|
||||||
handleImportIntent(intent)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onPause() {
|
override fun onPause() {
|
||||||
@ -105,54 +97,6 @@ class MainActivity : ComponentActivity() {
|
|||||||
super.onNewIntent(intent)
|
super.onNewIntent(intent)
|
||||||
setIntent(intent)
|
setIntent(intent)
|
||||||
initialRoute = getInitialRouteFromIntent(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? {
|
private fun getInitialRouteFromIntent(intent: Intent?): String? {
|
||||||
@ -192,8 +136,4 @@ class MainActivity : ComponentActivity() {
|
|||||||
initialRoute = initialRoute
|
initialRoute = initialRoute
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
|
||||||
private const val VITALWEAR_CHARACTER_MIME = "application/x-vitalwear-character"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -13,6 +13,9 @@ interface CardDao {
|
|||||||
@Insert(onConflict = OnConflictStrategy.IGNORE)
|
@Insert(onConflict = OnConflictStrategy.IGNORE)
|
||||||
suspend fun insertNewCard(card: Card): Long
|
suspend fun insertNewCard(card: Card): Long
|
||||||
|
|
||||||
|
@Query("SELECT * FROM Card")
|
||||||
|
fun getAllCards(): List<Card>
|
||||||
|
|
||||||
@Query("SELECT * FROM Card WHERE cardId = :id")
|
@Query("SELECT * FROM Card WHERE cardId = :id")
|
||||||
fun getCardByCardId(id: Int): List<Card>
|
fun getCardByCardId(id: Int): List<Card>
|
||||||
|
|
||||||
|
|||||||
@ -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)
|
||||||
|
}
|
||||||
|
|
||||||
@ -2,6 +2,8 @@ package com.github.nacabaro.vbhelper.database
|
|||||||
|
|
||||||
import androidx.room.Database
|
import androidx.room.Database
|
||||||
import androidx.room.RoomDatabase
|
import androidx.room.RoomDatabase
|
||||||
|
import androidx.room.migration.Migration
|
||||||
|
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||||
import com.github.nacabaro.vbhelper.daos.AdventureDao
|
import com.github.nacabaro.vbhelper.daos.AdventureDao
|
||||||
import com.github.nacabaro.vbhelper.daos.CardAdventureDao
|
import com.github.nacabaro.vbhelper.daos.CardAdventureDao
|
||||||
import com.github.nacabaro.vbhelper.daos.CharacterDao
|
import com.github.nacabaro.vbhelper.daos.CharacterDao
|
||||||
@ -13,6 +15,7 @@ import com.github.nacabaro.vbhelper.daos.ItemDao
|
|||||||
import com.github.nacabaro.vbhelper.daos.SpecialMissionDao
|
import com.github.nacabaro.vbhelper.daos.SpecialMissionDao
|
||||||
import com.github.nacabaro.vbhelper.daos.SpriteDao
|
import com.github.nacabaro.vbhelper.daos.SpriteDao
|
||||||
import com.github.nacabaro.vbhelper.daos.UserCharacterDao
|
import com.github.nacabaro.vbhelper.daos.UserCharacterDao
|
||||||
|
import com.github.nacabaro.vbhelper.daos.VitalWearSettingsDao
|
||||||
import com.github.nacabaro.vbhelper.domain.card.Background
|
import com.github.nacabaro.vbhelper.domain.card.Background
|
||||||
import com.github.nacabaro.vbhelper.domain.card.CardCharacter
|
import com.github.nacabaro.vbhelper.domain.card.CardCharacter
|
||||||
import com.github.nacabaro.vbhelper.domain.card.Card
|
import com.github.nacabaro.vbhelper.domain.card.Card
|
||||||
@ -29,10 +32,11 @@ import com.github.nacabaro.vbhelper.domain.device_data.TransformationHistory
|
|||||||
import com.github.nacabaro.vbhelper.domain.device_data.UserCharacter
|
import com.github.nacabaro.vbhelper.domain.device_data.UserCharacter
|
||||||
import com.github.nacabaro.vbhelper.domain.device_data.VBCharacterData
|
import com.github.nacabaro.vbhelper.domain.device_data.VBCharacterData
|
||||||
import com.github.nacabaro.vbhelper.domain.device_data.VitalsHistory
|
import com.github.nacabaro.vbhelper.domain.device_data.VitalsHistory
|
||||||
|
import com.github.nacabaro.vbhelper.domain.device_data.VitalWearCharacterSettings
|
||||||
import com.github.nacabaro.vbhelper.domain.items.Items
|
import com.github.nacabaro.vbhelper.domain.items.Items
|
||||||
|
|
||||||
@Database(
|
@Database(
|
||||||
version = 1,
|
version = 2,
|
||||||
entities = [
|
entities = [
|
||||||
Card::class,
|
Card::class,
|
||||||
CardProgress::class,
|
CardProgress::class,
|
||||||
@ -50,7 +54,8 @@ import com.github.nacabaro.vbhelper.domain.items.Items
|
|||||||
Items::class,
|
Items::class,
|
||||||
Adventure::class,
|
Adventure::class,
|
||||||
Background::class,
|
Background::class,
|
||||||
PossibleTransformations::class
|
PossibleTransformations::class,
|
||||||
|
VitalWearCharacterSettings::class,
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
abstract class AppDatabase : RoomDatabase() {
|
abstract class AppDatabase : RoomDatabase() {
|
||||||
@ -65,4 +70,39 @@ abstract class AppDatabase : RoomDatabase() {
|
|||||||
abstract fun specialMissionDao(): SpecialMissionDao
|
abstract fun specialMissionDao(): SpecialMissionDao
|
||||||
abstract fun cardAdventureDao(): CardAdventureDao
|
abstract fun cardAdventureDao(): CardAdventureDao
|
||||||
abstract fun cardFusionsDao(): CardFusionsDao
|
abstract fun cardFusionsDao(): CardFusionsDao
|
||||||
|
abstract fun vitalWearSettingsDao(): VitalWearSettingsDao
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
val MIGRATION_1_2 = object : Migration(1, 2) {
|
||||||
|
override fun migrate(db: SupportSQLiteDatabase) {
|
||||||
|
db.execSQL(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS `VitalWearCharacterSettings` (
|
||||||
|
`characterId` INTEGER NOT NULL,
|
||||||
|
`trainingInBackground` INTEGER NOT NULL,
|
||||||
|
`allowedBattles` INTEGER NOT NULL,
|
||||||
|
`accumulatedDailyInjuries` INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY(`characterId`)
|
||||||
|
)
|
||||||
|
""".trimIndent()
|
||||||
|
)
|
||||||
|
|
||||||
|
// Clean existing duplicates before adding the unique index.
|
||||||
|
db.execSQL(
|
||||||
|
"""
|
||||||
|
DELETE FROM `CardCharacter`
|
||||||
|
WHERE `id` NOT IN (
|
||||||
|
SELECT MIN(`id`) FROM `CardCharacter` GROUP BY `cardId`, `charaIndex`
|
||||||
|
)
|
||||||
|
""".trimIndent()
|
||||||
|
)
|
||||||
|
db.execSQL(
|
||||||
|
"""
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS `index_CardCharacter_cardId_charaIndex`
|
||||||
|
ON `CardCharacter` (`cardId`, `charaIndex`)
|
||||||
|
""".trimIndent()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@ -31,6 +31,7 @@ class DefaultAppContainer(private val context: Context) : AppContainer {
|
|||||||
"internalDb"
|
"internalDb"
|
||||||
)
|
)
|
||||||
.createFromAsset("items.db")
|
.createFromAsset("items.db")
|
||||||
|
.addMigrations(AppDatabase.MIGRATION_1_2)
|
||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -2,11 +2,15 @@ package com.github.nacabaro.vbhelper.domain.card
|
|||||||
|
|
||||||
import androidx.room.Entity
|
import androidx.room.Entity
|
||||||
import androidx.room.ForeignKey
|
import androidx.room.ForeignKey
|
||||||
|
import androidx.room.Index
|
||||||
import androidx.room.PrimaryKey
|
import androidx.room.PrimaryKey
|
||||||
import com.github.cfogrady.vbnfc.data.NfcCharacter
|
import com.github.cfogrady.vbnfc.data.NfcCharacter
|
||||||
import com.github.nacabaro.vbhelper.domain.characters.Sprite
|
import com.github.nacabaro.vbhelper.domain.characters.Sprite
|
||||||
|
|
||||||
@Entity(
|
@Entity(
|
||||||
|
indices = [
|
||||||
|
Index(value = ["cardId", "charaIndex"], unique = true)
|
||||||
|
],
|
||||||
foreignKeys = [
|
foreignKeys = [
|
||||||
ForeignKey(
|
ForeignKey(
|
||||||
entity = Card::class,
|
entity = Card::class,
|
||||||
@ -24,9 +28,8 @@ import com.github.nacabaro.vbhelper.domain.characters.Sprite
|
|||||||
)
|
)
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Character represents a character on a card. There should only be one of these per dimId
|
* Character represents a character on a specific card slot.
|
||||||
* and monIndex.
|
* Uniqueness is enforced per cardId + charaIndex.
|
||||||
* TODO: Customs will mean this should be unique per cardName and monIndex
|
|
||||||
*/
|
*/
|
||||||
data class CardCharacter (
|
data class CardCharacter (
|
||||||
@PrimaryKey(autoGenerate = true) val id: Long = 0,
|
@PrimaryKey(autoGenerate = true) val id: Long = 0,
|
||||||
|
|||||||
@ -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,
|
||||||
|
)
|
||||||
|
|
||||||
@ -41,8 +41,6 @@ import com.github.nacabaro.vbhelper.source.StorageRepository
|
|||||||
import com.github.nacabaro.vbhelper.R
|
import com.github.nacabaro.vbhelper.R
|
||||||
import com.github.nacabaro.vbhelper.dtos.CardDtos
|
import com.github.nacabaro.vbhelper.dtos.CardDtos
|
||||||
import com.github.nacabaro.vbhelper.source.CardRepository
|
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 com.github.nacabaro.vbhelper.utils.BitmapData
|
||||||
import kotlinx.coroutines.flow.flowOf
|
import kotlinx.coroutines.flow.flowOf
|
||||||
import kotlin.collections.emptyList
|
import kotlin.collections.emptyList
|
||||||
@ -178,27 +176,6 @@ fun HomeScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
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()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(16.dp)
|
|
||||||
) {
|
|
||||||
Text(text = "Send to VitalWear")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,8 @@
|
|||||||
|
package com.github.nacabaro.vbhelper.screens.scanScreen
|
||||||
|
|
||||||
|
enum class DetectedTransport {
|
||||||
|
UNKNOWN,
|
||||||
|
NFC_A,
|
||||||
|
ISO_DEP,
|
||||||
|
}
|
||||||
|
|
||||||
@ -24,6 +24,7 @@ import com.github.nacabaro.vbhelper.source.isMissingSecrets
|
|||||||
import com.github.nacabaro.vbhelper.source.proto.Secrets
|
import com.github.nacabaro.vbhelper.source.proto.Secrets
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import com.github.nacabaro.vbhelper.R
|
import com.github.nacabaro.vbhelper.R
|
||||||
|
|
||||||
@ -131,6 +132,7 @@ fun ScanScreenPreview() {
|
|||||||
navController = rememberNavController(),
|
navController = rememberNavController(),
|
||||||
scanScreenController = object: ScanScreenController {
|
scanScreenController = object: ScanScreenController {
|
||||||
override val secretsFlow = MutableStateFlow<Secrets>(Secrets.getDefaultInstance())
|
override val secretsFlow = MutableStateFlow<Secrets>(Secrets.getDefaultInstance())
|
||||||
|
override val detectedTransportFlow: StateFlow<DetectedTransport> = MutableStateFlow(DetectedTransport.UNKNOWN)
|
||||||
override fun unregisterActivityLifecycleListener(key: String) { }
|
override fun unregisterActivityLifecycleListener(key: String) { }
|
||||||
override fun registerActivityLifecycleListener(
|
override fun registerActivityLifecycleListener(
|
||||||
key: String,
|
key: String,
|
||||||
@ -141,7 +143,7 @@ fun ScanScreenPreview() {
|
|||||||
override fun flushCharacter(cardId: Long) {}
|
override fun flushCharacter(cardId: Long) {}
|
||||||
override fun onClickRead(secrets: Secrets, onComplete: ()->Unit, onMultipleCards: (List<Card>) -> Unit) {}
|
override fun onClickRead(secrets: Secrets, onComplete: ()->Unit, onMultipleCards: (List<Card>) -> Unit) {}
|
||||||
override fun onClickCheckCard(secrets: Secrets, nfcCharacter: NfcCharacter, onComplete: () -> Unit) {}
|
override fun onClickCheckCard(secrets: Secrets, nfcCharacter: NfcCharacter, onComplete: () -> Unit) {}
|
||||||
override fun onClickWrite(secrets: Secrets, nfcCharacter: NfcCharacter, onComplete: () -> Unit) {}
|
override fun onClickWrite(secrets: Secrets, nfcCharacter: NfcCharacter, onComplete: (ScanScreenController.WriteResult) -> Unit) {}
|
||||||
override fun cancelRead() {}
|
override fun cancelRead() {}
|
||||||
override fun characterFromNfc(nfcCharacter: NfcCharacter, onMultipleCards: (List<Card>, NfcCharacter) -> Unit): String { return "" }
|
override fun characterFromNfc(nfcCharacter: NfcCharacter, onMultipleCards: (List<Card>, NfcCharacter) -> Unit): String { return "" }
|
||||||
override suspend fun characterToNfc(characterId: Long): NfcCharacter? { return null }
|
override suspend fun characterToNfc(characterId: Long): NfcCharacter? { return null }
|
||||||
|
|||||||
@ -5,12 +5,21 @@ import com.github.nacabaro.vbhelper.ActivityLifecycleListener
|
|||||||
import com.github.nacabaro.vbhelper.domain.card.Card
|
import com.github.nacabaro.vbhelper.domain.card.Card
|
||||||
import com.github.nacabaro.vbhelper.source.proto.Secrets
|
import com.github.nacabaro.vbhelper.source.proto.Secrets
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
|
||||||
interface ScanScreenController {
|
interface ScanScreenController {
|
||||||
val secretsFlow: Flow<Secrets>
|
val secretsFlow: Flow<Secrets>
|
||||||
|
val detectedTransportFlow: StateFlow<DetectedTransport>
|
||||||
|
|
||||||
|
enum class WriteResult {
|
||||||
|
MOVE_CONFIRMED,
|
||||||
|
COPIED,
|
||||||
|
BLOCKED_DEVICE_FULL,
|
||||||
|
}
|
||||||
|
|
||||||
fun onClickRead(secrets: Secrets, onComplete: ()->Unit, onMultipleCards: (List<Card>) -> Unit)
|
fun onClickRead(secrets: Secrets, onComplete: ()->Unit, onMultipleCards: (List<Card>) -> Unit)
|
||||||
fun onClickCheckCard(secrets: Secrets, nfcCharacter: NfcCharacter, onComplete: () -> Unit)
|
fun onClickCheckCard(secrets: Secrets, nfcCharacter: NfcCharacter, onComplete: () -> Unit)
|
||||||
fun onClickWrite(secrets: Secrets, nfcCharacter: NfcCharacter, onComplete: () -> Unit)
|
fun onClickWrite(secrets: Secrets, nfcCharacter: NfcCharacter, onComplete: (WriteResult) -> Unit)
|
||||||
|
|
||||||
fun cancelRead()
|
fun cancelRead()
|
||||||
|
|
||||||
|
|||||||
@ -3,6 +3,7 @@ package com.github.nacabaro.vbhelper.screens.scanScreen
|
|||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.nfc.NfcAdapter
|
import android.nfc.NfcAdapter
|
||||||
import android.nfc.Tag
|
import android.nfc.Tag
|
||||||
|
import android.nfc.tech.IsoDep
|
||||||
import android.nfc.tech.NfcA
|
import android.nfc.tech.NfcA
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.provider.Settings
|
import android.provider.Settings
|
||||||
@ -16,16 +17,26 @@ import com.github.cfogrady.vbnfc.data.NfcCharacter
|
|||||||
import com.github.cfogrady.vbnfc.vb.VBNfcCharacter
|
import com.github.cfogrady.vbnfc.vb.VBNfcCharacter
|
||||||
import com.github.nacabaro.vbhelper.ActivityLifecycleListener
|
import com.github.nacabaro.vbhelper.ActivityLifecycleListener
|
||||||
import com.github.nacabaro.vbhelper.domain.card.Card
|
import com.github.nacabaro.vbhelper.domain.card.Card
|
||||||
|
import com.github.nacabaro.vbhelper.di.VBHelper
|
||||||
import com.github.nacabaro.vbhelper.screens.scanScreen.converters.FromNfcConverter
|
import com.github.nacabaro.vbhelper.screens.scanScreen.converters.FromNfcConverter
|
||||||
import com.github.nacabaro.vbhelper.screens.scanScreen.converters.ToNfcConverter
|
import com.github.nacabaro.vbhelper.screens.scanScreen.converters.ToNfcConverter
|
||||||
|
import com.github.nacabaro.vbhelper.source.VitalWearCharacterExporter
|
||||||
|
import com.github.nacabaro.vbhelper.source.VitalWearCharacterImporter
|
||||||
import com.github.nacabaro.vbhelper.source.getCryptographicTransformerMap
|
import com.github.nacabaro.vbhelper.source.getCryptographicTransformerMap
|
||||||
import com.github.nacabaro.vbhelper.source.isMissingSecrets
|
import com.github.nacabaro.vbhelper.source.isMissingSecrets
|
||||||
import com.github.nacabaro.vbhelper.source.proto.Secrets
|
import com.github.nacabaro.vbhelper.source.proto.Secrets
|
||||||
|
import com.github.nacabaro.vbhelper.transfer.hce.VitalWearHceReaderClient
|
||||||
|
import com.github.nacabaro.vbhelper.R
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import com.github.nacabaro.vbhelper.R
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import java.util.Arrays
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
|
import java.util.concurrent.atomic.AtomicLong
|
||||||
|
|
||||||
class ScanScreenControllerImpl(
|
class ScanScreenControllerImpl(
|
||||||
override val secretsFlow: Flow<Secrets>,
|
override val secretsFlow: Flow<Secrets>,
|
||||||
@ -33,49 +44,201 @@ class ScanScreenControllerImpl(
|
|||||||
private val registerActivityLifecycleListener: (String, ActivityLifecycleListener)->Unit,
|
private val registerActivityLifecycleListener: (String, ActivityLifecycleListener)->Unit,
|
||||||
private val unregisterActivityLifecycleListener: (String)->Unit,
|
private val unregisterActivityLifecycleListener: (String)->Unit,
|
||||||
): ScanScreenController {
|
): ScanScreenController {
|
||||||
|
|
||||||
|
override val detectedTransportFlow: StateFlow<DetectedTransport>
|
||||||
|
get() = _detectedTransportFlow
|
||||||
|
|
||||||
|
private val _detectedTransportFlow = MutableStateFlow(DetectedTransport.UNKNOWN)
|
||||||
private var lastScannedCharacter: NfcCharacter? = null
|
private var lastScannedCharacter: NfcCharacter? = null
|
||||||
|
private var lastRequestedCharacterId: Long? = null
|
||||||
private val nfcAdapter: NfcAdapter
|
private val nfcAdapter: NfcAdapter
|
||||||
|
private val isHandlingTag = AtomicBoolean(false)
|
||||||
|
private var lastTagId: ByteArray? = null
|
||||||
|
private var lastTagHandledAtMs: Long = 0L
|
||||||
|
private val tagStateLock = Any()
|
||||||
|
private val readerSessionCounter = AtomicLong(0L)
|
||||||
|
@Volatile private var activeReaderSessionId: Long = 0L
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG_DEBOUNCE_MS = 1500L
|
||||||
|
private val VITALWEAR_AID = byteArrayOf(
|
||||||
|
0xF0.toByte(), 0x56, 0x49, 0x54, 0x41, 0x4C, 0x57, 0x45, 0x41, 0x52
|
||||||
|
)
|
||||||
|
private const val SW_OK = 0x9000
|
||||||
|
}
|
||||||
|
|
||||||
init {
|
init {
|
||||||
val maybeNfcAdapter = NfcAdapter.getDefaultAdapter(componentActivity)
|
val maybeNfcAdapter = NfcAdapter.getDefaultAdapter(componentActivity)
|
||||||
if (maybeNfcAdapter == null) {
|
if (maybeNfcAdapter == null) {
|
||||||
Toast.makeText(componentActivity, componentActivity.getString(R.string.scan_no_nfc_on_device), Toast.LENGTH_SHORT).show()
|
Toast.makeText(componentActivity, componentActivity.getString(R.string.scan_no_nfc_on_device), Toast.LENGTH_SHORT).show()
|
||||||
}
|
}
|
||||||
nfcAdapter = maybeNfcAdapter
|
nfcAdapter = maybeNfcAdapter
|
||||||
checkSecrets()
|
checkSecrets()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onClickRead(secrets: Secrets, onComplete: ()->Unit, onMultipleCards: (List<Card>) -> Unit) {
|
// ---- Read (phone receives character FROM watch or bracelet) --------------------
|
||||||
handleTag(secrets) { tagCommunicator ->
|
|
||||||
try {
|
override fun onClickRead(secrets: Secrets, onComplete: () -> Unit, onMultipleCards: (List<Card>) -> Unit) {
|
||||||
val character = tagCommunicator.receiveCharacter()
|
_detectedTransportFlow.value = DetectedTransport.UNKNOWN
|
||||||
val resultMessage = characterFromNfc(character) { cards, nfcCharacter ->
|
handleTag(
|
||||||
lastScannedCharacter = nfcCharacter
|
secrets,
|
||||||
onMultipleCards(cards)
|
nfcAHandler = { tagCommunicator ->
|
||||||
|
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
|
isoDepHandler = { isoDep ->
|
||||||
} catch (e: Exception) {
|
val application = componentActivity.applicationContext as VBHelper
|
||||||
Log.e("NFC_READ", "Error reading character from NFC", e)
|
val importer = VitalWearCharacterImporter(application.container.db)
|
||||||
componentActivity.runOnUiThread {
|
try {
|
||||||
Toast.makeText(componentActivity, componentActivity.getString(R.string.scan_error_generic) + ": " + (e.message ?: e.javaClass.simpleName), Toast.LENGTH_LONG).show()
|
var importResult: VitalWearCharacterImporter.ImportResult? = null
|
||||||
|
val moved = VitalWearHceReaderClient(isoDep).moveCharacterFromWatch { character ->
|
||||||
|
val result = importer.importCharacter(character)
|
||||||
|
importResult = result
|
||||||
|
result.success
|
||||||
|
}
|
||||||
|
onComplete.invoke()
|
||||||
|
if (moved) {
|
||||||
|
importResult?.message ?: componentActivity.getString(R.string.scan_sent_character_success)
|
||||||
|
} else {
|
||||||
|
importResult?.message
|
||||||
|
?: "VitalWear import was rejected. Source character remains on the watch."
|
||||||
|
}
|
||||||
|
} catch (readError: Exception) {
|
||||||
|
Log.e("NFC_READ", "HCE read failed; watch may be armed as destination", readError)
|
||||||
|
onComplete.invoke()
|
||||||
|
"No source character detected on watch. If the watch is waiting to receive, use VBH to Watch."
|
||||||
}
|
}
|
||||||
onComplete.invoke()
|
|
||||||
componentActivity.getString(R.string.scan_error_generic)
|
|
||||||
}
|
}
|
||||||
}
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Write (phone sends character TO watch or bracelet) ------------------------
|
||||||
|
|
||||||
|
override fun onClickWrite(secrets: Secrets, nfcCharacter: NfcCharacter, onComplete: (ScanScreenController.WriteResult) -> Unit) {
|
||||||
|
_detectedTransportFlow.value = DetectedTransport.UNKNOWN
|
||||||
|
handleTag(
|
||||||
|
secrets,
|
||||||
|
nfcAHandler = { tagCommunicator ->
|
||||||
|
try {
|
||||||
|
val initialSlotState = readNfcASlotState(tagCommunicator)
|
||||||
|
if (initialSlotState.isFull()) {
|
||||||
|
onComplete.invoke(ScanScreenController.WriteResult.BLOCKED_DEVICE_FULL)
|
||||||
|
return@handleTag componentActivity.getString(R.string.scan_target_device_full)
|
||||||
|
}
|
||||||
|
|
||||||
|
val migrationCheck = verifyActiveToBackupMigration(tagCommunicator, initialSlotState)
|
||||||
|
if (!migrationCheck.canProceed) {
|
||||||
|
onComplete.invoke(ScanScreenController.WriteResult.COPIED)
|
||||||
|
return@handleTag migrationCheck.message
|
||||||
|
}
|
||||||
|
|
||||||
|
when (nfcCharacter) {
|
||||||
|
is VBNfcCharacter -> tagCommunicator.sendCharacter(nfcCharacter)
|
||||||
|
is BENfcCharacter -> tagCommunicator.sendCharacter(nfcCharacter)
|
||||||
|
}
|
||||||
|
onComplete.invoke(ScanScreenController.WriteResult.MOVE_CONFIRMED)
|
||||||
|
componentActivity.getString(R.string.scan_sent_character_success)
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
Log.e("TAG", e.stackTraceToString())
|
||||||
|
componentActivity.getString(R.string.scan_error_generic)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
isoDepHandler = { isoDep ->
|
||||||
|
val characterId = lastRequestedCharacterId
|
||||||
|
?: throw IllegalStateException("No character id available for VitalWear HCE write")
|
||||||
|
val application = componentActivity.applicationContext as VBHelper
|
||||||
|
val hceClient = VitalWearHceReaderClient(isoDep)
|
||||||
|
try {
|
||||||
|
val proto = runBlocking {
|
||||||
|
VitalWearCharacterExporter(application.container.db)
|
||||||
|
.buildCharacterProto(characterId)
|
||||||
|
}
|
||||||
|
hceClient.sendCharacterToWatch(proto)
|
||||||
|
onComplete.invoke(ScanScreenController.WriteResult.MOVE_CONFIRMED)
|
||||||
|
componentActivity.getString(R.string.scan_sent_character_success)
|
||||||
|
} catch (writeError: Throwable) {
|
||||||
|
Log.e("NFC_WRITE_HCE", "HCE write failed; attempting opposite direction", writeError)
|
||||||
|
runCatching {
|
||||||
|
var importResult: VitalWearCharacterImporter.ImportResult? = null
|
||||||
|
val moved = hceClient.moveCharacterFromWatch { character ->
|
||||||
|
val result = VitalWearCharacterImporter(application.container.db).importCharacter(character)
|
||||||
|
importResult = result
|
||||||
|
result.success
|
||||||
|
}
|
||||||
|
onComplete.invoke(ScanScreenController.WriteResult.COPIED)
|
||||||
|
if (moved) {
|
||||||
|
importResult?.message
|
||||||
|
?: "Detected source on watch and imported it instead of sending."
|
||||||
|
} else {
|
||||||
|
importResult?.message
|
||||||
|
?: "Watch is in source mode, but import was rejected."
|
||||||
|
}
|
||||||
|
}.getOrElse { readFallbackError ->
|
||||||
|
Log.e("NFC_WRITE_HCE", "HCE opposite-direction fallback failed", readFallbackError)
|
||||||
|
componentActivity.getString(R.string.scan_error_generic)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Check card (NFC-A only; ISO-DEP watches don't need a DIM prep) -----------
|
||||||
|
|
||||||
|
override fun onClickCheckCard(secrets: Secrets, nfcCharacter: NfcCharacter, onComplete: () -> Unit) {
|
||||||
|
_detectedTransportFlow.value = DetectedTransport.UNKNOWN
|
||||||
|
handleTag(
|
||||||
|
secrets,
|
||||||
|
nfcAHandler = { tagCommunicator ->
|
||||||
|
tagCommunicator.prepareDIMForCharacter(nfcCharacter.dimId)
|
||||||
|
onComplete.invoke()
|
||||||
|
componentActivity.getString(R.string.scan_sent_dim_success)
|
||||||
|
},
|
||||||
|
// HCE equivalent of the prep step: verify watch is armed for phone->watch transfer.
|
||||||
|
isoDepHandler = { isoDep ->
|
||||||
|
runCatching {
|
||||||
|
VitalWearHceReaderClient(isoDep).verifyWatchReadyToReceive()
|
||||||
|
}.fold(
|
||||||
|
onSuccess = {
|
||||||
|
onComplete.invoke()
|
||||||
|
"Watch ready. Tap again to send character."
|
||||||
|
},
|
||||||
|
onFailure = { error ->
|
||||||
|
Log.e("NFC_HCE", "Watch is not ready to receive character", error)
|
||||||
|
"Watch is not ready. On watch: Transfer > Receive from VBH, then tap again."
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Cancel / lifecycle -------------------------------------------------------
|
||||||
|
|
||||||
override fun cancelRead() {
|
override fun cancelRead() {
|
||||||
if(nfcAdapter.isEnabled) {
|
_detectedTransportFlow.value = DetectedTransport.UNKNOWN
|
||||||
nfcAdapter.disableReaderMode(componentActivity)
|
activeReaderSessionId = readerSessionCounter.incrementAndGet()
|
||||||
|
isHandlingTag.set(false)
|
||||||
|
synchronized(tagStateLock) {
|
||||||
|
lastTagId = null
|
||||||
|
lastTagHandledAtMs = 0L
|
||||||
}
|
}
|
||||||
|
disableReaderModeSafely()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun registerActivityLifecycleListener(
|
override fun registerActivityLifecycleListener(key: String, activityLifecycleListener: ActivityLifecycleListener) {
|
||||||
key: String,
|
|
||||||
activityLifecycleListener: ActivityLifecycleListener
|
|
||||||
) {
|
|
||||||
registerActivityLifecycleListener.invoke(key, activityLifecycleListener)
|
registerActivityLifecycleListener.invoke(key, activityLifecycleListener)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -83,43 +246,184 @@ class ScanScreenControllerImpl(
|
|||||||
unregisterActivityLifecycleListener.invoke(key)
|
unregisterActivityLifecycleListener.invoke(key)
|
||||||
}
|
}
|
||||||
|
|
||||||
// EXTRACTED DIRECTLY FROM EXAMPLE APP
|
// ---- NFC adapter wiring -------------------------------------------------------
|
||||||
private fun handleTag(secrets: Secrets, handlerFunc: (TagCommunicator)->String) {
|
|
||||||
|
/**
|
||||||
|
* Arms the NFC reader for both NFC-A (bracelet) and ISO-DEP (VitalWear HCE).
|
||||||
|
* [isoDepHandler] is optional; when null the reader only accepts NFC-A.
|
||||||
|
*/
|
||||||
|
private fun handleTag(
|
||||||
|
secrets: Secrets,
|
||||||
|
nfcAHandler: (TagCommunicator) -> String,
|
||||||
|
isoDepHandler: ((IsoDep) -> String)? = null
|
||||||
|
) {
|
||||||
if (!nfcAdapter.isEnabled) {
|
if (!nfcAdapter.isEnabled) {
|
||||||
showWirelessSettings()
|
showWirelessSettings()
|
||||||
} else {
|
return
|
||||||
val options = Bundle()
|
}
|
||||||
// Work around for some broken Nfc firmware implementations that poll the card too fast
|
val options = Bundle()
|
||||||
options.putInt(NfcAdapter.EXTRA_READER_PRESENCE_CHECK_DELAY, 250)
|
// Work around for some broken Nfc firmware implementations that poll the card too fast
|
||||||
nfcAdapter.enableReaderMode(componentActivity, buildOnReadTag(secrets, handlerFunc), NfcAdapter.FLAG_READER_NFC_A or NfcAdapter.FLAG_READER_SKIP_NDEF_CHECK,
|
options.putInt(NfcAdapter.EXTRA_READER_PRESENCE_CHECK_DELAY, 250)
|
||||||
options
|
val flags = NfcAdapter.FLAG_READER_NFC_A or
|
||||||
)
|
NfcAdapter.FLAG_READER_NFC_B or
|
||||||
|
NfcAdapter.FLAG_READER_SKIP_NDEF_CHECK
|
||||||
|
val sessionId = readerSessionCounter.incrementAndGet()
|
||||||
|
activeReaderSessionId = sessionId
|
||||||
|
isHandlingTag.set(false)
|
||||||
|
synchronized(tagStateLock) {
|
||||||
|
lastTagId = null
|
||||||
|
lastTagHandledAtMs = 0L
|
||||||
|
}
|
||||||
|
disableReaderModeSafely()
|
||||||
|
nfcAdapter.enableReaderMode(
|
||||||
|
componentActivity,
|
||||||
|
buildOnReadTag(secrets, nfcAHandler, isoDepHandler, sessionId),
|
||||||
|
flags,
|
||||||
|
options
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildOnReadTag(
|
||||||
|
secrets: Secrets,
|
||||||
|
nfcAHandler: (TagCommunicator) -> String,
|
||||||
|
isoDepHandler: ((IsoDep) -> String)?,
|
||||||
|
sessionId: Long,
|
||||||
|
): (Tag) -> Unit {
|
||||||
|
return { tag ->
|
||||||
|
if (activeReaderSessionId == sessionId) {
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
val shouldHandleTag = synchronized(tagStateLock) {
|
||||||
|
val sameRecentTag = lastTagId != null && tag.id != null && Arrays.equals(lastTagId, tag.id) &&
|
||||||
|
(now - lastTagHandledAtMs) < TAG_DEBOUNCE_MS
|
||||||
|
if (sameRecentTag || !isHandlingTag.compareAndSet(false, true)) {
|
||||||
|
false
|
||||||
|
} else {
|
||||||
|
lastTagId = tag.id?.clone()
|
||||||
|
lastTagHandledAtMs = now
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (shouldHandleTag) {
|
||||||
|
|
||||||
|
// Detect transport once per tap and lock to that route for this transfer.
|
||||||
|
val isoDep = IsoDep.get(tag)
|
||||||
|
val hasIsoDepRoute = isoDep != null && isoDepHandler != null
|
||||||
|
val hasNfcARoute = NfcA.get(tag) != null
|
||||||
|
try {
|
||||||
|
// Prefer ISO-DEP whenever present so HCE sessions do not accidentally fall through to NFC-A.
|
||||||
|
if (hasIsoDepRoute) {
|
||||||
|
val isoDepTarget = isoDep!!
|
||||||
|
val isoDepAction = isoDepHandler!!
|
||||||
|
val confirmedVitalWear = runCatching { isVitalWearHceTarget(isoDepTarget) }.getOrDefault(false)
|
||||||
|
if (!confirmedVitalWear) {
|
||||||
|
Log.w("NFC_HCE", "IsoDep tag did not confirm VitalWear AID; attempting ISO-DEP handler without NFC-A fallback")
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
_detectedTransportFlow.value = DetectedTransport.ISO_DEP
|
||||||
|
isoDepTarget.connect()
|
||||||
|
isoDepTarget.use {
|
||||||
|
val successText = isoDepAction.invoke(isoDepTarget)
|
||||||
|
componentActivity.runOnUiThread {
|
||||||
|
Toast.makeText(componentActivity, successText, Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
Log.e("NFC_HCE", "IsoDep transfer failed", e)
|
||||||
|
componentActivity.runOnUiThread {
|
||||||
|
Toast.makeText(componentActivity, componentActivity.getString(R.string.scan_error_generic), Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (hasNfcARoute) {
|
||||||
|
if (!handleNfcATag(tag, secrets, nfcAHandler)) {
|
||||||
|
componentActivity.runOnUiThread {
|
||||||
|
Toast.makeText(componentActivity, componentActivity.getString(R.string.scan_tag_not_vb), Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
_detectedTransportFlow.value = DetectedTransport.UNKNOWN
|
||||||
|
componentActivity.runOnUiThread {
|
||||||
|
Toast.makeText(componentActivity, componentActivity.getString(R.string.scan_tag_not_vb), Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (activeReaderSessionId == sessionId) {
|
||||||
|
disableReaderModeSafely()
|
||||||
|
isHandlingTag.set(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// EXTRACTED DIRECTLY FROM EXAMPLE APP
|
private fun handleNfcATag(
|
||||||
private fun buildOnReadTag(secrets: Secrets, handlerFunc: (TagCommunicator)->String): (Tag)->Unit {
|
tag: Tag,
|
||||||
return { tag->
|
secrets: Secrets,
|
||||||
val nfcData = NfcA.get(tag)
|
nfcAHandler: (TagCommunicator) -> String,
|
||||||
if (nfcData == null) {
|
): Boolean {
|
||||||
componentActivity.runOnUiThread {
|
val nfcData = NfcA.get(tag) ?: return false
|
||||||
Toast.makeText(componentActivity, componentActivity.getString(R.string.scan_tag_not_vb), Toast.LENGTH_SHORT).show()
|
_detectedTransportFlow.value = DetectedTransport.NFC_A
|
||||||
}
|
return try {
|
||||||
}
|
|
||||||
nfcData.connect()
|
nfcData.connect()
|
||||||
nfcData.use {
|
nfcData.use {
|
||||||
val tagCommunicator = TagCommunicator.getInstance(nfcData, secrets.getCryptographicTransformerMap())
|
val tagCommunicator = TagCommunicator.getInstance(nfcData, secrets.getCryptographicTransformerMap())
|
||||||
val successText = handlerFunc(tagCommunicator)
|
val successText = nfcAHandler(tagCommunicator)
|
||||||
componentActivity.runOnUiThread {
|
componentActivity.runOnUiThread {
|
||||||
Toast.makeText(componentActivity, successText, Toast.LENGTH_SHORT).show()
|
Toast.makeText(componentActivity, successText, Toast.LENGTH_SHORT).show()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
true
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
val staleTagSession = e is SecurityException && (e.message?.contains("out of date", ignoreCase = true) == true)
|
||||||
|
if (staleTagSession) {
|
||||||
|
Log.w("NFC_A", "Ignoring stale NFC-A tag session; waiting for a fresh tap", e)
|
||||||
|
componentActivity.runOnUiThread {
|
||||||
|
Toast.makeText(componentActivity, "Tag session expired. Please tap again.", Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
Log.e("NFC_A", "NfcA transfer failed", e)
|
||||||
|
componentActivity.runOnUiThread {
|
||||||
|
Toast.makeText(componentActivity, componentActivity.getString(R.string.scan_error_generic), Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun disableReaderModeSafely() {
|
||||||
|
if (!nfcAdapter.isEnabled) return
|
||||||
|
runCatching { nfcAdapter.disableReaderMode(componentActivity) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isVitalWearHceTarget(isoDep: IsoDep): Boolean {
|
||||||
|
return runCatching {
|
||||||
|
val originalTimeout = isoDep.timeout
|
||||||
|
try {
|
||||||
|
isoDep.timeout = 500
|
||||||
|
if (!isoDep.isConnected) {
|
||||||
|
isoDep.connect()
|
||||||
|
}
|
||||||
|
val selectApdu = byteArrayOf(0x00, 0xA4.toByte(), 0x04, 0x00, VITALWEAR_AID.size.toByte()) + VITALWEAR_AID
|
||||||
|
val response = isoDep.transceive(selectApdu)
|
||||||
|
statusWord(response) == SW_OK
|
||||||
|
} finally {
|
||||||
|
isoDep.timeout = originalTimeout
|
||||||
|
runCatching { if (isoDep.isConnected) isoDep.close() }
|
||||||
|
}
|
||||||
|
}.getOrDefault(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Misc ---------------------------------------------------------------------
|
||||||
|
|
||||||
private fun checkSecrets() {
|
private fun checkSecrets() {
|
||||||
componentActivity.lifecycleScope.launch(Dispatchers.IO) {
|
componentActivity.lifecycleScope.launch(Dispatchers.IO) {
|
||||||
if(secretsFlow.stateIn(componentActivity.lifecycleScope).value.isMissingSecrets()) {
|
if (secretsFlow.stateIn(componentActivity.lifecycleScope).value.isMissingSecrets()) {
|
||||||
componentActivity.runOnUiThread {
|
componentActivity.runOnUiThread {
|
||||||
Toast.makeText(componentActivity, componentActivity.getString(R.string.scan_missing_secrets), Toast.LENGTH_SHORT).show()
|
Toast.makeText(componentActivity, componentActivity.getString(R.string.scan_missing_secrets), Toast.LENGTH_SHORT).show()
|
||||||
}
|
}
|
||||||
@ -127,74 +431,153 @@ class ScanScreenControllerImpl(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onClickWrite(
|
|
||||||
secrets: Secrets,
|
|
||||||
nfcCharacter: NfcCharacter,
|
|
||||||
onComplete: () -> Unit
|
|
||||||
) {
|
|
||||||
handleTag(secrets) { tagCommunicator ->
|
|
||||||
try {
|
|
||||||
if (nfcCharacter is VBNfcCharacter) {
|
|
||||||
Log.d("SendCharacter", "VBNfcCharacter")
|
|
||||||
val castNfcCharacter: VBNfcCharacter = nfcCharacter
|
|
||||||
tagCommunicator.sendCharacter(castNfcCharacter)
|
|
||||||
} else if (nfcCharacter is BENfcCharacter) {
|
|
||||||
Log.d("SendCharacter", "BENfcCharacter")
|
|
||||||
val castNfcCharacter: BENfcCharacter = nfcCharacter
|
|
||||||
tagCommunicator.sendCharacter(castNfcCharacter)
|
|
||||||
}
|
|
||||||
onComplete.invoke()
|
|
||||||
componentActivity.getString(R.string.scan_sent_character_success)
|
|
||||||
} catch (e: Throwable) {
|
|
||||||
Log.e("TAG", e.stackTraceToString())
|
|
||||||
componentActivity.getString(R.string.scan_error_generic)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onClickCheckCard(
|
|
||||||
secrets: Secrets,
|
|
||||||
nfcCharacter: NfcCharacter,
|
|
||||||
onComplete: () -> Unit
|
|
||||||
) {
|
|
||||||
handleTag(secrets) { tagCommunicator ->
|
|
||||||
tagCommunicator.prepareDIMForCharacter(nfcCharacter.dimId)
|
|
||||||
onComplete.invoke()
|
|
||||||
componentActivity.getString(R.string.scan_sent_dim_success)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// EXTRACTED DIRECTLY FROM EXAMPLE APP
|
|
||||||
private fun showWirelessSettings() {
|
private fun showWirelessSettings() {
|
||||||
Toast.makeText(componentActivity, componentActivity.getString(R.string.scan_nfc_must_be_enabled), Toast.LENGTH_SHORT).show()
|
Toast.makeText(componentActivity, componentActivity.getString(R.string.scan_nfc_must_be_enabled), Toast.LENGTH_SHORT).show()
|
||||||
componentActivity.startActivity(Intent(Settings.ACTION_WIRELESS_SETTINGS))
|
componentActivity.startActivity(Intent(Settings.ACTION_WIRELESS_SETTINGS))
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun characterFromNfc(
|
/**
|
||||||
nfcCharacter: NfcCharacter,
|
* Best-effort, non-destructive slot-capacity check for NFC-A devices.
|
||||||
onMultipleCards: (List<Card>, NfcCharacter) -> Unit
|
*/
|
||||||
): String {
|
private fun readNfcASlotState(tagCommunicator: TagCommunicator): NfcASlotState {
|
||||||
val nfcConverter = FromNfcConverter(
|
val communicatorClass = tagCommunicator.javaClass
|
||||||
componentActivity = componentActivity
|
|
||||||
)
|
val countMethod = communicatorClass.methods.firstOrNull {
|
||||||
return nfcConverter.addCharacter(nfcCharacter, onMultipleCards)
|
it.parameterCount == 0 &&
|
||||||
|
Number::class.java.isAssignableFrom(it.returnType) &&
|
||||||
|
it.name.contains("count", ignoreCase = true) &&
|
||||||
|
it.name.contains("character", ignoreCase = true)
|
||||||
|
}
|
||||||
|
val count = if (countMethod != null) {
|
||||||
|
runCatching { (countMethod.invoke(tagCommunicator) as Number).toInt() }.getOrNull()
|
||||||
|
} else null
|
||||||
|
|
||||||
|
val activeMethod = communicatorClass.methods.firstOrNull {
|
||||||
|
it.parameterCount == 0 && it.returnType == java.lang.Boolean.TYPE &&
|
||||||
|
(it.name.contains("active", ignoreCase = true) || it.name.contains("current", ignoreCase = true)) &&
|
||||||
|
it.name.contains("character", ignoreCase = true)
|
||||||
|
}
|
||||||
|
val backupMethod = communicatorClass.methods.firstOrNull {
|
||||||
|
it.parameterCount == 0 && it.returnType == java.lang.Boolean.TYPE &&
|
||||||
|
it.name.contains("backup", ignoreCase = true) &&
|
||||||
|
it.name.contains("character", ignoreCase = true)
|
||||||
|
}
|
||||||
|
|
||||||
|
val activePresent = if (activeMethod != null) {
|
||||||
|
runCatching { activeMethod.invoke(tagCommunicator) as Boolean }.getOrNull()
|
||||||
|
} else null
|
||||||
|
val backupPresent = if (backupMethod != null) {
|
||||||
|
runCatching { backupMethod.invoke(tagCommunicator) as Boolean }.getOrNull()
|
||||||
|
} else null
|
||||||
|
|
||||||
|
if (count == null && activePresent == null && backupPresent == null) {
|
||||||
|
Log.w("NFC_A", "Unable to introspect NFC-A slot occupancy; defaulting to non-blocking write path")
|
||||||
|
}
|
||||||
|
|
||||||
|
return NfcASlotState(count = count, activePresent = activePresent, backupPresent = backupPresent)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attempts to mirror official toy behavior by moving the active character to backup.
|
||||||
|
* If the library already does this internally, this is a harmless no-op.
|
||||||
|
*/
|
||||||
|
private fun verifyActiveToBackupMigration(
|
||||||
|
tagCommunicator: TagCommunicator,
|
||||||
|
beforeState: NfcASlotState,
|
||||||
|
): SlotMigrationCheck {
|
||||||
|
val migrationResult = moveActiveToBackupIfSupported(tagCommunicator)
|
||||||
|
if (migrationResult.attempted && !migrationResult.success) {
|
||||||
|
return SlotMigrationCheck(
|
||||||
|
canProceed = false,
|
||||||
|
message = "Transfer blocked. Could not move active character to backup safely."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we could not introspect occupancy at all, keep legacy behavior and proceed.
|
||||||
|
if (!beforeState.hasOccupancySignal()) {
|
||||||
|
return SlotMigrationCheck(canProceed = true, message = "")
|
||||||
|
}
|
||||||
|
|
||||||
|
val afterState = readNfcASlotState(tagCommunicator)
|
||||||
|
val migrationVerified = afterState.backupPresent == true ||
|
||||||
|
(beforeState.count != null && afterState.count != null && afterState.count >= beforeState.count)
|
||||||
|
if (!migrationVerified) {
|
||||||
|
return SlotMigrationCheck(
|
||||||
|
canProceed = false,
|
||||||
|
message = "Transfer blocked. Backup slot could not be verified after migration."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return SlotMigrationCheck(canProceed = true, message = "")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun moveActiveToBackupIfSupported(tagCommunicator: TagCommunicator): MigrationInvokeResult {
|
||||||
|
val communicatorClass = tagCommunicator.javaClass
|
||||||
|
val candidate = communicatorClass.methods.firstOrNull {
|
||||||
|
it.parameterCount == 0 &&
|
||||||
|
(
|
||||||
|
(it.name.contains("move", ignoreCase = true) && it.name.contains("backup", ignoreCase = true)) ||
|
||||||
|
(it.name.contains("shift", ignoreCase = true) && it.name.contains("backup", ignoreCase = true)) ||
|
||||||
|
(it.name.contains("promote", ignoreCase = true) && it.name.contains("backup", ignoreCase = true))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (candidate == null) {
|
||||||
|
return MigrationInvokeResult(attempted = false, success = true)
|
||||||
|
}
|
||||||
|
|
||||||
|
return runCatching { candidate.invoke(tagCommunicator) }
|
||||||
|
.fold(
|
||||||
|
onSuccess = { MigrationInvokeResult(attempted = true, success = true) },
|
||||||
|
onFailure = {
|
||||||
|
Log.w("NFC_A", "Failed to move active character to backup", it)
|
||||||
|
MigrationInvokeResult(attempted = true, success = false)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class NfcASlotState(
|
||||||
|
val count: Int?,
|
||||||
|
val activePresent: Boolean?,
|
||||||
|
val backupPresent: Boolean?,
|
||||||
|
) {
|
||||||
|
fun hasOccupancySignal(): Boolean {
|
||||||
|
return count != null || activePresent != null || backupPresent != null
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isFull(): Boolean {
|
||||||
|
if (count != null) {
|
||||||
|
return count >= 2
|
||||||
|
}
|
||||||
|
if (activePresent != null && backupPresent != null) {
|
||||||
|
return activePresent && backupPresent
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class SlotMigrationCheck(
|
||||||
|
val canProceed: Boolean,
|
||||||
|
val message: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
private data class MigrationInvokeResult(
|
||||||
|
val attempted: Boolean,
|
||||||
|
val success: Boolean,
|
||||||
|
)
|
||||||
|
|
||||||
|
override fun characterFromNfc(nfcCharacter: NfcCharacter, onMultipleCards: (List<Card>, NfcCharacter) -> Unit): String {
|
||||||
|
return FromNfcConverter(componentActivity = componentActivity).addCharacter(nfcCharacter, onMultipleCards)
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun characterToNfc(characterId: Long): NfcCharacter {
|
override suspend fun characterToNfc(characterId: Long): NfcCharacter {
|
||||||
val nfcGenerator = ToNfcConverter(
|
lastRequestedCharacterId = characterId
|
||||||
componentActivity = componentActivity
|
val character = ToNfcConverter(componentActivity = componentActivity).characterToNfc(characterId)
|
||||||
)
|
|
||||||
|
|
||||||
val character = nfcGenerator.characterToNfc(characterId)
|
|
||||||
Log.d("CharacterType", character.toString())
|
Log.d("CharacterType", character.toString())
|
||||||
return character
|
return character
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun flushCharacter(cardId: Long) {
|
override fun flushCharacter(cardId: Long) {
|
||||||
val nfcConverter = FromNfcConverter(
|
val nfcConverter = FromNfcConverter(componentActivity = componentActivity)
|
||||||
componentActivity = componentActivity
|
|
||||||
)
|
|
||||||
|
|
||||||
componentActivity.lifecycleScope.launch(Dispatchers.IO) {
|
componentActivity.lifecycleScope.launch(Dispatchers.IO) {
|
||||||
if (lastScannedCharacter != null) {
|
if (lastScannedCharacter != null) {
|
||||||
nfcConverter.addCharacterUsingCard(lastScannedCharacter!!, cardId)
|
nfcConverter.addCharacterUsingCard(lastScannedCharacter!!, cardId)
|
||||||
|
|||||||
@ -12,9 +12,11 @@ import com.github.cfogrady.vbnfc.vb.VBNfcCharacter
|
|||||||
import com.github.nacabaro.vbhelper.database.AppDatabase
|
import com.github.nacabaro.vbhelper.database.AppDatabase
|
||||||
import com.github.nacabaro.vbhelper.di.VBHelper
|
import com.github.nacabaro.vbhelper.di.VBHelper
|
||||||
import com.github.nacabaro.vbhelper.domain.device_data.UserCharacter
|
import com.github.nacabaro.vbhelper.domain.device_data.UserCharacter
|
||||||
|
import com.github.nacabaro.vbhelper.domain.device_data.VBCharacterData
|
||||||
import com.github.nacabaro.vbhelper.dtos.CharacterDtos
|
import com.github.nacabaro.vbhelper.dtos.CharacterDtos
|
||||||
import com.github.nacabaro.vbhelper.utils.DeviceType
|
import com.github.nacabaro.vbhelper.utils.DeviceType
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.flow.firstOrNull
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
|
|
||||||
class ToNfcConverter(
|
class ToNfcConverter(
|
||||||
@ -39,7 +41,10 @@ class ToNfcConverter(
|
|||||||
.characterDao()
|
.characterDao()
|
||||||
.getCharacterInfo(userCharacter.charId)
|
.getCharacterInfo(userCharacter.charId)
|
||||||
|
|
||||||
return if (userCharacter.characterType == DeviceType.BEDevice)
|
val card = database.cardDao().getCardByCharacterIdSync(characterId)
|
||||||
|
val shouldEncodeAsBem = card?.isBEm ?: (userCharacter.characterType == DeviceType.BEDevice)
|
||||||
|
|
||||||
|
return if (shouldEncodeAsBem)
|
||||||
nfcToBENfc(characterId, characterInfo, userCharacter)
|
nfcToBENfc(characterId, characterInfo, userCharacter)
|
||||||
else
|
else
|
||||||
nfcToVBNfc(characterId, characterInfo, userCharacter)
|
nfcToVBNfc(characterId, characterInfo, userCharacter)
|
||||||
@ -55,7 +60,12 @@ class ToNfcConverter(
|
|||||||
val vbData = database
|
val vbData = database
|
||||||
.userCharacterDao()
|
.userCharacterDao()
|
||||||
.getVbData(characterId)
|
.getVbData(characterId)
|
||||||
.first()
|
.firstOrNull()
|
||||||
|
?: VBCharacterData(
|
||||||
|
id = characterId,
|
||||||
|
generation = 0,
|
||||||
|
totalTrophies = userCharacter.trophies
|
||||||
|
)
|
||||||
|
|
||||||
val paddedTransformationArray = generateTransformationHistory(characterId, 9)
|
val paddedTransformationArray = generateTransformationHistory(characterId, 9)
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
package com.github.nacabaro.vbhelper.screens.scanScreen.screens
|
package com.github.nacabaro.vbhelper.screens.scanScreen.screens
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.DisposableEffect
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
@ -15,6 +16,7 @@ import com.github.nacabaro.vbhelper.screens.scanScreen.SCAN_SCREEN_ACTIVITY_LIFE
|
|||||||
import com.github.nacabaro.vbhelper.screens.scanScreen.ScanScreenController
|
import com.github.nacabaro.vbhelper.screens.scanScreen.ScanScreenController
|
||||||
import com.github.nacabaro.vbhelper.R
|
import com.github.nacabaro.vbhelper.R
|
||||||
|
|
||||||
|
private const val TAG = "ReadingScreen"
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ReadingScreen(
|
fun ReadingScreen(
|
||||||
@ -30,55 +32,72 @@ fun ReadingScreen(
|
|||||||
var isDoneReadingCharacter by remember { mutableStateOf(false) }
|
var isDoneReadingCharacter by remember { mutableStateOf(false) }
|
||||||
var cardSelectScreen by remember { mutableStateOf(false) }
|
var cardSelectScreen by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
fun startReadIfNeeded() {
|
||||||
|
val availableSecrets = secrets
|
||||||
|
if (availableSecrets == null) {
|
||||||
|
Log.d(TAG, "startReadIfNeeded: skipped (no secrets)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!readingScreen || isDoneReadingCharacter || cardSelectScreen) {
|
||||||
|
Log.d(
|
||||||
|
TAG,
|
||||||
|
"startReadIfNeeded: skipped (readingScreen=$readingScreen, done=$isDoneReadingCharacter, cardSelect=$cardSelectScreen)"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.d(TAG, "startReadIfNeeded: arming onClickRead")
|
||||||
|
scanScreenController.onClickRead(
|
||||||
|
secrets = availableSecrets,
|
||||||
|
onComplete = {
|
||||||
|
Log.d(TAG, "onClickRead.onComplete: marking read complete")
|
||||||
|
readingScreen = false
|
||||||
|
isDoneReadingCharacter = true
|
||||||
|
},
|
||||||
|
onMultipleCards = { cards ->
|
||||||
|
Log.d(TAG, "onClickRead.onMultipleCards: showing card select (count=${cards.size})")
|
||||||
|
cardsRead = cards
|
||||||
|
readingScreen = false
|
||||||
|
cardSelectScreen = true
|
||||||
|
isDoneReadingCharacter = true
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
DisposableEffect(readingScreen) {
|
DisposableEffect(readingScreen) {
|
||||||
if(readingScreen) {
|
if(readingScreen) {
|
||||||
|
Log.d(TAG, "DisposableEffect: readingScreen=true, registering lifecycle listener")
|
||||||
scanScreenController.registerActivityLifecycleListener(
|
scanScreenController.registerActivityLifecycleListener(
|
||||||
SCAN_SCREEN_ACTIVITY_LIFECYCLE_LISTENER,
|
SCAN_SCREEN_ACTIVITY_LIFECYCLE_LISTENER,
|
||||||
object: ActivityLifecycleListener {
|
object: ActivityLifecycleListener {
|
||||||
override fun onPause() {
|
override fun onPause() {
|
||||||
|
Log.d(TAG, "lifecycle.onPause: cancelRead")
|
||||||
scanScreenController.cancelRead()
|
scanScreenController.cancelRead()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onResume() {
|
override fun onResume() {
|
||||||
scanScreenController.onClickRead(
|
Log.d(TAG, "lifecycle.onResume: attempting startReadIfNeeded")
|
||||||
secrets = secrets!!,
|
startReadIfNeeded()
|
||||||
onComplete = {
|
|
||||||
isDoneReadingCharacter = true
|
|
||||||
},
|
|
||||||
onMultipleCards = { cards ->
|
|
||||||
cardsRead = cards
|
|
||||||
readingScreen = false
|
|
||||||
cardSelectScreen = true
|
|
||||||
isDoneReadingCharacter = true
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
scanScreenController.onClickRead(
|
startReadIfNeeded()
|
||||||
secrets = secrets!!,
|
|
||||||
onComplete = {
|
|
||||||
isDoneReadingCharacter = true
|
|
||||||
},
|
|
||||||
onMultipleCards = { cards ->
|
|
||||||
cardsRead = cards
|
|
||||||
readingScreen = false
|
|
||||||
cardSelectScreen = true
|
|
||||||
isDoneReadingCharacter = true
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
onDispose {
|
onDispose {
|
||||||
if(readingScreen) {
|
if(readingScreen) {
|
||||||
|
Log.d(TAG, "DisposableEffect.onDispose: unregister listener + cancelRead")
|
||||||
scanScreenController.unregisterActivityLifecycleListener(
|
scanScreenController.unregisterActivityLifecycleListener(
|
||||||
SCAN_SCREEN_ACTIVITY_LIFECYCLE_LISTENER
|
SCAN_SCREEN_ACTIVITY_LIFECYCLE_LISTENER
|
||||||
)
|
)
|
||||||
scanScreenController.cancelRead()
|
scanScreenController.cancelRead()
|
||||||
|
} else {
|
||||||
|
Log.d(TAG, "DisposableEffect.onDispose: readingScreen=false, nothing to cancel")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isDoneReadingCharacter && !cardSelectScreen) {
|
if (isDoneReadingCharacter && !cardSelectScreen) {
|
||||||
|
Log.d(TAG, "state gate: done read without cardSelect, calling onComplete")
|
||||||
readingScreen = false
|
readingScreen = false
|
||||||
onComplete()
|
onComplete()
|
||||||
}
|
}
|
||||||
@ -86,9 +105,11 @@ fun ReadingScreen(
|
|||||||
if (!readingScreen) {
|
if (!readingScreen) {
|
||||||
ReadCharacterScreen(
|
ReadCharacterScreen(
|
||||||
onClickConfirm = {
|
onClickConfirm = {
|
||||||
|
Log.d(TAG, "ReadCharacterScreen.onClickConfirm: entering reading screen")
|
||||||
readingScreen = true
|
readingScreen = true
|
||||||
},
|
},
|
||||||
onClickCancel = {
|
onClickCancel = {
|
||||||
|
Log.d(TAG, "ReadCharacterScreen.onClickCancel: user cancelled")
|
||||||
onCancel()
|
onCancel()
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@ -96,6 +117,7 @@ fun ReadingScreen(
|
|||||||
|
|
||||||
if (readingScreen) {
|
if (readingScreen) {
|
||||||
ActionScreen(topBannerText = stringResource(R.string.reading_character_title),) {
|
ActionScreen(topBannerText = stringResource(R.string.reading_character_title),) {
|
||||||
|
Log.d(TAG, "ActionScreen.onCancel: cancelRead + onCancel")
|
||||||
readingScreen = false
|
readingScreen = false
|
||||||
scanScreenController.cancelRead()
|
scanScreenController.cancelRead()
|
||||||
onCancel()
|
onCancel()
|
||||||
@ -104,6 +126,7 @@ fun ReadingScreen(
|
|||||||
ChooseCard(
|
ChooseCard(
|
||||||
cards = cardsRead!!,
|
cards = cardsRead!!,
|
||||||
onCardSelected = { card ->
|
onCardSelected = { card ->
|
||||||
|
Log.d(TAG, "ChooseCard.onCardSelected: selected cardId=${card.id}")
|
||||||
cardSelectScreen = false
|
cardSelectScreen = false
|
||||||
scanScreenController.flushCharacter(card.id)
|
scanScreenController.flushCharacter(card.id)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -14,6 +14,7 @@ import com.github.nacabaro.vbhelper.ActivityLifecycleListener
|
|||||||
import com.github.nacabaro.vbhelper.di.VBHelper
|
import com.github.nacabaro.vbhelper.di.VBHelper
|
||||||
import com.github.nacabaro.vbhelper.screens.scanScreen.SCAN_SCREEN_ACTIVITY_LIFECYCLE_LISTENER
|
import com.github.nacabaro.vbhelper.screens.scanScreen.SCAN_SCREEN_ACTIVITY_LIFECYCLE_LISTENER
|
||||||
import com.github.nacabaro.vbhelper.screens.scanScreen.ScanScreenController
|
import com.github.nacabaro.vbhelper.screens.scanScreen.ScanScreenController
|
||||||
|
import com.github.nacabaro.vbhelper.screens.scanScreen.ScanScreenController.WriteResult
|
||||||
import com.github.nacabaro.vbhelper.source.StorageRepository
|
import com.github.nacabaro.vbhelper.source.StorageRepository
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
@ -38,6 +39,7 @@ fun WritingScreen(
|
|||||||
var writingConfirmScreen by remember { mutableStateOf(false) }
|
var writingConfirmScreen by remember { mutableStateOf(false) }
|
||||||
var isDoneSendingCard by remember { mutableStateOf(false) }
|
var isDoneSendingCard by remember { mutableStateOf(false) }
|
||||||
var isDoneWritingCharacter by remember { mutableStateOf(false) }
|
var isDoneWritingCharacter by remember { mutableStateOf(false) }
|
||||||
|
var writeResult by remember { mutableStateOf<WriteResult?>(null) }
|
||||||
|
|
||||||
DisposableEffect(writing) {
|
DisposableEffect(writing) {
|
||||||
if (writing) {
|
if (writing) {
|
||||||
@ -54,7 +56,8 @@ fun WritingScreen(
|
|||||||
isDoneSendingCard = true
|
isDoneSendingCard = true
|
||||||
}
|
}
|
||||||
} else if (!isDoneWritingCharacter) {
|
} else if (!isDoneWritingCharacter) {
|
||||||
scanScreenController.onClickWrite(secrets!!, nfcCharacter) {
|
scanScreenController.onClickWrite(secrets!!, nfcCharacter) { result ->
|
||||||
|
writeResult = result
|
||||||
isDoneWritingCharacter = true
|
isDoneWritingCharacter = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -68,7 +71,8 @@ fun WritingScreen(
|
|||||||
isDoneSendingCard = true
|
isDoneSendingCard = true
|
||||||
}
|
}
|
||||||
} else if (!isDoneWritingCharacter) {
|
} else if (!isDoneWritingCharacter) {
|
||||||
scanScreenController.onClickWrite(secrets!!, nfcCharacter) {
|
scanScreenController.onClickWrite(secrets!!, nfcCharacter) { result ->
|
||||||
|
writeResult = result
|
||||||
isDoneWritingCharacter = true
|
isDoneWritingCharacter = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -129,8 +133,9 @@ fun WritingScreen(
|
|||||||
LaunchedEffect(isDoneSendingCard, isDoneWritingCharacter) {
|
LaunchedEffect(isDoneSendingCard, isDoneWritingCharacter) {
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
if (isDoneSendingCard && isDoneWritingCharacter) {
|
if (isDoneSendingCard && isDoneWritingCharacter) {
|
||||||
storageRepository
|
if (writeResult == WriteResult.MOVE_CONFIRMED) {
|
||||||
.deleteCharacter(characterId)
|
storageRepository.deleteCharacter(characterId)
|
||||||
|
}
|
||||||
completedWriting = true
|
completedWriting = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,86 +1,68 @@
|
|||||||
package com.github.nacabaro.vbhelper.source
|
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.cfogrady.vitalwear.protos.Character
|
||||||
import com.github.nacabaro.vbhelper.database.AppDatabase
|
import com.github.nacabaro.vbhelper.database.AppDatabase
|
||||||
import com.github.nacabaro.vbhelper.utils.DeviceType
|
import com.github.nacabaro.vbhelper.utils.DeviceType
|
||||||
import kotlinx.coroutines.flow.firstOrNull
|
import kotlinx.coroutines.flow.firstOrNull
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
import java.io.File
|
|
||||||
|
|
||||||
class VitalWearCharacterExporter(
|
class VitalWearCharacterExporter(
|
||||||
private val context: Context,
|
|
||||||
private val database: AppDatabase
|
private val database: AppDatabase
|
||||||
) {
|
) {
|
||||||
fun buildShareIntent(characterId: Long): Intent {
|
/**
|
||||||
return runBlocking {
|
* Builds a Character proto from the stored character data.
|
||||||
val characterWithSprites = database.userCharacterDao().getCharacterWithSprites(characterId)
|
* Used by the HCE ISO-DEP transfer path.
|
||||||
val userCharacter = database.userCharacterDao().getCharacter(characterId)
|
*/
|
||||||
val card = database.cardDao().getCardByCharacterIdSync(characterId)
|
suspend fun buildCharacterProto(characterId: Long): Character {
|
||||||
?: error("Card not found for character $characterId")
|
val characterWithSprites = database.userCharacterDao().getCharacterWithSprites(characterId)
|
||||||
val cardProgress = database.cardProgressDao().getCardProgressSync(card.id) ?: 0
|
val userCharacter = database.userCharacterDao().getCharacter(characterId)
|
||||||
val proto = Character.newBuilder()
|
val vwSettings = database.vitalWearSettingsDao().getByCharacterId(characterId)
|
||||||
.setCardId(card.cardId)
|
val card = database.cardDao().getCardByCharacterIdSync(characterId)
|
||||||
.setCardName(card.name)
|
?: error("Card not found for character $characterId")
|
||||||
.setCharacterStats(
|
val cardProgress = database.cardProgressDao().getCardProgressSync(card.id) ?: 0
|
||||||
Character.CharacterStats.newBuilder()
|
return Character.newBuilder()
|
||||||
.setSlotId(database.userCharacterDao().getCharacterInfo(characterId).charaIndex)
|
.setCardId(card.cardId)
|
||||||
.setVitals(characterWithSprites.vitalPoints)
|
.setCardName(card.name)
|
||||||
.setTrainingTimeRemainingInSeconds(resolveTrainingSeconds(characterId, userCharacter.characterType))
|
.setCharacterStats(
|
||||||
.setTimeUntilNextTransformation(characterWithSprites.transformationCountdown.toLong() * 60L)
|
Character.CharacterStats.newBuilder()
|
||||||
.setTrainedBp(resolveTrainedBp(characterId, userCharacter.characterType))
|
.setSlotId(database.userCharacterDao().getCharacterInfo(characterId).charaIndex)
|
||||||
.setTrainedHp(resolveTrainedHp(characterId, userCharacter.characterType))
|
.setVitals(characterWithSprites.vitalPoints)
|
||||||
.setTrainedAp(resolveTrainedAp(characterId, userCharacter.characterType))
|
.setTrainingTimeRemainingInSeconds(resolveTrainingSeconds(characterId, userCharacter.characterType))
|
||||||
.setTrainedPp(characterWithSprites.trophies)
|
.setTimeUntilNextTransformation(characterWithSprites.transformationCountdown.toLong() * 60L)
|
||||||
.setInjured(characterWithSprites.injuryStatus.name.lowercase().contains("inj"))
|
.setTrainedBp(resolveTrainedBp(characterId, userCharacter.characterType))
|
||||||
.setAccumulatedDailyInjuries(0)
|
.setTrainedHp(resolveTrainedHp(characterId, userCharacter.characterType))
|
||||||
.setTotalBattles(characterWithSprites.totalBattlesWon + characterWithSprites.totalBattlesLost)
|
.setTrainedAp(resolveTrainedAp(characterId, userCharacter.characterType))
|
||||||
.setCurrentPhaseBattles(characterWithSprites.currentPhaseBattlesWon + characterWithSprites.currentPhaseBattlesLost)
|
.setTrainedPp(characterWithSprites.trophies)
|
||||||
.setTotalWins(characterWithSprites.totalBattlesWon)
|
.setInjured(characterWithSprites.injuryStatus.name.lowercase().contains("inj"))
|
||||||
.setCurrentPhaseWins(characterWithSprites.currentPhaseBattlesWon)
|
.setAccumulatedDailyInjuries(vwSettings?.accumulatedDailyInjuries ?: 0)
|
||||||
.setMood(characterWithSprites.mood)
|
.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(vwSettings?.trainingInBackground ?: false)
|
||||||
|
.setAllowedBattles(resolveAllowedBattles(vwSettings?.allowedBattles))
|
||||||
|
.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()
|
||||||
)
|
}
|
||||||
.setSettings(
|
)
|
||||||
Character.Settings.newBuilder()
|
.build()
|
||||||
.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 {
|
private fun resolveTrainingSeconds(characterId: Long, deviceType: DeviceType): Long {
|
||||||
if (deviceType != DeviceType.BEDevice) {
|
if (deviceType != DeviceType.BEDevice) return 0L
|
||||||
return 0L
|
|
||||||
}
|
|
||||||
return database.userCharacterDao().getBeData(characterId).valueOrNull()?.remainingTrainingTimeInMinutes?.toLong()?.times(60L) ?: 0L
|
return database.userCharacterDao().getBeData(characterId).valueOrNull()?.remainingTrainingTimeInMinutes?.toLong()?.times(60L) ?: 0L
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -103,12 +85,13 @@ class VitalWearCharacterExporter(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun <T> kotlinx.coroutines.flow.Flow<T>.valueOrNull(): T? {
|
private fun <T> kotlinx.coroutines.flow.Flow<T>.valueOrNull(): T? {
|
||||||
return runBlocking {
|
return runBlocking { firstOrNull() }
|
||||||
firstOrNull()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
private fun resolveAllowedBattles(rawValue: Int?): Character.Settings.AllowedBattles {
|
||||||
const val VITALWEAR_CHARACTER_MIME = "application/x-vitalwear-character"
|
if (rawValue == null) return Character.Settings.AllowedBattles.CARD_ONLY
|
||||||
|
return Character.Settings.AllowedBattles.forNumber(rawValue)
|
||||||
|
?: Character.Settings.AllowedBattles.CARD_ONLY
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,9 +3,13 @@ package com.github.nacabaro.vbhelper.source
|
|||||||
import com.github.cfogrady.vbnfc.data.NfcCharacter
|
import com.github.cfogrady.vbnfc.data.NfcCharacter
|
||||||
import com.github.cfogrady.vitalwear.protos.Character
|
import com.github.cfogrady.vitalwear.protos.Character
|
||||||
import com.github.nacabaro.vbhelper.database.AppDatabase
|
import com.github.nacabaro.vbhelper.database.AppDatabase
|
||||||
|
import com.github.nacabaro.vbhelper.domain.card.Card
|
||||||
import com.github.nacabaro.vbhelper.domain.device_data.BECharacterData
|
import com.github.nacabaro.vbhelper.domain.device_data.BECharacterData
|
||||||
import com.github.nacabaro.vbhelper.domain.device_data.UserCharacter
|
import com.github.nacabaro.vbhelper.domain.device_data.UserCharacter
|
||||||
|
import com.github.nacabaro.vbhelper.domain.device_data.VBCharacterData
|
||||||
|
import com.github.nacabaro.vbhelper.domain.device_data.VitalWearCharacterSettings
|
||||||
import com.github.nacabaro.vbhelper.utils.DeviceType
|
import com.github.nacabaro.vbhelper.utils.DeviceType
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
import kotlin.math.max
|
import kotlin.math.max
|
||||||
|
|
||||||
class VitalWearCharacterImporter(
|
class VitalWearCharacterImporter(
|
||||||
@ -37,6 +41,7 @@ class VitalWearCharacterImporter(
|
|||||||
val totalWins = character.characterStats.totalWins.coerceAtMost(totalBattles)
|
val totalWins = character.characterStats.totalWins.coerceAtMost(totalBattles)
|
||||||
val currentPhaseBattles = character.characterStats.currentPhaseBattles
|
val currentPhaseBattles = character.characterStats.currentPhaseBattles
|
||||||
val currentPhaseWins = character.characterStats.currentPhaseWins.coerceAtMost(currentPhaseBattles)
|
val currentPhaseWins = character.characterStats.currentPhaseWins.coerceAtMost(currentPhaseBattles)
|
||||||
|
val deviceType = if (importedCard.isBEm) DeviceType.BEDevice else DeviceType.VBDevice
|
||||||
|
|
||||||
val userCharacterId = database.userCharacterDao().insertCharacterData(
|
val userCharacterId = database.userCharacterDao().insertCharacterData(
|
||||||
UserCharacter(
|
UserCharacter(
|
||||||
@ -53,44 +58,70 @@ class VitalWearCharacterImporter(
|
|||||||
totalBattlesLost = (totalBattles - totalWins).coerceAtLeast(0),
|
totalBattlesLost = (totalBattles - totalWins).coerceAtLeast(0),
|
||||||
activityLevel = 0,
|
activityLevel = 0,
|
||||||
heartRateCurrent = 0,
|
heartRateCurrent = 0,
|
||||||
characterType = DeviceType.BEDevice,
|
characterType = deviceType,
|
||||||
isActive = true
|
isActive = true
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
database.userCharacterDao().insertBECharacterData(
|
runBlocking {
|
||||||
BECharacterData(
|
database.vitalWearSettingsDao().upsert(
|
||||||
id = userCharacterId,
|
VitalWearCharacterSettings(
|
||||||
trainingHp = character.characterStats.trainedHp,
|
characterId = userCharacterId,
|
||||||
trainingAp = character.characterStats.trainedAp,
|
trainingInBackground = character.settings.trainingInBackground,
|
||||||
trainingBp = character.characterStats.trainedBp,
|
allowedBattles = character.settings.allowedBattles.number,
|
||||||
remainingTrainingTimeInMinutes = secondsToMinutes(character.characterStats.trainingTimeRemainingInSeconds),
|
accumulatedDailyInjuries = character.characterStats.accumulatedDailyInjuries,
|
||||||
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
|
|
||||||
)
|
)
|
||||||
)
|
}
|
||||||
|
|
||||||
|
if (importedCard.isBEm) {
|
||||||
|
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
|
||||||
|
)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
database.userCharacterDao().insertVBCharacterData(
|
||||||
|
VBCharacterData(
|
||||||
|
id = userCharacterId,
|
||||||
|
generation = max(character.transformationHistoryCount - 1, 0),
|
||||||
|
totalTrophies = character.characterStats.trainedPp.coerceAtLeast(0)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
val now = System.currentTimeMillis()
|
val now = System.currentTimeMillis()
|
||||||
database.dexDao().insertCharacter(slotId, importedCard.id, now)
|
database.dexDao().insertCharacter(slotId, importedCard.id, now)
|
||||||
|
|
||||||
for (transformation in character.transformationHistoryList) {
|
for (transformation in character.transformationHistoryList) {
|
||||||
val transformationCard = resolveCard(transformation.cardName, character.cardId)
|
val transformationCard = resolveRelatedCard(
|
||||||
|
incomingCardName = transformation.cardName,
|
||||||
|
incomingCardId = character.cardId,
|
||||||
|
matchedRootCard = importedCard,
|
||||||
|
incomingRootCardName = character.cardName,
|
||||||
|
)
|
||||||
if (transformationCard != null) {
|
if (transformationCard != null) {
|
||||||
database.userCharacterDao().insertTransformation(
|
database.userCharacterDao().insertTransformation(
|
||||||
userCharacterId,
|
userCharacterId,
|
||||||
@ -103,7 +134,12 @@ class VitalWearCharacterImporter(
|
|||||||
}
|
}
|
||||||
|
|
||||||
for ((cardName, maxAdventureCompleted) in character.maxAdventureCompletedByCardMap) {
|
for ((cardName, maxAdventureCompleted) in character.maxAdventureCompletedByCardMap) {
|
||||||
val adventureCard = resolveCard(cardName, null) ?: continue
|
val adventureCard = resolveRelatedCard(
|
||||||
|
incomingCardName = cardName,
|
||||||
|
incomingCardId = null,
|
||||||
|
matchedRootCard = importedCard,
|
||||||
|
incomingRootCardName = character.cardName,
|
||||||
|
) ?: continue
|
||||||
val currentStage = (maxAdventureCompleted + 1).coerceAtLeast(0)
|
val currentStage = (maxAdventureCompleted + 1).coerceAtLeast(0)
|
||||||
database.cardProgressDao().updateCardProgress(
|
database.cardProgressDao().updateCardProgress(
|
||||||
currentStage = currentStage,
|
currentStage = currentStage,
|
||||||
@ -120,19 +156,33 @@ class VitalWearCharacterImporter(
|
|||||||
|
|
||||||
private fun resolveCard(character: Character) = resolveCard(character.cardName, character.cardId)
|
private fun resolveCard(character: Character) = resolveCard(character.cardName, character.cardId)
|
||||||
|
|
||||||
private fun resolveCard(cardName: String?, cardId: Int?): com.github.nacabaro.vbhelper.domain.card.Card? {
|
private fun resolveCard(cardName: String?, cardId: Int?): Card? {
|
||||||
if (!cardName.isNullOrBlank()) {
|
return selectImportedCard(
|
||||||
database.cardDao().getCardByName(cardName)?.let { return it }
|
candidates = database.cardDao().getAllCards(),
|
||||||
}
|
incomingCardName = cardName,
|
||||||
|
incomingCardId = cardId,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (cardId != null) {
|
private fun resolveRelatedCard(
|
||||||
val matches = database.cardDao().getCardByCardId(cardId)
|
incomingCardName: String?,
|
||||||
if (matches.size == 1) {
|
incomingCardId: Int?,
|
||||||
return matches.first()
|
matchedRootCard: Card,
|
||||||
|
incomingRootCardName: String,
|
||||||
|
): Card? {
|
||||||
|
if (incomingCardName.isNullOrBlank()) {
|
||||||
|
return if (incomingCardId != null && incomingCardId > 0 && matchedRootCard.cardId == incomingCardId) {
|
||||||
|
matchedRootCard
|
||||||
|
} else {
|
||||||
|
null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return null
|
if (cardNamesMatch(incomingCardName, incomingRootCardName)) {
|
||||||
|
return matchedRootCard
|
||||||
|
}
|
||||||
|
|
||||||
|
return resolveCard(incomingCardName, incomingCardId)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun secondsToMinutes(seconds: Long): Int {
|
private fun secondsToMinutes(seconds: Long): Int {
|
||||||
@ -161,3 +211,55 @@ class VitalWearCharacterImporter(
|
|||||||
return enumValues<NfcCharacter.AbilityRarity>().first()
|
return enumValues<NfcCharacter.AbilityRarity>().first()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal fun selectImportedCard(
|
||||||
|
candidates: List<Card>,
|
||||||
|
incomingCardName: String?,
|
||||||
|
incomingCardId: Int?,
|
||||||
|
): Card? {
|
||||||
|
val requestedName = incomingCardName?.takeIf { it.isNotBlank() }
|
||||||
|
val idMatches = if (incomingCardId != null && incomingCardId > 0) {
|
||||||
|
candidates.filter { it.cardId == incomingCardId }
|
||||||
|
} else {
|
||||||
|
emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
|
requestedName?.let { requestedNameValue ->
|
||||||
|
candidates.firstOrNull {
|
||||||
|
it.name == requestedNameValue && (incomingCardId == null || incomingCardId <= 0 || it.cardId == incomingCardId)
|
||||||
|
}?.let { return it }
|
||||||
|
|
||||||
|
val ignoreCaseMatches = candidates.filter {
|
||||||
|
it.name.equals(requestedNameValue, ignoreCase = true) &&
|
||||||
|
(incomingCardId == null || incomingCardId <= 0 || it.cardId == incomingCardId)
|
||||||
|
}
|
||||||
|
if (ignoreCaseMatches.size == 1) {
|
||||||
|
return ignoreCaseMatches.single()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (idMatches.size == 1) {
|
||||||
|
return idMatches.single()
|
||||||
|
}
|
||||||
|
|
||||||
|
requestedName?.let { requestedNameValue ->
|
||||||
|
val normalizedMatches = candidates.filter {
|
||||||
|
cardNamesMatch(it.name, requestedNameValue) &&
|
||||||
|
(incomingCardId == null || incomingCardId <= 0 || it.cardId == incomingCardId)
|
||||||
|
}
|
||||||
|
if (normalizedMatches.size == 1) {
|
||||||
|
return normalizedMatches.single()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun cardNamesMatch(left: String, right: String): Boolean {
|
||||||
|
return left.equals(right, ignoreCase = true) || left.toNormalizedCardLookupKey() == right.toNormalizedCardLookupKey()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun String.toNormalizedCardLookupKey(): String {
|
||||||
|
return lowercase().filter { it.isLetterOrDigit() }
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@ -56,6 +56,8 @@
|
|||||||
<string name="scan_error_generic">Ops!</string>
|
<string name="scan_error_generic">Ops!</string>
|
||||||
<string name="scan_sent_dim_success">DIM enviado com sucesso!</string>
|
<string name="scan_sent_dim_success">DIM enviado com sucesso!</string>
|
||||||
<string name="scan_nfc_must_be_enabled">O NFC precisa estar ativado.</string>
|
<string name="scan_nfc_must_be_enabled">O NFC precisa estar ativado.</string>
|
||||||
|
<string name="scan_target_device_full">O dispositivo de destino está cheio (ativo + backup). Remova um personagem antes de enviar.</string>
|
||||||
|
<string name="scan_transfer_skipped_storage_state">Transferência ignorada. O envio só é permitido quando o brinquedo tem um personagem ativo e o slot de backup está vazio.</string>
|
||||||
|
|
||||||
<string name="settings_title">Configurações</string>
|
<string name="settings_title">Configurações</string>
|
||||||
|
|
||||||
|
|||||||
@ -60,6 +60,8 @@
|
|||||||
<string name="scan_error_generic">Whoops</string>
|
<string name="scan_error_generic">Whoops</string>
|
||||||
<string name="scan_sent_dim_success">Sent DIM successfully!</string>
|
<string name="scan_sent_dim_success">Sent DIM successfully!</string>
|
||||||
<string name="scan_nfc_must_be_enabled">NFC must be enabled</string>
|
<string name="scan_nfc_must_be_enabled">NFC must be enabled</string>
|
||||||
|
<string name="scan_target_device_full">Target device is full (active + backup). Remove one character before sending.</string>
|
||||||
|
<string name="scan_transfer_skipped_storage_state">Transfer skipped. Send is allowed only when the toy has an active character and an empty backup slot.</string>
|
||||||
|
|
||||||
|
|
||||||
<string name="settings_title">Settings</string>
|
<string name="settings_title">Settings</string>
|
||||||
|
|||||||
@ -0,0 +1,56 @@
|
|||||||
|
package com.github.nacabaro.vbhelper.source
|
||||||
|
|
||||||
|
import com.github.nacabaro.vbhelper.domain.card.Card
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNotNull
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class VitalWearCharacterImporterTest {
|
||||||
|
@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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
File diff suppressed because one or more lines are too long
Loading…
x
Reference in New Issue
Block a user