mirror of
https://github.com/nacabaro/vbhelper.git
synced 2026-07-30 00:31:54 +00:00
Compare commits
17 Commits
af27fc4933
...
c67dc4bd02
| Author | SHA1 | Date | |
|---|---|---|---|
| c67dc4bd02 | |||
| 8aa31e819b | |||
|
|
bd8883e5d1 | ||
|
|
b094d6ccd3 | ||
|
|
6c257b2080 | ||
|
|
04ea584c4f | ||
|
|
1a83527590 | ||
|
|
431be5faea | ||
|
|
9aa62f0d81 | ||
|
|
8fd4f6c414 | ||
|
|
f829efae68 | ||
|
|
895b704c8d | ||
|
|
d40428ccfd | ||
|
|
cd9a6d5d80 | ||
|
|
e0a086d9aa | ||
|
|
e787ea4a19 | ||
|
|
5273bae7b6 |
1782
app/lint-baseline.xml
Normal file
1782
app/lint-baseline.xml
Normal file
File diff suppressed because it is too large
Load Diff
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -22,6 +22,15 @@
|
|||||||
android:theme="@style/Theme.VBHelper"
|
android:theme="@style/Theme.VBHelper"
|
||||||
android:usesCleartextTraffic="true"
|
android:usesCleartextTraffic="true"
|
||||||
tools:targetApi="31">
|
tools:targetApi="31">
|
||||||
|
<provider
|
||||||
|
android:name="androidx.core.content.FileProvider"
|
||||||
|
android:authorities="${applicationId}.provider"
|
||||||
|
android:exported="false"
|
||||||
|
android:grantUriPermissions="true">
|
||||||
|
<meta-data
|
||||||
|
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||||
|
android:resource="@xml/provider_paths" />
|
||||||
|
</provider>
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
@ -45,6 +54,11 @@
|
|||||||
<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>
|
||||||
|
|
||||||
|
|||||||
@ -0,0 +1,22 @@
|
|||||||
|
package com.github.cfogrady.vitalwear.common.data
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.room.Room
|
||||||
|
|
||||||
|
object SharedDatabaseFactory {
|
||||||
|
private const val DATABASE_NAME = "shared_transfer.db"
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var instance: SharedTransferDatabase? = null
|
||||||
|
|
||||||
|
fun getDatabase(context: Context): SharedTransferDatabase {
|
||||||
|
return instance ?: synchronized(this) {
|
||||||
|
instance ?: Room.databaseBuilder(
|
||||||
|
context.applicationContext,
|
||||||
|
SharedTransferDatabase::class.java,
|
||||||
|
DATABASE_NAME
|
||||||
|
).build().also { instance = it }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@ -0,0 +1,16 @@
|
|||||||
|
package com.github.cfogrady.vitalwear.common.data
|
||||||
|
|
||||||
|
import androidx.room.Database
|
||||||
|
import androidx.room.RoomDatabase
|
||||||
|
|
||||||
|
@Database(
|
||||||
|
entities = [
|
||||||
|
SharedTransferSeenEntity::class,
|
||||||
|
],
|
||||||
|
version = 1,
|
||||||
|
exportSchema = false,
|
||||||
|
)
|
||||||
|
abstract class SharedTransferDatabase : RoomDatabase() {
|
||||||
|
abstract fun transferSeenDao(): SharedTransferSeenDao
|
||||||
|
}
|
||||||
|
|
||||||
@ -0,0 +1,47 @@
|
|||||||
|
package com.github.cfogrady.vitalwear.common.data
|
||||||
|
|
||||||
|
import androidx.room.Dao
|
||||||
|
import androidx.room.Insert
|
||||||
|
import androidx.room.OnConflictStrategy
|
||||||
|
import androidx.room.Query
|
||||||
|
import androidx.room.Transaction
|
||||||
|
|
||||||
|
@Dao
|
||||||
|
interface SharedTransferSeenDao {
|
||||||
|
@Insert(onConflict = OnConflictStrategy.IGNORE)
|
||||||
|
fun insert(entry: SharedTransferSeenEntity): Long
|
||||||
|
|
||||||
|
@Query(
|
||||||
|
"""
|
||||||
|
UPDATE ${SharedTransferSeenEntity.TABLE}
|
||||||
|
SET cardName = :cardName,
|
||||||
|
seenAtEpochMillis = :seenAtEpochMillis
|
||||||
|
WHERE cardLookupKey = :cardLookupKey
|
||||||
|
AND slotId = :slotId
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
fun update(cardName: String, cardLookupKey: String, slotId: Int, seenAtEpochMillis: Long)
|
||||||
|
|
||||||
|
@Transaction
|
||||||
|
fun markSeen(cardName: String, slotId: Int, seenAtEpochMillis: Long) {
|
||||||
|
if (slotId < 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val cardLookupKey = cardName.lowercase().filter { it.isLetterOrDigit() }
|
||||||
|
if (cardLookupKey.isBlank()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val inserted = insert(
|
||||||
|
SharedTransferSeenEntity(
|
||||||
|
cardName = cardName,
|
||||||
|
cardLookupKey = cardLookupKey,
|
||||||
|
slotId = slotId,
|
||||||
|
seenAtEpochMillis = seenAtEpochMillis,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if (inserted == -1L) {
|
||||||
|
update(cardName, cardLookupKey, slotId, seenAtEpochMillis)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@ -0,0 +1,24 @@
|
|||||||
|
package com.github.cfogrady.vitalwear.common.data
|
||||||
|
|
||||||
|
import androidx.room.Entity
|
||||||
|
import androidx.room.Index
|
||||||
|
import androidx.room.PrimaryKey
|
||||||
|
|
||||||
|
@Entity(
|
||||||
|
tableName = SharedTransferSeenEntity.TABLE,
|
||||||
|
indices = [
|
||||||
|
Index(value = ["cardLookupKey", "slotId"], unique = true)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
data class SharedTransferSeenEntity(
|
||||||
|
@PrimaryKey(autoGenerate = true) val id: Long = 0,
|
||||||
|
val cardName: String,
|
||||||
|
val cardLookupKey: String,
|
||||||
|
val slotId: Int,
|
||||||
|
val seenAtEpochMillis: Long,
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
const val TABLE = "TransferSeen"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@ -1,11 +1,16 @@
|
|||||||
package com.github.nacabaro.vbhelper
|
package com.github.nacabaro.vbhelper
|
||||||
|
|
||||||
|
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
|
||||||
@ -17,11 +22,15 @@ import com.github.nacabaro.vbhelper.screens.adventureScreen.AdventureScreenContr
|
|||||||
import com.github.nacabaro.vbhelper.screens.cardScreen.CardScreenControllerImpl
|
import com.github.nacabaro.vbhelper.screens.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() {
|
||||||
private val onActivityLifecycleListeners = HashMap<String, ActivityLifecycleListener>()
|
private val onActivityLifecycleListeners = HashMap<String, ActivityLifecycleListener>()
|
||||||
|
private var initialRoute: String? = null
|
||||||
|
|
||||||
private fun registerActivityLifecycleListener(key: String, activityLifecycleListener: ActivityLifecycleListener) {
|
private fun registerActivityLifecycleListener(key: String, activityLifecycleListener: ActivityLifecycleListener) {
|
||||||
if( onActivityLifecycleListeners[key] != null) {
|
if( onActivityLifecycleListeners[key] != null) {
|
||||||
@ -54,6 +63,8 @@ class MainActivity : ComponentActivity() {
|
|||||||
|
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
|
|
||||||
|
initialRoute = getInitialRouteFromIntent(intent)
|
||||||
|
|
||||||
setContent {
|
setContent {
|
||||||
VBHelperTheme {
|
VBHelperTheme {
|
||||||
MainApplication(
|
MainApplication(
|
||||||
@ -64,12 +75,14 @@ class MainActivity : ComponentActivity() {
|
|||||||
homeScreenController = homeScreenController,
|
homeScreenController = homeScreenController,
|
||||||
storageScreenController = storageScreenController,
|
storageScreenController = storageScreenController,
|
||||||
spriteViewerController = spriteViewerController,
|
spriteViewerController = spriteViewerController,
|
||||||
cardScreenController = cardScreenController
|
cardScreenController = cardScreenController,
|
||||||
|
initialRoute = initialRoute
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Log.i("MainActivity", "Activity onCreated")
|
Log.i("MainActivity", "Activity onCreated")
|
||||||
|
handleImportIntent(intent)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onPause() {
|
override fun onPause() {
|
||||||
@ -88,6 +101,71 @@ class MainActivity : ComponentActivity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onNewIntent(intent: Intent) {
|
||||||
|
super.onNewIntent(intent)
|
||||||
|
setIntent(intent)
|
||||||
|
initialRoute = getInitialRouteFromIntent(intent)
|
||||||
|
// Optionally, you may want to trigger navigation here if needed
|
||||||
|
handleImportIntent(intent)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleImportIntent(intent: Intent?) {
|
||||||
|
val importUri = extractVitalWearImportUri(intent) ?: return
|
||||||
|
val application = applicationContext as VBHelper
|
||||||
|
|
||||||
|
lifecycleScope.launch(Dispatchers.IO) {
|
||||||
|
val result = runCatching {
|
||||||
|
contentResolver.openInputStream(importUri)?.use { inputStream ->
|
||||||
|
val character = Character.parseFrom(inputStream)
|
||||||
|
VitalWearCharacterImporter(application.container.db).importCharacter(character)
|
||||||
|
} ?: VitalWearCharacterImporter.ImportResult(
|
||||||
|
success = false,
|
||||||
|
message = "VitalWear import file could not be opened."
|
||||||
|
)
|
||||||
|
}.getOrElse {
|
||||||
|
VitalWearCharacterImporter.ImportResult(
|
||||||
|
success = false,
|
||||||
|
message = "VitalWear import failed: ${it.message ?: "Unknown error"}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
runOnUiThread {
|
||||||
|
Toast.makeText(this@MainActivity, result.message, Toast.LENGTH_LONG).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun extractVitalWearImportUri(intent: Intent?): Uri? {
|
||||||
|
if (intent == null) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
val isVitalWearImport = intent.type == VITALWEAR_CHARACTER_MIME
|
||||||
|
if (!isVitalWearImport) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return when (intent.action) {
|
||||||
|
Intent.ACTION_SEND -> {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
intent.getParcelableExtra(Intent.EXTRA_STREAM)
|
||||||
|
}
|
||||||
|
Intent.ACTION_VIEW -> intent.data
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getInitialRouteFromIntent(intent: Intent?): String? {
|
||||||
|
if (intent == null) return null
|
||||||
|
val data = intent.data
|
||||||
|
if (intent.action == Intent.ACTION_VIEW && data != null) {
|
||||||
|
if (data.scheme == "vbhelper" && data.host == "auth") {
|
||||||
|
return "Battle"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun MainApplication(
|
private fun MainApplication(
|
||||||
scanScreenController: ScanScreenControllerImpl,
|
scanScreenController: ScanScreenControllerImpl,
|
||||||
@ -97,7 +175,8 @@ class MainActivity : ComponentActivity() {
|
|||||||
storageScreenController: StorageScreenControllerImpl,
|
storageScreenController: StorageScreenControllerImpl,
|
||||||
homeScreenController: HomeScreenControllerImpl,
|
homeScreenController: HomeScreenControllerImpl,
|
||||||
spriteViewerController: SpriteViewerControllerImpl,
|
spriteViewerController: SpriteViewerControllerImpl,
|
||||||
cardScreenController: CardScreenControllerImpl
|
cardScreenController: CardScreenControllerImpl,
|
||||||
|
initialRoute: String? = null
|
||||||
) {
|
) {
|
||||||
AppNavigation(
|
AppNavigation(
|
||||||
applicationNavigationHandlers = AppNavigationHandlers(
|
applicationNavigationHandlers = AppNavigationHandlers(
|
||||||
@ -109,7 +188,12 @@ class MainActivity : ComponentActivity() {
|
|||||||
homeScreenController,
|
homeScreenController,
|
||||||
spriteViewerController,
|
spriteViewerController,
|
||||||
cardScreenController
|
cardScreenController
|
||||||
)
|
),
|
||||||
|
initialRoute = initialRoute
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val VITALWEAR_CHARACTER_MIME = "application/x-vitalwear-character"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,46 @@
|
|||||||
|
package com.github.nacabaro.vbhelper.companion.card
|
||||||
|
|
||||||
|
enum class Endian {
|
||||||
|
Big,
|
||||||
|
Little,
|
||||||
|
}
|
||||||
|
|
||||||
|
fun ByteArray.getUInt32(index: Int = 0, endian: Endian = Endian.Little): UInt {
|
||||||
|
require(this.size >= index + 4) { "Must be 4 bytes from index to get a UInt" }
|
||||||
|
var result: UInt = 0u
|
||||||
|
for (i in 0 until 4) {
|
||||||
|
result = if (endian == Endian.Big) {
|
||||||
|
result or ((this[index + i].toUInt() and 0xFFu) shl (8 * (3 - i)))
|
||||||
|
} else {
|
||||||
|
result or ((this[index + i].toUInt() and 0xFFu) shl (8 * i))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
fun ByteArray.getUInt16(index: Int = 0, endian: Endian = Endian.Little): UShort {
|
||||||
|
require(this.size >= index + 2) { "Must be 2 bytes from index to get a UInt" }
|
||||||
|
var result: UInt = 0u
|
||||||
|
for (i in 0 until 2) {
|
||||||
|
result = if (endian == Endian.Big) {
|
||||||
|
result or ((this[index + i].toUInt() and 0xFFu) shl (8 * (1 - i)))
|
||||||
|
} else {
|
||||||
|
result or ((this[index + i].toUInt() and 0xFFu) shl (8 * i))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result.toUShort()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun UShort.toByteArray(endian: Endian = Endian.Little): ByteArray {
|
||||||
|
val byteArray = byteArrayOf(0, 0)
|
||||||
|
val asUInt = this.toUInt()
|
||||||
|
for (i in 0 until 2) {
|
||||||
|
if (endian == Endian.Little) {
|
||||||
|
byteArray[i] = ((asUInt shr (8 * i)) and 255u).toByte()
|
||||||
|
} else {
|
||||||
|
byteArray[1 - i] = ((asUInt shr (8 * i)) and 255u).toByte()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return byteArray
|
||||||
|
}
|
||||||
|
|
||||||
@ -0,0 +1,400 @@
|
|||||||
|
package com.github.nacabaro.vbhelper.companion.card
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.activity.compose.setContent
|
||||||
|
import androidx.activity.result.ActivityResultLauncher
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.Checkbox
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextField
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.github.cfogrady.vb.dim.adventure.AdventureLevels
|
||||||
|
import com.github.cfogrady.vb.dim.adventure.AdventureLevels.AdventureLevel
|
||||||
|
import com.github.cfogrady.vb.dim.card.Card
|
||||||
|
import com.github.cfogrady.vb.dim.card.DimReader
|
||||||
|
import com.github.cfogrady.vb.dim.card.DimWriter
|
||||||
|
import com.github.cfogrady.vb.dim.character.CharacterStats
|
||||||
|
import com.github.cfogrady.vb.dim.character.CharacterStats.CharacterStatsEntry
|
||||||
|
import com.github.cfogrady.vb.dim.fusion.AttributeFusions
|
||||||
|
import com.github.cfogrady.vb.dim.fusion.SpecificFusions
|
||||||
|
import com.github.cfogrady.vb.dim.fusion.SpecificFusions.SpecificFusionEntry
|
||||||
|
import com.github.cfogrady.vb.dim.header.DimHeader
|
||||||
|
import com.github.cfogrady.vb.dim.transformation.TransformationRequirements
|
||||||
|
import com.github.cfogrady.vb.dim.transformation.TransformationRequirements.TransformationRequirementsEntry
|
||||||
|
import com.github.nacabaro.vbhelper.R
|
||||||
|
import com.github.nacabaro.vbhelper.companion.card.CompanionValidateCardActivity
|
||||||
|
import com.github.nacabaro.vbhelper.companion.common.ChannelTypes
|
||||||
|
import com.github.nacabaro.vbhelper.companion.ui.CompanionLoading
|
||||||
|
import com.github.nacabaro.vbhelper.companion.validation.ValidatedCardManager
|
||||||
|
import com.github.nacabaro.vbhelper.di.VBHelper
|
||||||
|
import com.google.android.gms.wearable.Wearable
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.tasks.await
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import timber.log.Timber
|
||||||
|
import java.io.ByteArrayOutputStream
|
||||||
|
import java.io.DataOutputStream
|
||||||
|
import java.nio.charset.Charset
|
||||||
|
|
||||||
|
class CompanionImportCardActivity : ComponentActivity() {
|
||||||
|
enum class ImportState {
|
||||||
|
PickFile,
|
||||||
|
NameOrUnique,
|
||||||
|
LoadFile,
|
||||||
|
UnlockCard,
|
||||||
|
ImportCard,
|
||||||
|
Success,
|
||||||
|
}
|
||||||
|
|
||||||
|
private lateinit var filePickLauncher: ActivityResultLauncher<Array<String>>
|
||||||
|
private lateinit var validationLauncher: ActivityResultLauncher<Intent>
|
||||||
|
private var uri: Uri? = null
|
||||||
|
private val importState = MutableStateFlow(ImportState.PickFile)
|
||||||
|
private val cardName = MutableStateFlow("")
|
||||||
|
private val uniqueSprites = MutableStateFlow(false)
|
||||||
|
private val convertToBem = MutableStateFlow(false)
|
||||||
|
private val importPercent = MutableStateFlow(0)
|
||||||
|
private val importStatus = MutableStateFlow("Preparing transfer")
|
||||||
|
private lateinit var card: Card<out DimHeader, out CharacterStats<out CharacterStatsEntry>, out TransformationRequirements<out TransformationRequirementsEntry>, out AdventureLevels<out AdventureLevel>, out AttributeFusions, out SpecificFusions<out SpecificFusionEntry>>
|
||||||
|
|
||||||
|
private val validatedCardManager by lazy {
|
||||||
|
(application as VBHelper).validatedCardManager
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
filePickLauncher = buildFilePickLauncher()
|
||||||
|
validationLauncher = buildValidationLauncher()
|
||||||
|
setContent {
|
||||||
|
val state by importState.collectAsState()
|
||||||
|
when (state) {
|
||||||
|
ImportState.PickFile -> {
|
||||||
|
LaunchedEffect(true) {
|
||||||
|
filePickLauncher.launch(arrayOf("*/*"))
|
||||||
|
}
|
||||||
|
CompanionLoading(loadingText = stringResource(R.string.settings_companion_import_card_image_title))
|
||||||
|
}
|
||||||
|
ImportState.NameOrUnique -> NameOrUnique()
|
||||||
|
ImportState.LoadFile -> {
|
||||||
|
CompanionLoading(loadingText = "Loading Card Image")
|
||||||
|
LaunchedEffect(true) {
|
||||||
|
runCatching {
|
||||||
|
loadCard()
|
||||||
|
}.onFailure { error ->
|
||||||
|
Timber.e(error, "Failed to load card image")
|
||||||
|
Toast.makeText(
|
||||||
|
this@CompanionImportCardActivity,
|
||||||
|
"Card load failed: ${error.message ?: "unknown error"}",
|
||||||
|
Toast.LENGTH_LONG,
|
||||||
|
).show()
|
||||||
|
importState.value = ImportState.NameOrUnique
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ImportState.UnlockCard -> {
|
||||||
|
CompanionLoading(loadingText = stringResource(R.string.companion_validation_connect_vb))
|
||||||
|
LaunchedEffect(true) {
|
||||||
|
validationLauncher.launch(
|
||||||
|
Intent(applicationContext, CompanionValidateCardActivity::class.java).apply {
|
||||||
|
putExtra(CompanionValidateCardActivity.CARD_VALIDATED_KEY, card.header.dimId)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ImportState.ImportCard -> {
|
||||||
|
val percent by importPercent.collectAsState()
|
||||||
|
val status by importStatus.collectAsState()
|
||||||
|
CompanionLoading(loadingText = "$status ($percent%)")
|
||||||
|
LaunchedEffect(true) {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
val transferred = importCard()
|
||||||
|
importState.value = if (transferred) ImportState.Success else ImportState.NameOrUnique
|
||||||
|
} catch (error: Exception) {
|
||||||
|
Timber.e(error, "Card transfer failed")
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
Toast.makeText(
|
||||||
|
this@CompanionImportCardActivity,
|
||||||
|
"Card transfer failed: ${error.message ?: "unknown error"}",
|
||||||
|
Toast.LENGTH_LONG,
|
||||||
|
).show()
|
||||||
|
}
|
||||||
|
importState.value = ImportState.NameOrUnique
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ImportState.Success -> {
|
||||||
|
LaunchedEffect(true) {
|
||||||
|
Handler(Looper.getMainLooper()).postDelayed({
|
||||||
|
finish()
|
||||||
|
}, 1000)
|
||||||
|
}
|
||||||
|
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
|
Text(text = stringResource(R.string.companion_card_import_success))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun NameOrUnique() {
|
||||||
|
val selectedUri = uri
|
||||||
|
val filePath = selectedUri?.path ?: "No file selected"
|
||||||
|
val name by cardName.collectAsState()
|
||||||
|
val unique by uniqueSprites.collectAsState()
|
||||||
|
val convert by convertToBem.collectAsState()
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.Center,
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.Center,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Button(modifier = Modifier.padding(end = 10.dp), onClick = { importState.value = ImportState.PickFile }) {
|
||||||
|
Text(text = "File")
|
||||||
|
}
|
||||||
|
Text(text = filePath)
|
||||||
|
}
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(top = 16.dp),
|
||||||
|
horizontalArrangement = Arrangement.Center,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Text(text = "Name:", modifier = Modifier.padding(end = 10.dp))
|
||||||
|
TextField(value = name, onValueChange = { cardName.value = it })
|
||||||
|
}
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(top = 16.dp),
|
||||||
|
horizontalArrangement = Arrangement.Center,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Text(text = "Unique Sprites:")
|
||||||
|
Checkbox(checked = unique, onCheckedChange = { uniqueSprites.value = it })
|
||||||
|
}
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(top = 16.dp),
|
||||||
|
horizontalArrangement = Arrangement.Center,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Text(text = "Convert to BeM:")
|
||||||
|
Checkbox(checked = convert, onCheckedChange = { convertToBem.value = it })
|
||||||
|
}
|
||||||
|
Button(
|
||||||
|
modifier = Modifier.padding(top = 16.dp),
|
||||||
|
enabled = selectedUri != null,
|
||||||
|
onClick = { importState.value = ImportState.LoadFile },
|
||||||
|
) {
|
||||||
|
Text(text = "Import")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildFilePickLauncher(): ActivityResultLauncher<Array<String>> {
|
||||||
|
return registerForActivityResult(ActivityResultContracts.OpenDocument()) {
|
||||||
|
if (it == null) {
|
||||||
|
Toast.makeText(
|
||||||
|
this,
|
||||||
|
"Card file selection cancelled.",
|
||||||
|
Toast.LENGTH_SHORT,
|
||||||
|
).show()
|
||||||
|
importState.value = ImportState.NameOrUnique
|
||||||
|
} else {
|
||||||
|
uri = it
|
||||||
|
val path = it.path ?: ""
|
||||||
|
var name = path.substringAfterLast("/")
|
||||||
|
if (name.contains('.')) {
|
||||||
|
name = name.substringBeforeLast('.')
|
||||||
|
}
|
||||||
|
cardName.value = name
|
||||||
|
importState.value = ImportState.NameOrUnique
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildValidationLauncher(): ActivityResultLauncher<Intent> {
|
||||||
|
return registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||||
|
val validatedCardId = result.data?.extras?.getInt(CompanionValidateCardActivity.CARD_VALIDATED_KEY, -1)
|
||||||
|
if (result.resultCode != RESULT_OK || validatedCardId == null || validatedCardId == -1) {
|
||||||
|
Toast.makeText(
|
||||||
|
this,
|
||||||
|
"Card validation was cancelled.",
|
||||||
|
Toast.LENGTH_SHORT,
|
||||||
|
).show()
|
||||||
|
importState.value = ImportState.NameOrUnique
|
||||||
|
} else {
|
||||||
|
lifecycleScope.launch(Dispatchers.IO) {
|
||||||
|
validatedCardManager.addValidatedCard(validatedCardId)
|
||||||
|
importState.value = ImportState.ImportCard
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun loadCard() {
|
||||||
|
val selectedUri = uri
|
||||||
|
if (selectedUri == null) {
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
Toast.makeText(
|
||||||
|
this@CompanionImportCardActivity,
|
||||||
|
"No card file selected.",
|
||||||
|
Toast.LENGTH_SHORT,
|
||||||
|
).show()
|
||||||
|
}
|
||||||
|
importState.value = ImportState.NameOrUnique
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val dimReader = DimReader()
|
||||||
|
val inputStream = contentResolver.openInputStream(selectedUri)
|
||||||
|
if (inputStream == null) {
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
Toast.makeText(
|
||||||
|
this@CompanionImportCardActivity,
|
||||||
|
"Unable to open selected card file.",
|
||||||
|
Toast.LENGTH_LONG,
|
||||||
|
).show()
|
||||||
|
}
|
||||||
|
importState.value = ImportState.NameOrUnique
|
||||||
|
return
|
||||||
|
}
|
||||||
|
inputStream.use {
|
||||||
|
card = dimReader.readCard(it, false)
|
||||||
|
if (validatedCardManager.isValidatedCard(card.header.dimId)) {
|
||||||
|
importState.value = ImportState.ImportCard
|
||||||
|
} else {
|
||||||
|
importState.value = ImportState.UnlockCard
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun importCard(): Boolean {
|
||||||
|
importPercent.value = 0
|
||||||
|
importStatus.value = "Preparing card"
|
||||||
|
val channelClient = Wearable.getChannelClient(this)
|
||||||
|
val nodes = Wearable.getNodeClient(this).connectedNodes.await()
|
||||||
|
Timber.i("Card transfer start: connectedNodes=${nodes.size}, ids=${nodes.joinToString { it.id }}")
|
||||||
|
if (nodes.isEmpty()) {
|
||||||
|
importStatus.value = getString(R.string.companion_firmware_no_watch)
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
Toast.makeText(
|
||||||
|
this@CompanionImportCardActivity,
|
||||||
|
getString(R.string.companion_firmware_no_watch),
|
||||||
|
Toast.LENGTH_LONG,
|
||||||
|
).show()
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
val cardWriter = DimWriter()
|
||||||
|
val cardPayload = ByteArrayOutputStream().use { outputStream ->
|
||||||
|
cardWriter.writeCard(card, outputStream)
|
||||||
|
outputStream.toByteArray()
|
||||||
|
}
|
||||||
|
val cardNameBytes = cardName.value.toByteArray(Charset.defaultCharset())
|
||||||
|
val metadataLength = cardNameBytes.size + 1 + 1 + 1 + 4
|
||||||
|
val totalBytes = (metadataLength + cardPayload.size).toLong() * nodes.size
|
||||||
|
var transferredBytes = 0L
|
||||||
|
var successfulTransfers = 0
|
||||||
|
val failedNodes = mutableListOf<String>()
|
||||||
|
for (node in nodes) {
|
||||||
|
val nodeName = node.displayName.takeIf { it.isNotBlank() } ?: node.id
|
||||||
|
importStatus.value = "Connecting to $nodeName"
|
||||||
|
try {
|
||||||
|
Timber.i("Opening card channel for nodeId=${node.id}, displayName=${node.displayName}")
|
||||||
|
val channel = channelClient.openChannel(node.id, ChannelTypes.CARD_DATA).await()
|
||||||
|
channelClient.getOutputStream(channel).await().use { os ->
|
||||||
|
val output = DataOutputStream(os)
|
||||||
|
output.write(cardNameBytes)
|
||||||
|
output.writeByte(0)
|
||||||
|
output.writeByte(if (uniqueSprites.value) 1 else 0)
|
||||||
|
output.writeByte(if (convertToBem.value) 1 else 0)
|
||||||
|
output.writeInt(cardPayload.size)
|
||||||
|
transferredBytes += metadataLength
|
||||||
|
val metadataPercent = if (totalBytes == 0L) 0 else ((transferredBytes * 100) / totalBytes).toInt().coerceIn(0, 100)
|
||||||
|
importStatus.value = "Sending card to $nodeName"
|
||||||
|
importPercent.value = metadataPercent
|
||||||
|
|
||||||
|
val buffer = ByteArray(4096)
|
||||||
|
var bytesRead: Int
|
||||||
|
cardPayload.inputStream().use { payloadInput ->
|
||||||
|
while (payloadInput.read(buffer).also { bytesRead = it } >= 0) {
|
||||||
|
output.write(buffer, 0, bytesRead)
|
||||||
|
transferredBytes += bytesRead
|
||||||
|
val transferPercent = if (totalBytes == 0L) 0 else ((transferredBytes * 100) / totalBytes).toInt().coerceIn(0, 100)
|
||||||
|
importPercent.value = transferPercent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
output.flush()
|
||||||
|
}
|
||||||
|
channelClient.close(channel).await()
|
||||||
|
successfulTransfers++
|
||||||
|
Timber.i("Card transfer complete for nodeId=${node.id}, card=${cardName.value}, payloadBytes=${cardPayload.size}")
|
||||||
|
} catch (error: Exception) {
|
||||||
|
failedNodes.add("$nodeName: ${error.message ?: "unknown error"}")
|
||||||
|
Timber.e(error, "Card transfer failed for nodeId=${node.id}, displayName=${node.displayName}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (successfulTransfers == 0) {
|
||||||
|
importStatus.value = "Transfer failed"
|
||||||
|
importPercent.value = 0
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
val reason = failedNodes.firstOrNull() ?: "No watch accepted the transfer"
|
||||||
|
Toast.makeText(this@CompanionImportCardActivity, "Card transfer failed: $reason", Toast.LENGTH_LONG).show()
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
importStatus.value = "Transfer complete"
|
||||||
|
importPercent.value = 100
|
||||||
|
if (failedNodes.isNotEmpty()) {
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
Toast.makeText(
|
||||||
|
this@CompanionImportCardActivity,
|
||||||
|
"Sent to $successfulTransfers watch(es); ${failedNodes.size} failed.",
|
||||||
|
Toast.LENGTH_LONG,
|
||||||
|
).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Timber.i("Companion-style card import sent to watch for ${cardName.value}; success=$successfulTransfers failures=${failedNodes.size}")
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@ -0,0 +1,162 @@
|
|||||||
|
package com.github.nacabaro.vbhelper.companion.card
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
|
import android.nfc.NfcAdapter
|
||||||
|
import android.nfc.Tag
|
||||||
|
import android.nfc.tech.MifareUltralight
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
|
import android.provider.Settings
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.activity.compose.setContent
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import com.github.nacabaro.vbhelper.R
|
||||||
|
import com.github.nacabaro.vbhelper.companion.ui.CompanionLoading
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import timber.log.Timber
|
||||||
|
|
||||||
|
class CompanionValidateCardActivity : ComponentActivity(), NfcAdapter.ReaderCallback {
|
||||||
|
companion object {
|
||||||
|
const val CARD_VALIDATED_KEY = "CARD_VALIDATED"
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class CardValidationState {
|
||||||
|
WaitingForVBConnect,
|
||||||
|
ValidateCardOnVB,
|
||||||
|
Success,
|
||||||
|
}
|
||||||
|
|
||||||
|
private lateinit var nfcAdapter: NfcAdapter
|
||||||
|
private val validationStateFlow = MutableStateFlow(CardValidationState.WaitingForVBConnect)
|
||||||
|
private var cardIdToValidate: UShort = 0u
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
val maybeNfcAdapter = NfcAdapter.getDefaultAdapter(this)
|
||||||
|
val inputCardId = intent.getIntExtra(CARD_VALIDATED_KEY, -1)
|
||||||
|
if (inputCardId == -1) {
|
||||||
|
Timber.e("CompanionValidateCardActivity called without card number!")
|
||||||
|
finish()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cardIdToValidate = inputCardId.toUShort()
|
||||||
|
if (maybeNfcAdapter == null) {
|
||||||
|
Toast.makeText(this, getString(R.string.scan_no_nfc_on_device), Toast.LENGTH_SHORT).show()
|
||||||
|
finish()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
nfcAdapter = maybeNfcAdapter
|
||||||
|
setContent {
|
||||||
|
DisplayContent()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun DisplayContent() {
|
||||||
|
val validationState by validationStateFlow.collectAsState()
|
||||||
|
when (validationState) {
|
||||||
|
CardValidationState.WaitingForVBConnect -> {
|
||||||
|
CompanionLoading(loadingText = stringResource(R.string.companion_validation_connect_vb))
|
||||||
|
}
|
||||||
|
CardValidationState.ValidateCardOnVB -> {
|
||||||
|
CompanionLoading(loadingText = stringResource(R.string.companion_validation_insert_card))
|
||||||
|
}
|
||||||
|
CardValidationState.Success -> {
|
||||||
|
LaunchedEffect(true) {
|
||||||
|
Handler(Looper.getMainLooper()).postDelayed({
|
||||||
|
val intent = Intent().apply {
|
||||||
|
putExtra(CARD_VALIDATED_KEY, cardIdToValidate.toInt())
|
||||||
|
}
|
||||||
|
setResult(RESULT_OK, intent)
|
||||||
|
finish()
|
||||||
|
}, 1000)
|
||||||
|
}
|
||||||
|
Text(text = stringResource(R.string.companion_validation_success))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onResume() {
|
||||||
|
super.onResume()
|
||||||
|
if (!nfcAdapter.isEnabled) {
|
||||||
|
showWirelessSettings()
|
||||||
|
} else {
|
||||||
|
nfcAdapter.enableReaderMode(this, this, NfcAdapter.FLAG_READER_NFC_A, Bundle())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPause() {
|
||||||
|
super.onPause()
|
||||||
|
if (this::nfcAdapter.isInitialized) {
|
||||||
|
nfcAdapter.disableReaderMode(this)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showWirelessSettings() {
|
||||||
|
Toast.makeText(this, getString(R.string.scan_nfc_must_be_enabled), Toast.LENGTH_SHORT).show()
|
||||||
|
startActivity(Intent(Settings.ACTION_WIRELESS_SETTINGS))
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onTagDiscovered(tag: Tag?) {
|
||||||
|
if (tag == null) {
|
||||||
|
Timber.w("Tag discovery returned null tag")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
when (validationStateFlow.value) {
|
||||||
|
CardValidationState.WaitingForVBConnect -> {
|
||||||
|
val nfcData = MifareUltralight.get(tag)
|
||||||
|
if (nfcData == null) {
|
||||||
|
Timber.w("Unsupported NFC tech for validation")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val writeSuccessful = runCatching {
|
||||||
|
nfcData.connect()
|
||||||
|
nfcData.use {
|
||||||
|
val vbData = VBNfcData(nfcData)
|
||||||
|
vbData.writeCardCheck(nfcData, cardIdToValidate)
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}.onFailure {
|
||||||
|
Timber.e(it, "Failed during first validation NFC step")
|
||||||
|
runOnUiThread {
|
||||||
|
Toast.makeText(
|
||||||
|
this,
|
||||||
|
"Failed to validate card on bracelet. Please retry.",
|
||||||
|
Toast.LENGTH_SHORT,
|
||||||
|
).show()
|
||||||
|
}
|
||||||
|
}.getOrDefault(false)
|
||||||
|
if (writeSuccessful) {
|
||||||
|
validationStateFlow.value = CardValidationState.ValidateCardOnVB
|
||||||
|
}
|
||||||
|
}
|
||||||
|
CardValidationState.ValidateCardOnVB -> {
|
||||||
|
val nfcData = MifareUltralight.get(tag)
|
||||||
|
if (nfcData == null) {
|
||||||
|
Timber.w("Unsupported NFC tech for validation")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
runCatching {
|
||||||
|
nfcData.connect()
|
||||||
|
nfcData.use {
|
||||||
|
val vbData = VBNfcData(nfcData)
|
||||||
|
if (vbData.wasCardIdValidated(cardIdToValidate)) {
|
||||||
|
validationStateFlow.value = CardValidationState.Success
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.onFailure {
|
||||||
|
Timber.e(it, "Failed during second validation NFC step")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> Timber.w("Incorrect state")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@ -0,0 +1,48 @@
|
|||||||
|
package com.github.nacabaro.vbhelper.companion.card
|
||||||
|
|
||||||
|
import android.nfc.tech.MifareUltralight
|
||||||
|
import kotlin.experimental.and
|
||||||
|
import kotlin.experimental.or
|
||||||
|
|
||||||
|
class VBNfcData(nfcData: MifareUltralight) {
|
||||||
|
companion object {
|
||||||
|
private const val ITEM_ID_BE: UShort = 4u
|
||||||
|
private const val STATUS_READY_FLAG: Byte = 0b00000001
|
||||||
|
private const val STATUS_DIM_READY_FLAG: Byte = 0b00000010
|
||||||
|
private val STATUS_DIM_IS_READY = STATUS_READY_FLAG or STATUS_DIM_READY_FLAG
|
||||||
|
private const val OPERATION_READY: Byte = 1
|
||||||
|
private const val OPERATION_CHECK_DIM: Byte = 3
|
||||||
|
}
|
||||||
|
|
||||||
|
val itemId: UShort
|
||||||
|
val status: Byte
|
||||||
|
val operation: Byte
|
||||||
|
val dimId: UShort
|
||||||
|
|
||||||
|
init {
|
||||||
|
val readData = nfcData.transceive(byteArrayOf(0x30, 0x04))
|
||||||
|
itemId = readData.getUInt16(4, Endian.Big)
|
||||||
|
status = readData[8]
|
||||||
|
if (itemId == ITEM_ID_BE) {
|
||||||
|
operation = readData[9]
|
||||||
|
dimId = readData.getUInt16(10, Endian.Big)
|
||||||
|
} else {
|
||||||
|
dimId = readData[9].toUShort()
|
||||||
|
operation = readData[10]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun writeCardCheck(nfcData: MifareUltralight, dimId: UShort) {
|
||||||
|
if (itemId == ITEM_ID_BE) {
|
||||||
|
val dimData = dimId.toByteArray(endian = Endian.Big)
|
||||||
|
nfcData.writePage(6, byteArrayOf(STATUS_READY_FLAG, OPERATION_CHECK_DIM, dimData[0], dimData[1]))
|
||||||
|
} else {
|
||||||
|
nfcData.writePage(6, byteArrayOf(STATUS_READY_FLAG, dimId.toByte(), OPERATION_CHECK_DIM, 0))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun wasCardIdValidated(dimId: UShort): Boolean {
|
||||||
|
return dimId == this.dimId && operation == OPERATION_READY && (status and STATUS_DIM_IS_READY == STATUS_DIM_IS_READY)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@ -0,0 +1,10 @@
|
|||||||
|
package com.github.nacabaro.vbhelper.companion.common
|
||||||
|
|
||||||
|
object ChannelTypes {
|
||||||
|
const val CARD_DATA = "com.github.cfogrady.vitalwear/CARD_DATA"
|
||||||
|
const val FIRMWARE_DATA = "com.github.cfogrady.vitalwear/FIRMWARE_DATA"
|
||||||
|
const val CHARACTER_DATA = "com.github.cfogrady.vitalwear/CHARACTER_DATA"
|
||||||
|
const val SEND_LOGS_REQUEST = "SEND_LOGS_REQUEST"
|
||||||
|
const val LOGS_DATA = "LOG_DATA"
|
||||||
|
}
|
||||||
|
|
||||||
@ -0,0 +1,140 @@
|
|||||||
|
package com.github.nacabaro.vbhelper.companion.firmware
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.activity.compose.setContent
|
||||||
|
import androidx.activity.result.ActivityResultLauncher
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
import com.github.nacabaro.vbhelper.R
|
||||||
|
import com.github.nacabaro.vbhelper.companion.common.ChannelTypes
|
||||||
|
import com.github.nacabaro.vbhelper.companion.ui.CompanionLoading
|
||||||
|
import com.google.android.gms.wearable.Wearable
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.tasks.await
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import timber.log.Timber
|
||||||
|
import java.io.DataOutputStream
|
||||||
|
|
||||||
|
class CompanionFirmwareImportActivity : ComponentActivity() {
|
||||||
|
enum class FirmwareImportState {
|
||||||
|
PickFirmware,
|
||||||
|
LoadFirmware,
|
||||||
|
}
|
||||||
|
|
||||||
|
private val importState = MutableStateFlow(FirmwareImportState.PickFirmware)
|
||||||
|
private val transferPercent = MutableStateFlow(0)
|
||||||
|
private val transferStatus = MutableStateFlow("Preparing transfer")
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
val firmwareImportActivity = buildFirmwareFilePickLauncher()
|
||||||
|
setContent {
|
||||||
|
BuildScreen(firmwareImportActivity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun BuildScreen(firmwareImportActivity: ActivityResultLauncher<Array<String>>) {
|
||||||
|
val state by importState.collectAsState()
|
||||||
|
val percent by transferPercent.collectAsState()
|
||||||
|
val status by transferStatus.collectAsState()
|
||||||
|
if (state == FirmwareImportState.PickFirmware) {
|
||||||
|
LaunchedEffect(true) {
|
||||||
|
firmwareImportActivity.launch(arrayOf("*/*"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val loadingText = if (state == FirmwareImportState.PickFirmware) {
|
||||||
|
getString(R.string.companion_loading_select_firmware)
|
||||||
|
} else {
|
||||||
|
"$status ($percent%)"
|
||||||
|
}
|
||||||
|
CompanionLoading(loadingText = loadingText)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildFirmwareFilePickLauncher(): ActivityResultLauncher<Array<String>> {
|
||||||
|
return registerForActivityResult(ActivityResultContracts.OpenDocument()) {
|
||||||
|
importState.value = FirmwareImportState.LoadFirmware
|
||||||
|
if (it == null) {
|
||||||
|
finish()
|
||||||
|
} else {
|
||||||
|
Timber.i("Path: ${it.path}")
|
||||||
|
importFirmware(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun importFirmware(uri: Uri) {
|
||||||
|
transferPercent.value = 0
|
||||||
|
transferStatus.value = "Reading firmware file"
|
||||||
|
val channelClient = Wearable.getChannelClient(this)
|
||||||
|
val nodeListTask = Wearable.getNodeClient(this).connectedNodes
|
||||||
|
lifecycleScope.launch(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
val nodes = nodeListTask.await()
|
||||||
|
if (nodes.isEmpty()) {
|
||||||
|
transferStatus.value = getString(R.string.companion_firmware_no_watch)
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
Toast.makeText(this@CompanionFirmwareImportActivity, getString(R.string.companion_firmware_no_watch), Toast.LENGTH_LONG).show()
|
||||||
|
}
|
||||||
|
importState.value = FirmwareImportState.PickFirmware
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
|
||||||
|
val firmware = contentResolver.openInputStream(uri)?.use { input ->
|
||||||
|
input.readBytes()
|
||||||
|
} ?: throw IllegalStateException("Unable to read selected firmware file")
|
||||||
|
|
||||||
|
transferPercent.value = 5
|
||||||
|
transferStatus.value = "Sending firmware"
|
||||||
|
val totalBytes = (firmware.size + 4).toLong() * nodes.size
|
||||||
|
var transferredBytes = 0L
|
||||||
|
for (node in nodes) {
|
||||||
|
val channel = channelClient.openChannel(node.id, ChannelTypes.FIRMWARE_DATA).await()
|
||||||
|
try {
|
||||||
|
channelClient.getOutputStream(channel).await().use {
|
||||||
|
val output = DataOutputStream(it)
|
||||||
|
output.writeInt(firmware.size)
|
||||||
|
transferredBytes += 4
|
||||||
|
transferPercent.value = if (totalBytes == 0L) 0 else ((transferredBytes * 100) / totalBytes).toInt().coerceIn(0, 100)
|
||||||
|
val buffer = ByteArray(4096)
|
||||||
|
var bytesRead: Int
|
||||||
|
firmware.inputStream().use { firmwareInput ->
|
||||||
|
while (firmwareInput.read(buffer).also { bytesRead = it } >= 0) {
|
||||||
|
output.write(buffer, 0, bytesRead)
|
||||||
|
transferredBytes += bytesRead
|
||||||
|
transferPercent.value = if (totalBytes == 0L) 0 else ((transferredBytes * 100) / totalBytes).toInt().coerceIn(0, 100)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
output.flush()
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
runCatching { channelClient.close(channel).await() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
transferStatus.value = "Transfer complete"
|
||||||
|
transferPercent.value = 100
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
Toast.makeText(this@CompanionFirmwareImportActivity, getString(R.string.companion_firmware_sent_success), Toast.LENGTH_SHORT).show()
|
||||||
|
finish()
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Timber.e(e, "Failed to send firmware to watch")
|
||||||
|
transferStatus.value = "Transfer failed: ${e.message ?: "unknown"}"
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
Toast.makeText(this@CompanionFirmwareImportActivity, "Firmware transfer failed: ${e.message ?: "unknown error"}", Toast.LENGTH_LONG).show()
|
||||||
|
}
|
||||||
|
importState.value = FirmwareImportState.PickFirmware
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@ -0,0 +1,77 @@
|
|||||||
|
package com.github.nacabaro.vbhelper.companion.logging
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.content.Context
|
||||||
|
import android.util.Log
|
||||||
|
import org.tinylog.Logger
|
||||||
|
import org.tinylog.configuration.Configuration
|
||||||
|
import org.tinylog.provider.ProviderRegistry
|
||||||
|
import timber.log.Timber
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
@SuppressLint("LogNotTimber")
|
||||||
|
class TinyLogTree(context: Context) : Timber.DebugTree() {
|
||||||
|
companion object {
|
||||||
|
private val logFileRegexp = Regex("""log_(\d{4}-\d{2}-\d{2})_(\d+)\.txt""")
|
||||||
|
private const val START_DATE = "0000-00-00"
|
||||||
|
|
||||||
|
fun shutdown() {
|
||||||
|
ProviderRegistry.getLoggingProvider().shutdown()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setupRollingLogs(logsDir: String) {
|
||||||
|
Configuration.set("writer", "rolling file")
|
||||||
|
Configuration.set("writer.level", "info")
|
||||||
|
Configuration.set("writer.backups", "3")
|
||||||
|
Configuration.set("writer.format", "{date: yyyy-MM-dd HH:mm:ss.SSS}{pipe}{tag}{pipe}{level}{pipe}{message}")
|
||||||
|
Configuration.set("writer.file", "$logsDir/log_{date:yyyy-MM-dd}_{count}.txt")
|
||||||
|
Configuration.set("writer.policies", "daily, size: 10mb")
|
||||||
|
Configuration.set("writer.buffered", "true")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getLogsDir(context: Context): File = File(context.filesDir, "logs")
|
||||||
|
|
||||||
|
fun getMostRecentLogFile(context: Context): File? {
|
||||||
|
val logsDir = getLogsDir(context)
|
||||||
|
val files = logsDir.listFiles() ?: return null
|
||||||
|
return getMostRecentLogFileFromFiles(files)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun getMostRecentLogFileFromFiles(files: Array<out File>): File? {
|
||||||
|
var date = START_DATE
|
||||||
|
var count = 0
|
||||||
|
var mostRecentFile: File? = null
|
||||||
|
for (child in files) {
|
||||||
|
val matchResult = logFileRegexp.find(child.name) ?: continue
|
||||||
|
val childDate = matchResult.groups[1]?.value ?: START_DATE
|
||||||
|
val childCount = matchResult.groups[2]?.value?.toInt() ?: 0
|
||||||
|
if (childDate > date || (childDate == date && childCount > count)) {
|
||||||
|
mostRecentFile = child
|
||||||
|
date = childDate
|
||||||
|
count = childCount
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Log.i("TinyLogTree", "Most Recent Log: $mostRecentFile")
|
||||||
|
return mostRecentFile
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
init {
|
||||||
|
val logsDir = getLogsDir(context).absolutePath
|
||||||
|
setupRollingLogs(logsDir)
|
||||||
|
Configuration.set("writingthread", "true")
|
||||||
|
Log.i("TinyLogTree", "Log Dir: $logsDir")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun log(priority: Int, tag: String?, message: String, t: Throwable?) {
|
||||||
|
val taggedLogger = Logger.tag(tag)
|
||||||
|
when (priority) {
|
||||||
|
Log.VERBOSE, Log.DEBUG -> taggedLogger.debug { message }
|
||||||
|
Log.INFO -> taggedLogger.info { message }
|
||||||
|
Log.WARN -> taggedLogger.warn { message }
|
||||||
|
Log.ERROR, Log.ASSERT -> taggedLogger.error { message }
|
||||||
|
else -> taggedLogger.error { "Invalid priority $priority For Message! $message" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@ -0,0 +1,122 @@
|
|||||||
|
package com.github.nacabaro.vbhelper.companion.logs
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.app.Activity
|
||||||
|
import android.content.ClipData
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
|
import android.util.Log
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.core.content.FileProvider
|
||||||
|
import com.github.nacabaro.vbhelper.R
|
||||||
|
import com.github.nacabaro.vbhelper.companion.common.ChannelTypes
|
||||||
|
import com.google.android.gms.wearable.CapabilityClient
|
||||||
|
import com.google.android.gms.wearable.ChannelClient
|
||||||
|
import com.google.android.gms.wearable.ChannelClient.ChannelCallback
|
||||||
|
import com.google.android.gms.wearable.Wearable
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.tasks.await
|
||||||
|
import timber.log.Timber
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
class CompanionLogService {
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "CompanionLogService"
|
||||||
|
}
|
||||||
|
|
||||||
|
var initializedActivity: Activity? = null
|
||||||
|
|
||||||
|
fun sendLogFile(context: Context, file: File) {
|
||||||
|
initializedActivity?.let {
|
||||||
|
sendLogFile(context, file, it)
|
||||||
|
initializedActivity?.finish()
|
||||||
|
}
|
||||||
|
initializedActivity = null
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressLint("LogNotTimber")
|
||||||
|
fun sendLogFile(context: Context, file: File, activity: Activity) {
|
||||||
|
val emailIntent = Intent(Intent.ACTION_SENDTO).apply {
|
||||||
|
data = Uri.parse("mailto:")
|
||||||
|
putExtra(Intent.EXTRA_EMAIL, arrayOf("vitalwear-developers@googlegroups.com", "taveonnelsonn@gmail.com"))
|
||||||
|
putExtra(Intent.EXTRA_SUBJECT, "VBHelper Log")
|
||||||
|
putExtra(Intent.EXTRA_TEXT, "Please take a look at these logs to help diagnose the below issue:\n")
|
||||||
|
}
|
||||||
|
val fileUri = FileProvider.getUriForFile(context, "${context.packageName}.provider", file)
|
||||||
|
emailIntent.putExtra(Intent.EXTRA_STREAM, fileUri)
|
||||||
|
emailIntent.clipData = ClipData.newRawUri("", fileUri)
|
||||||
|
emailIntent.flags = Intent.FLAG_GRANT_READ_URI_PERMISSION
|
||||||
|
Log.i(TAG, "Starting activity to send email")
|
||||||
|
activity.startActivity(Intent.createChooser(emailIntent, "Select Email Client"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun receiveFile(context: Context, channel: ChannelClient.Channel) {
|
||||||
|
val channelClient = Wearable.getChannelClient(context)
|
||||||
|
val logs = File(context.filesDir, "logs")
|
||||||
|
if (!logs.exists()) {
|
||||||
|
logs.mkdirs()
|
||||||
|
}
|
||||||
|
val file = File(logs, "watch_log.txt")
|
||||||
|
channelClient.registerChannelCallback(channel, object : ChannelCallback() {
|
||||||
|
override fun onInputClosed(channel: ChannelClient.Channel, closeReason: Int, appErrorCode: Int) {
|
||||||
|
super.onInputClosed(channel, closeReason, appErrorCode)
|
||||||
|
if (closeReason != ChannelCallback.CLOSE_REASON_NORMAL) {
|
||||||
|
Timber.w("Failed to receive file. Close Reason: ${closeReasonString(closeReason)}")
|
||||||
|
} else {
|
||||||
|
Timber.i("Successfully downloaded log file. Input closed.")
|
||||||
|
sendLogFile(context, file)
|
||||||
|
}
|
||||||
|
channelClient.close(channel)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
channelClient.receiveFile(channel, Uri.fromFile(file), false).apply {
|
||||||
|
addOnFailureListener {
|
||||||
|
Toast.makeText(context, context.getString(R.string.companion_logs_receive_failed), Toast.LENGTH_SHORT).show()
|
||||||
|
initializedActivity?.finish()
|
||||||
|
initializedActivity = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun fetchWatchLogs(callingActivity: Activity) {
|
||||||
|
val messageClient = Wearable.getMessageClient(callingActivity)
|
||||||
|
val capabilityInfoTask = Wearable.getCapabilityClient(callingActivity)
|
||||||
|
.getCapability(ChannelTypes.SEND_LOGS_REQUEST, CapabilityClient.FILTER_REACHABLE)
|
||||||
|
CoroutineScope(Dispatchers.IO).launch {
|
||||||
|
val capabilityInfo = capabilityInfoTask.await()
|
||||||
|
if (capabilityInfo.nodes.isEmpty()) {
|
||||||
|
CoroutineScope(Dispatchers.Main).launch {
|
||||||
|
Toast.makeText(callingActivity, callingActivity.getString(R.string.companion_firmware_no_watch), Toast.LENGTH_SHORT).show()
|
||||||
|
callingActivity.finish()
|
||||||
|
}
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
for (node in capabilityInfo.nodes) {
|
||||||
|
messageClient.sendMessage(node.id, ChannelTypes.SEND_LOGS_REQUEST, null).apply {
|
||||||
|
addOnFailureListener {
|
||||||
|
Toast.makeText(callingActivity, callingActivity.getString(R.string.companion_logs_failed_request), Toast.LENGTH_SHORT).show()
|
||||||
|
callingActivity.finish()
|
||||||
|
}
|
||||||
|
addOnSuccessListener {
|
||||||
|
initializedActivity = callingActivity
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun closeReasonString(closeReason: Int): String {
|
||||||
|
return when (closeReason) {
|
||||||
|
ChannelCallback.CLOSE_REASON_NORMAL -> "Normal"
|
||||||
|
ChannelCallback.CLOSE_REASON_LOCAL_CLOSE -> "Closed Locally"
|
||||||
|
ChannelCallback.CLOSE_REASON_REMOTE_CLOSE -> "Closed Remotely"
|
||||||
|
ChannelCallback.CLOSE_REASON_DISCONNECTED -> "Disconnected"
|
||||||
|
ChannelCallback.CLOSE_REASON_CONNECTION_TIMEOUT -> "Timeout"
|
||||||
|
else -> "Unknown"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@ -0,0 +1,41 @@
|
|||||||
|
package com.github.nacabaro.vbhelper.companion.logs
|
||||||
|
|
||||||
|
import android.os.Bundle
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.activity.compose.setContent
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import com.github.nacabaro.vbhelper.R
|
||||||
|
import com.github.nacabaro.vbhelper.companion.ui.CompanionConfirmation
|
||||||
|
import com.github.nacabaro.vbhelper.companion.ui.CompanionLoading
|
||||||
|
import com.github.nacabaro.vbhelper.di.VBHelper
|
||||||
|
|
||||||
|
class CompanionWatchLogsActivity : ComponentActivity() {
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
val activity = this
|
||||||
|
setContent {
|
||||||
|
var accepted by remember { mutableStateOf(false) }
|
||||||
|
if (!accepted) {
|
||||||
|
CompanionConfirmation(prompt = stringResource(R.string.companion_logs_warning)) {
|
||||||
|
if (it) {
|
||||||
|
accepted = true
|
||||||
|
} else {
|
||||||
|
finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
CompanionLoading(loadingText = stringResource(R.string.settings_companion_send_watch_logs_title))
|
||||||
|
LaunchedEffect(true) {
|
||||||
|
(application as VBHelper).companionLogService.fetchWatchLogs(activity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -0,0 +1,21 @@
|
|||||||
|
package com.github.nacabaro.vbhelper.companion.logs
|
||||||
|
|
||||||
|
import com.github.nacabaro.vbhelper.companion.common.ChannelTypes
|
||||||
|
import com.github.nacabaro.vbhelper.di.VBHelper
|
||||||
|
import com.google.android.gms.wearable.ChannelClient
|
||||||
|
import com.google.android.gms.wearable.WearableListenerService
|
||||||
|
import timber.log.Timber
|
||||||
|
|
||||||
|
class WatchCommunicationService : WearableListenerService() {
|
||||||
|
override fun onChannelOpened(channel: ChannelClient.Channel) {
|
||||||
|
super.onChannelOpened(channel)
|
||||||
|
when (channel.path) {
|
||||||
|
ChannelTypes.LOGS_DATA -> {
|
||||||
|
val logService = (application as VBHelper).companionLogService
|
||||||
|
logService.receiveFile(this, channel)
|
||||||
|
}
|
||||||
|
else -> Timber.i("Unknown channel: ${channel.path}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@ -0,0 +1,23 @@
|
|||||||
|
package com.github.nacabaro.vbhelper.companion.transfer
|
||||||
|
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import com.github.nacabaro.vbhelper.R
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compatibility endpoint kept for legacy links/intents from older companion flows.
|
||||||
|
* Character transfer remains watch-driven and is not supported from phone companion UI.
|
||||||
|
*/
|
||||||
|
class CompanionCharacterImportActivity : ComponentActivity() {
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
Toast.makeText(
|
||||||
|
this,
|
||||||
|
getString(R.string.companion_transfer_disabled_message),
|
||||||
|
Toast.LENGTH_LONG,
|
||||||
|
).show()
|
||||||
|
finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@ -0,0 +1,42 @@
|
|||||||
|
package com.github.nacabaro.vbhelper.companion.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun CompanionConfirmation(
|
||||||
|
prompt: String,
|
||||||
|
onResult: (Boolean) -> Unit,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(24.dp),
|
||||||
|
verticalArrangement = Arrangement.Center,
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
Text(text = prompt, modifier = Modifier.padding(bottom = 24.dp))
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceEvenly,
|
||||||
|
) {
|
||||||
|
Button(onClick = { onResult(true) }) {
|
||||||
|
Text(text = "Ok")
|
||||||
|
}
|
||||||
|
Button(onClick = { onResult(false) }) {
|
||||||
|
Text(text = "Cancel")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@ -0,0 +1,32 @@
|
|||||||
|
package com.github.nacabaro.vbhelper.companion.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun CompanionLoading(
|
||||||
|
loadingText: String,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(24.dp),
|
||||||
|
verticalArrangement = Arrangement.Center,
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator()
|
||||||
|
Text(
|
||||||
|
text = loadingText,
|
||||||
|
modifier = Modifier.padding(top = 16.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@ -0,0 +1,14 @@
|
|||||||
|
package com.github.nacabaro.vbhelper.companion.validation
|
||||||
|
|
||||||
|
import androidx.room.Database
|
||||||
|
import androidx.room.RoomDatabase
|
||||||
|
|
||||||
|
@Database(
|
||||||
|
entities = [ValidatedCardEntity::class],
|
||||||
|
version = 1,
|
||||||
|
exportSchema = false,
|
||||||
|
)
|
||||||
|
abstract class CompanionValidatedDatabase : RoomDatabase() {
|
||||||
|
abstract fun validatedCardDao(): ValidatedCardDao
|
||||||
|
}
|
||||||
|
|
||||||
@ -0,0 +1,16 @@
|
|||||||
|
package com.github.nacabaro.vbhelper.companion.validation
|
||||||
|
|
||||||
|
import androidx.room.Dao
|
||||||
|
import androidx.room.Insert
|
||||||
|
import androidx.room.OnConflictStrategy
|
||||||
|
import androidx.room.Query
|
||||||
|
|
||||||
|
@Dao
|
||||||
|
interface ValidatedCardDao {
|
||||||
|
@Query("select cardId from ${ValidatedCardEntity.TABLE}")
|
||||||
|
suspend fun getIds(): List<Int>
|
||||||
|
|
||||||
|
@Insert(onConflict = OnConflictStrategy.IGNORE)
|
||||||
|
suspend fun insert(validatedCardEntity: ValidatedCardEntity)
|
||||||
|
}
|
||||||
|
|
||||||
@ -0,0 +1,13 @@
|
|||||||
|
package com.github.nacabaro.vbhelper.companion.validation
|
||||||
|
|
||||||
|
import androidx.room.Entity
|
||||||
|
|
||||||
|
@Entity(tableName = ValidatedCardEntity.TABLE, primaryKeys = ["cardId"])
|
||||||
|
data class ValidatedCardEntity(
|
||||||
|
val cardId: Int,
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
const val TABLE = "validated"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@ -0,0 +1,35 @@
|
|||||||
|
package com.github.nacabaro.vbhelper.companion.validation
|
||||||
|
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
|
||||||
|
class ValidatedCardManager(
|
||||||
|
private val validatedCardDao: ValidatedCardDao,
|
||||||
|
) {
|
||||||
|
private val mutex = Mutex()
|
||||||
|
private var validatedIds: Set<Int>? = null
|
||||||
|
|
||||||
|
private suspend fun ensureLoaded(): Set<Int> {
|
||||||
|
validatedIds?.let { return it }
|
||||||
|
return mutex.withLock {
|
||||||
|
validatedIds ?: validatedCardDao.getIds().toSet().also { validatedIds = it }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun addValidatedCard(cardId: Int) {
|
||||||
|
mutex.withLock {
|
||||||
|
val current = validatedIds ?: validatedCardDao.getIds().toSet()
|
||||||
|
if (!current.contains(cardId)) {
|
||||||
|
validatedCardDao.insert(ValidatedCardEntity(cardId))
|
||||||
|
validatedIds = current + cardId
|
||||||
|
} else {
|
||||||
|
validatedIds = current
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun isValidatedCard(cardId: Int): Boolean {
|
||||||
|
return ensureLoaded().contains(cardId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@ -19,6 +19,9 @@ interface CardDao {
|
|||||||
@Query("SELECT * FROM Card WHERE id = :id")
|
@Query("SELECT * FROM Card WHERE id = :id")
|
||||||
fun getCardById(id: Long): Card?
|
fun getCardById(id: Long): Card?
|
||||||
|
|
||||||
|
@Query("SELECT * FROM Card WHERE LOWER(name) = LOWER(:name) LIMIT 1")
|
||||||
|
fun getCardByName(name: String): Card?
|
||||||
|
|
||||||
@Query(
|
@Query(
|
||||||
"""
|
"""
|
||||||
SELECT ca.*
|
SELECT ca.*
|
||||||
@ -30,6 +33,18 @@ interface CardDao {
|
|||||||
)
|
)
|
||||||
fun getCardByCharacterId(id: Long): Flow<Card>
|
fun getCardByCharacterId(id: Long): Flow<Card>
|
||||||
|
|
||||||
|
@Query(
|
||||||
|
"""
|
||||||
|
SELECT ca.*
|
||||||
|
FROM Card ca
|
||||||
|
JOIN CardCharacter ch ON ca.id = ch.cardId
|
||||||
|
JOIN UserCharacter uc ON ch.id = uc.charId
|
||||||
|
WHERE uc.id = :id
|
||||||
|
LIMIT 1
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
fun getCardByCharacterIdSync(id: Long): Card?
|
||||||
|
|
||||||
@Query("UPDATE Card SET name = :newName WHERE id = :id")
|
@Query("UPDATE Card SET name = :newName WHERE id = :id")
|
||||||
suspend fun renameCard(id: Int, newName: String)
|
suspend fun renameCard(id: Int, newName: String)
|
||||||
|
|
||||||
|
|||||||
@ -24,6 +24,9 @@ interface CardProgressDao {
|
|||||||
)
|
)
|
||||||
fun getCardProgress(cardId: Long): Flow<Int>
|
fun getCardProgress(cardId: Long): Flow<Int>
|
||||||
|
|
||||||
|
@Query("SELECT currentStage FROM CardProgress WHERE cardId = :cardId LIMIT 1")
|
||||||
|
fun getCardProgressSync(cardId: Long): Int?
|
||||||
|
|
||||||
@Insert
|
@Insert
|
||||||
fun insertCardProgress(cardProgress: CardProgress)
|
fun insertCardProgress(cardProgress: CardProgress)
|
||||||
}
|
}
|
||||||
@ -0,0 +1,17 @@
|
|||||||
|
package com.github.nacabaro.vbhelper.daos
|
||||||
|
|
||||||
|
import androidx.room.Dao
|
||||||
|
import androidx.room.Query
|
||||||
|
import androidx.room.Upsert
|
||||||
|
import com.github.nacabaro.vbhelper.domain.device_data.CharacterTransferPolicy
|
||||||
|
|
||||||
|
@Dao
|
||||||
|
interface CharacterTransferPolicyDao {
|
||||||
|
@Query("SELECT * FROM CharacterTransferPolicy WHERE characterId = :characterId")
|
||||||
|
fun getByCharacterId(characterId: Long): CharacterTransferPolicy?
|
||||||
|
|
||||||
|
@Upsert
|
||||||
|
fun upsert(policy: CharacterTransferPolicy)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -0,0 +1,7 @@
|
|||||||
|
package com.github.nacabaro.vbhelper.daos
|
||||||
|
|
||||||
|
import com.github.cfogrady.vitalwear.common.data.SharedTransferSeenDao
|
||||||
|
|
||||||
|
@Deprecated("Use SharedTransferSeenDao from the shared transfer database.")
|
||||||
|
typealias TransferSeenDao = SharedTransferSeenDao
|
||||||
|
|
||||||
@ -55,6 +55,22 @@ interface UserCharacterDao {
|
|||||||
)
|
)
|
||||||
fun getTransformationHistory(monId: Long): Flow<List<CharacterDtos.TransformationHistory>>
|
fun getTransformationHistory(monId: Long): Flow<List<CharacterDtos.TransformationHistory>>
|
||||||
|
|
||||||
|
@Query(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
t.stageId as stageId,
|
||||||
|
c.charaIndex AS monIndex,
|
||||||
|
ca.name as cardName,
|
||||||
|
t.transformationDate AS transformationDate
|
||||||
|
FROM TransformationHistory t
|
||||||
|
JOIN CardCharacter c ON c.id = t.stageId
|
||||||
|
JOIN Card ca ON ca.id = c.cardId
|
||||||
|
WHERE t.monId = :monId
|
||||||
|
ORDER BY t.transformationDate ASC, t.id ASC
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
suspend fun getTransformationHistoryForExport(monId: Long): List<CharacterDtos.TransformationHistoryExport>
|
||||||
|
|
||||||
@Query(
|
@Query(
|
||||||
"""
|
"""
|
||||||
SELECT
|
SELECT
|
||||||
|
|||||||
@ -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)
|
||||||
|
}
|
||||||
|
|
||||||
@ -0,0 +1,32 @@
|
|||||||
|
package com.github.nacabaro.vbhelper.domain.device_data
|
||||||
|
|
||||||
|
import androidx.room.Entity
|
||||||
|
import androidx.room.ForeignKey
|
||||||
|
import androidx.room.PrimaryKey
|
||||||
|
import com.github.nacabaro.vbhelper.transfer.ExportFormat
|
||||||
|
import com.github.nacabaro.vbhelper.transfer.TransferTarget
|
||||||
|
import com.github.nacabaro.vbhelper.transfer.TransferTransport
|
||||||
|
import com.github.nacabaro.vbhelper.utils.DeviceType
|
||||||
|
|
||||||
|
@Entity(
|
||||||
|
foreignKeys = [
|
||||||
|
ForeignKey(
|
||||||
|
entity = UserCharacter::class,
|
||||||
|
parentColumns = ["id"],
|
||||||
|
childColumns = ["characterId"],
|
||||||
|
onDelete = ForeignKey.CASCADE,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
data class CharacterTransferPolicy(
|
||||||
|
@PrimaryKey val characterId: Long,
|
||||||
|
val nativeDeviceType: DeviceType,
|
||||||
|
val preferredHceExportFormat: ExportFormat,
|
||||||
|
val preferredNfcaExportFormat: ExportFormat,
|
||||||
|
val lastObservedImportFormat: ExportFormat?,
|
||||||
|
val lastTransferTransport: TransferTransport?,
|
||||||
|
val lastTransferTarget: TransferTarget?,
|
||||||
|
val preserveVbRoundTrip: Boolean,
|
||||||
|
val preserveBeRoundTrip: Boolean,
|
||||||
|
)
|
||||||
|
|
||||||
@ -0,0 +1,31 @@
|
|||||||
|
package com.github.nacabaro.vbhelper.domain.device_data
|
||||||
|
|
||||||
|
import com.github.nacabaro.vbhelper.utils.DeviceType
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UnifiedCharacter provides a single access point for common character data
|
||||||
|
* and bridges the specific data for VB and BE characters.
|
||||||
|
*/
|
||||||
|
data class UnifiedCharacter(
|
||||||
|
val userCharacter: UserCharacter,
|
||||||
|
val vbData: VBCharacterData? = null,
|
||||||
|
val beData: BECharacterData? = null
|
||||||
|
) {
|
||||||
|
val characterType: DeviceType get() = userCharacter.characterType
|
||||||
|
|
||||||
|
fun isVB() = characterType == DeviceType.VBDevice
|
||||||
|
fun isBE() = characterType == DeviceType.BEDevice
|
||||||
|
|
||||||
|
// Unified accessors for core stats
|
||||||
|
val trophies: Int get() = userCharacter.trophies
|
||||||
|
val ageInDays: Int get() = userCharacter.ageInDays
|
||||||
|
val mood: Int get() = userCharacter.mood
|
||||||
|
val vitalPoints: Int get() = userCharacter.vitalPoints
|
||||||
|
val injuryStatus = userCharacter.injuryStatus
|
||||||
|
|
||||||
|
// BE specific accessors with defaults
|
||||||
|
val trainingHp: Int get() = beData?.trainingHp ?: 0
|
||||||
|
val trainingAp: Int get() = beData?.trainingAp ?: 0
|
||||||
|
val trainingBp: Int get() = beData?.trainingBp ?: 0
|
||||||
|
val abilityRarity = beData?.abilityRarity
|
||||||
|
}
|
||||||
@ -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,
|
||||||
|
)
|
||||||
|
|
||||||
@ -0,0 +1,7 @@
|
|||||||
|
package com.github.nacabaro.vbhelper.domain.transfer
|
||||||
|
|
||||||
|
import com.github.cfogrady.vitalwear.common.data.SharedTransferSeenEntity
|
||||||
|
|
||||||
|
@Deprecated("Use SharedTransferSeenEntity from the shared transfer database.")
|
||||||
|
typealias TransferSeenEntity = SharedTransferSeenEntity
|
||||||
|
|
||||||
@ -52,6 +52,13 @@ object CharacterDtos {
|
|||||||
val transformationDate: Long
|
val transformationDate: Long
|
||||||
)
|
)
|
||||||
|
|
||||||
|
data class TransformationHistoryExport(
|
||||||
|
val stageId: Long,
|
||||||
|
val monIndex: Int,
|
||||||
|
val cardName: String,
|
||||||
|
val transformationDate: Long
|
||||||
|
)
|
||||||
|
|
||||||
data class CardCharaProgress(
|
data class CardCharaProgress(
|
||||||
val id: Long,
|
val id: Long,
|
||||||
val spriteIdle: ByteArray,
|
val spriteIdle: ByteArray,
|
||||||
|
|||||||
@ -54,6 +54,7 @@ data class AppNavigationHandlers(
|
|||||||
@Composable
|
@Composable
|
||||||
fun AppNavigation(
|
fun AppNavigation(
|
||||||
applicationNavigationHandlers: AppNavigationHandlers,
|
applicationNavigationHandlers: AppNavigationHandlers,
|
||||||
|
initialRoute: String? = null
|
||||||
) {
|
) {
|
||||||
val navController = rememberNavController()
|
val navController = rememberNavController()
|
||||||
|
|
||||||
@ -64,7 +65,7 @@ fun AppNavigation(
|
|||||||
) { contentPadding ->
|
) { contentPadding ->
|
||||||
NavHost(
|
NavHost(
|
||||||
navController = navController,
|
navController = navController,
|
||||||
startDestination = NavigationItems.Home.route,
|
startDestination = initialRoute ?: NavigationItems.Home.route,
|
||||||
enterTransition = {
|
enterTransition = {
|
||||||
fadeIn(
|
fadeIn(
|
||||||
animationSpec = tween(200)
|
animationSpec = tween(200)
|
||||||
|
|||||||
@ -30,12 +30,19 @@ fun BottomNavigationBar(navController: NavController) {
|
|||||||
label = { Text(text = stringResource(item.label)) },
|
label = { Text(text = stringResource(item.label)) },
|
||||||
selected = currentRoute == item.route,
|
selected = currentRoute == item.route,
|
||||||
onClick = {
|
onClick = {
|
||||||
|
if (item == NavigationItems.Home) {
|
||||||
|
navController.navigate(NavigationItems.Home.route) {
|
||||||
|
popUpTo(0) { inclusive = false }
|
||||||
|
launchSingleTop = true
|
||||||
|
}
|
||||||
|
} else {
|
||||||
navController.navigate(item.route) {
|
navController.navigate(item.route) {
|
||||||
popUpTo(navController.graph.startDestinationId) { saveState = true }
|
popUpTo(navController.graph.startDestinationId) { saveState = true }
|
||||||
launchSingleTop = true
|
launchSingleTop = true
|
||||||
restoreState = true
|
restoreState = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,6 +2,7 @@ package com.github.nacabaro.vbhelper.screens.homeScreens
|
|||||||
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
@ -40,6 +41,8 @@ import com.github.nacabaro.vbhelper.source.StorageRepository
|
|||||||
import com.github.nacabaro.vbhelper.R
|
import com.github.nacabaro.vbhelper.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
|
||||||
@ -136,6 +139,7 @@ fun HomeScreen(
|
|||||||
Text(text = stringResource(R.string.adventure_empty_state))
|
Text(text = stringResource(R.string.adventure_empty_state))
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
Column(modifier = Modifier.padding(top = contentPadding.calculateTopPadding())) {
|
||||||
val cardIcon = BitmapData(
|
val cardIcon = BitmapData(
|
||||||
bitmap = cardIconData!!.cardIcon,
|
bitmap = cardIconData!!.cardIcon,
|
||||||
width = cardIconData!!.cardIconWidth,
|
width = cardIconData!!.cardIconWidth,
|
||||||
@ -147,7 +151,7 @@ fun HomeScreen(
|
|||||||
activeMon = activeMon!!,
|
activeMon = activeMon!!,
|
||||||
beData = beData!!,
|
beData = beData!!,
|
||||||
transformationHistory = transformationHistory,
|
transformationHistory = transformationHistory,
|
||||||
contentPadding = contentPadding,
|
contentPadding = PaddingValues(0.dp),
|
||||||
cardIcon = cardIcon
|
cardIcon = cardIcon
|
||||||
)
|
)
|
||||||
} else if (!activeMon!!.isBemCard && activeMon!!.characterType == DeviceType.BEDevice && beData != null) {
|
} else if (!activeMon!!.isBemCard && activeMon!!.characterType == DeviceType.BEDevice && beData != null) {
|
||||||
@ -155,7 +159,7 @@ fun HomeScreen(
|
|||||||
activeMon = activeMon!!,
|
activeMon = activeMon!!,
|
||||||
beData = beData!!,
|
beData = beData!!,
|
||||||
transformationHistory = transformationHistory,
|
transformationHistory = transformationHistory,
|
||||||
contentPadding = contentPadding,
|
contentPadding = PaddingValues(0.dp),
|
||||||
cardIcon = cardIcon
|
cardIcon = cardIcon
|
||||||
)
|
)
|
||||||
} else if (vbData != null) {
|
} else if (vbData != null) {
|
||||||
@ -163,7 +167,7 @@ fun HomeScreen(
|
|||||||
activeMon = activeMon!!,
|
activeMon = activeMon!!,
|
||||||
vbData = vbData!!,
|
vbData = vbData!!,
|
||||||
transformationHistory = transformationHistory,
|
transformationHistory = transformationHistory,
|
||||||
contentPadding = contentPadding,
|
contentPadding = PaddingValues(0.dp),
|
||||||
specialMissions = vbSpecialMissions,
|
specialMissions = vbSpecialMissions,
|
||||||
homeScreenController = homeScreenController,
|
homeScreenController = homeScreenController,
|
||||||
onClickCollect = { item, currency ->
|
onClickCollect = { item, currency ->
|
||||||
@ -173,6 +177,29 @@ fun HomeScreen(
|
|||||||
cardIcon = cardIcon
|
cardIcon = cardIcon
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
try {
|
||||||
|
val intent = VitalWearCharacterExporter(application, application.container.db)
|
||||||
|
.buildShareIntent(activeMon!!.id)
|
||||||
|
.addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
application.startActivity(intent)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Toast.makeText(
|
||||||
|
application,
|
||||||
|
"Could not send character to VitalWear: ${e.message}",
|
||||||
|
Toast.LENGTH_LONG
|
||||||
|
).show()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(16.dp)
|
||||||
|
) {
|
||||||
|
Text(text = "Send to VitalWear")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -221,5 +248,3 @@ fun HomeScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -0,0 +1,241 @@
|
|||||||
|
package com.github.nacabaro.vbhelper.screens.homeScreens.screens
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.aspectRatio
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import com.github.nacabaro.vbhelper.R
|
||||||
|
import com.github.nacabaro.vbhelper.screens.homeScreens.dialogs.DeleteSpecialMissionDialog
|
||||||
|
import com.github.nacabaro.vbhelper.components.CharacterEntry
|
||||||
|
import com.github.nacabaro.vbhelper.components.ItemDisplay
|
||||||
|
import com.github.nacabaro.vbhelper.components.SpecialMissionsEntry
|
||||||
|
import com.github.nacabaro.vbhelper.components.TransformationHistoryCard
|
||||||
|
import com.github.nacabaro.vbhelper.domain.device_data.SpecialMissions
|
||||||
|
import com.github.nacabaro.vbhelper.domain.device_data.UnifiedCharacter
|
||||||
|
import com.github.nacabaro.vbhelper.dtos.CharacterDtos
|
||||||
|
import com.github.nacabaro.vbhelper.dtos.ItemDtos
|
||||||
|
import com.github.nacabaro.vbhelper.screens.homeScreens.HomeScreenControllerImpl
|
||||||
|
import com.github.nacabaro.vbhelper.screens.itemsScreen.getIconResource
|
||||||
|
import com.github.nacabaro.vbhelper.screens.itemsScreen.ItemsScreenControllerImpl
|
||||||
|
import com.github.nacabaro.vbhelper.utils.BitmapData
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun UnifiedHomeScreen(
|
||||||
|
activeMon: CharacterDtos.CharacterWithSprites,
|
||||||
|
cardIcon: BitmapData,
|
||||||
|
unifiedChar: UnifiedCharacter,
|
||||||
|
specialMissions: List<SpecialMissions>,
|
||||||
|
transformationHistory: List<CharacterDtos.TransformationHistory>,
|
||||||
|
contentPadding: PaddingValues,
|
||||||
|
homeScreenController: HomeScreenControllerImpl,
|
||||||
|
onClickCollect: (ItemDtos.PurchasedItem?, Int?) -> Unit
|
||||||
|
) {
|
||||||
|
var selectedSpecialMissionId by remember { mutableStateOf<Long>(-1) }
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(top = contentPadding.calculateTopPadding())
|
||||||
|
.verticalScroll(state = rememberScrollState())
|
||||||
|
) {
|
||||||
|
// CORE SLOT: Always the same for VB and BE
|
||||||
|
Row(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
CharacterEntry(
|
||||||
|
icon = BitmapData(
|
||||||
|
bitmap = activeMon.spriteIdle,
|
||||||
|
width = activeMon.spriteWidth,
|
||||||
|
height = activeMon.spriteHeight
|
||||||
|
),
|
||||||
|
cardIcon = cardIcon,
|
||||||
|
multiplier = 8,
|
||||||
|
shape = androidx.compose.material.MaterialTheme.shapes.small,
|
||||||
|
modifier = Modifier.weight(1f).aspectRatio(1f)
|
||||||
|
)
|
||||||
|
Column(modifier = Modifier.weight(0.5f).aspectRatio(0.5f)) {
|
||||||
|
ItemDisplay(
|
||||||
|
icon = R.drawable.baseline_vitals_24,
|
||||||
|
textValue = activeMon.vitalPoints.toString(),
|
||||||
|
definition = stringResource(R.string.home_vbdim_vitals),
|
||||||
|
modifier = Modifier.weight(0.5f).aspectRatio(1f).padding(8.dp)
|
||||||
|
)
|
||||||
|
ItemDisplay(
|
||||||
|
icon = R.drawable.baseline_trophy_24,
|
||||||
|
textValue = activeMon.trophies.toString(),
|
||||||
|
definition = stringResource(R.string.home_vbdim_trophies),
|
||||||
|
modifier = Modifier.weight(0.5f).aspectRatio(1f).padding(8.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// STATS ROW 1: mood and timing (VB timer vs BE training limit)
|
||||||
|
Row(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
ItemDisplay(
|
||||||
|
icon = R.drawable.baseline_mood_24,
|
||||||
|
textValue = activeMon.mood.toString(),
|
||||||
|
definition = stringResource(R.string.home_vbdim_mood),
|
||||||
|
modifier = Modifier.weight(1f).aspectRatio(1f).padding(8.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
if (unifiedChar.isVB()) {
|
||||||
|
val transformationCountdownInHours = activeMon.transformationCountdown / 60
|
||||||
|
ItemDisplay(
|
||||||
|
icon = R.drawable.baseline_next_24,
|
||||||
|
textValue = when (transformationCountdownInHours) {
|
||||||
|
0 -> "${activeMon.transformationCountdown} m"
|
||||||
|
else -> "$transformationCountdownInHours h"
|
||||||
|
},
|
||||||
|
definition = stringResource(R.string.home_vbdim_next_timer),
|
||||||
|
modifier = Modifier.weight(1f).aspectRatio(1f).padding(8.dp)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
val timeInHours = (unifiedChar.beData?.remainingTrainingTimeInMinutes ?: 0) / 60
|
||||||
|
ItemDisplay(
|
||||||
|
icon = R.drawable.baseline_timer_24,
|
||||||
|
textValue = "$timeInHours h",
|
||||||
|
definition = "Training limit",
|
||||||
|
modifier = Modifier.weight(1f).aspectRatio(1f).padding(8.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (unifiedChar.isBE()) {
|
||||||
|
ItemDisplay(
|
||||||
|
icon = R.drawable.baseline_rank_24,
|
||||||
|
textValue = (unifiedChar.beData?.rank ?: 0).toString(),
|
||||||
|
definition = "Rank",
|
||||||
|
modifier = Modifier.weight(1f).aspectRatio(1f).padding(8.dp)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
// For VB, maybe put a spacer or other stat here to keep row balance
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// STATS ROW 2: Battle stats and Items
|
||||||
|
Row(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
val transformationCountdownInHours = activeMon.transformationCountdown / 60
|
||||||
|
ItemDisplay(
|
||||||
|
icon = R.drawable.baseline_next_24,
|
||||||
|
textValue = when (transformationCountdownInHours) {
|
||||||
|
0 -> "${activeMon.transformationCountdown} m"
|
||||||
|
else -> "$transformationCountdownInHours h"
|
||||||
|
},
|
||||||
|
definition = "Next timer",
|
||||||
|
modifier = Modifier.weight(1f).aspectRatio(1f).padding(8.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Battle stats (Common)
|
||||||
|
ItemDisplay(
|
||||||
|
icon = R.drawable.baseline_swords_24,
|
||||||
|
textValue = calculateWinPercent(activeMon.totalBattlesWon, activeMon.totalBattlesLost),
|
||||||
|
definition = "Total battle win %",
|
||||||
|
modifier = Modifier.weight(1f).aspectRatio(1f).padding(8.dp)
|
||||||
|
)
|
||||||
|
ItemDisplay(
|
||||||
|
icon = R.drawable.baseline_swords_24,
|
||||||
|
textValue = calculateWinPercent(activeMon.currentPhaseBattlesWon, activeMon.currentPhaseBattlesLost),
|
||||||
|
definition = "Current phase win %",
|
||||||
|
modifier = Modifier.weight(1f).aspectRatio(1f).padding(8.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
if (unifiedChar.isBE()) {
|
||||||
|
val beData = unifiedChar.beData
|
||||||
|
if (beData != null && beData.itemRemainingTime != 0) {
|
||||||
|
ItemDisplay(
|
||||||
|
icon = getIconResource(beData.itemType),
|
||||||
|
textValue = "${beData.itemRemainingTime} m",
|
||||||
|
definition = getItemTypeName(beData.itemType),
|
||||||
|
modifier = Modifier.weight(1f).aspectRatio(1f).padding(8.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HISTORY SLOT: Common
|
||||||
|
Row(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
TransformationHistoryCard(
|
||||||
|
transformationHistory = transformationHistory,
|
||||||
|
modifier = Modifier.weight(1f).padding(8.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CONTEXTUAL BOTTOM SLOT
|
||||||
|
if (unifiedChar.isVB()) {
|
||||||
|
Row(modifier = Modifier.padding(16.dp)) {
|
||||||
|
Text(text = stringResource(R.string.home_vbdim_special_missions), fontSize = 24.sp)
|
||||||
|
}
|
||||||
|
for (mission in specialMissions) {
|
||||||
|
Row(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
SpecialMissionsEntry(
|
||||||
|
specialMission = mission,
|
||||||
|
modifier = Modifier.weight(1f).padding(8.dp),
|
||||||
|
onClickMission = { missionId -> selectedSpecialMissionId = missionId },
|
||||||
|
onClickCollect = { homeScreenController.clearSpecialMission(selectedSpecialMissionId, onClickCollect) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (unifiedChar.isBE()) {
|
||||||
|
Row(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
val beData = unifiedChar.beData ?: return@Column
|
||||||
|
ItemDisplay(
|
||||||
|
icon = R.drawable.baseline_health_24,
|
||||||
|
textValue = "+${beData.trainingHp}",
|
||||||
|
definition = "Training HP",
|
||||||
|
modifier = Modifier.weight(1f).aspectRatio(1f).padding(8.dp)
|
||||||
|
)
|
||||||
|
ItemDisplay(
|
||||||
|
icon = R.drawable.baseline_agility_24,
|
||||||
|
textValue = "+${beData.trainingBp}",
|
||||||
|
definition = "Training BP",
|
||||||
|
modifier = Modifier.weight(1f).aspectRatio(1f).padding(8.dp)
|
||||||
|
)
|
||||||
|
ItemDisplay(
|
||||||
|
icon = R.drawable.baseline_attack_24,
|
||||||
|
textValue = "+${beData.trainingAp}",
|
||||||
|
definition = "Training AP",
|
||||||
|
modifier = Modifier.weight(1f).aspectRatio(1f).padding(8.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedSpecialMissionId.toInt() != -1 && unifiedChar.isVB()) {
|
||||||
|
DeleteSpecialMissionDialog(
|
||||||
|
onClickDismiss = { selectedSpecialMissionId = -1 },
|
||||||
|
onClickDelete = {
|
||||||
|
homeScreenController.clearSpecialMission(selectedSpecialMissionId, onClickCollect)
|
||||||
|
selectedSpecialMissionId = -1
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun calculateWinPercent(won: Int, lost: Int): String {
|
||||||
|
return if (lost == 0) "0.00 %" else {
|
||||||
|
val percentage = won.toFloat() / (won + lost).toFloat()
|
||||||
|
String.format(Locale.getDefault(), "%.2f", percentage * 100) + " %"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getItemTypeName(itemId: Int): String {
|
||||||
|
return when (itemId) {
|
||||||
|
ItemsScreenControllerImpl.ItemTypes.PPTraining.id -> "PP Training"
|
||||||
|
ItemsScreenControllerImpl.ItemTypes.HPTraining.id -> "HP Training"
|
||||||
|
ItemsScreenControllerImpl.ItemTypes.APTraining.id -> "AP Training"
|
||||||
|
ItemsScreenControllerImpl.ItemTypes.BPTraining.id -> "BP Training"
|
||||||
|
ItemsScreenControllerImpl.ItemTypes.AllTraining.id -> "All Training"
|
||||||
|
else -> ""
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -42,6 +42,11 @@ fun ChooseConnectOption(
|
|||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(contentPadding)
|
.padding(contentPadding)
|
||||||
) {
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Bandai Toys = Vital Bracelet, Vital Hero, BE Bracelet",
|
||||||
|
fontSize = 14.sp,
|
||||||
|
modifier = Modifier.padding(bottom = 24.dp)
|
||||||
|
)
|
||||||
ScanButton(
|
ScanButton(
|
||||||
text = stringResource(R.string.scan_vb_to_app),
|
text = stringResource(R.string.scan_vb_to_app),
|
||||||
disabled = onClickRead == null,
|
disabled = onClickRead == null,
|
||||||
|
|||||||
@ -0,0 +1,8 @@
|
|||||||
|
package com.github.nacabaro.vbhelper.screens.scanScreen
|
||||||
|
|
||||||
|
enum class DetectedTransport {
|
||||||
|
UNKNOWN,
|
||||||
|
NFC_A,
|
||||||
|
ISO_DEP,
|
||||||
|
}
|
||||||
|
|
||||||
@ -47,14 +47,22 @@ class ScanScreenControllerImpl(
|
|||||||
|
|
||||||
override fun onClickRead(secrets: Secrets, onComplete: ()->Unit, onMultipleCards: (List<Card>) -> Unit) {
|
override fun onClickRead(secrets: Secrets, onComplete: ()->Unit, onMultipleCards: (List<Card>) -> Unit) {
|
||||||
handleTag(secrets) { tagCommunicator ->
|
handleTag(secrets) { tagCommunicator ->
|
||||||
|
try {
|
||||||
val character = tagCommunicator.receiveCharacter()
|
val character = tagCommunicator.receiveCharacter()
|
||||||
val resultMessage = characterFromNfc(character) { cards, nfcCharacter ->
|
val resultMessage = characterFromNfc(character) { cards, nfcCharacter ->
|
||||||
lastScannedCharacter = nfcCharacter
|
lastScannedCharacter = nfcCharacter
|
||||||
onMultipleCards(cards)
|
onMultipleCards(cards)
|
||||||
|
|
||||||
}
|
}
|
||||||
onComplete.invoke()
|
onComplete.invoke()
|
||||||
resultMessage
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -0,0 +1,51 @@
|
|||||||
|
package com.github.nacabaro.vbhelper.screens.scanScreen
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.VibrationEffect
|
||||||
|
import android.os.Vibrator
|
||||||
|
import android.os.VibratorManager
|
||||||
|
import timber.log.Timber
|
||||||
|
|
||||||
|
class TransferHaptics(context: Context) {
|
||||||
|
private val vibrator: Vibrator? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||||
|
val manager = context.getSystemService(VibratorManager::class.java)
|
||||||
|
manager?.defaultVibrator
|
||||||
|
} else {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
context.getSystemService(Context.VIBRATOR_SERVICE) as? Vibrator
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onTransferStart() {
|
||||||
|
vibrate(VibrationEffect.createOneShot(100L, VibrationEffect.DEFAULT_AMPLITUDE))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onTransferProgress() {
|
||||||
|
vibrate(VibrationEffect.createOneShot(50L, VibrationEffect.DEFAULT_AMPLITUDE))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onTransferComplete() {
|
||||||
|
vibrate(VibrationEffect.createWaveform(longArrayOf(0L, 100L, 80L, 150L), -1))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onTransferFailed() {
|
||||||
|
vibrate(VibrationEffect.createOneShot(250L, VibrationEffect.DEFAULT_AMPLITUDE))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onDeviceDetected() {
|
||||||
|
vibrate(VibrationEffect.createOneShot(80L, VibrationEffect.DEFAULT_AMPLITUDE))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun vibrate(effect: VibrationEffect) {
|
||||||
|
val deviceVibrator = vibrator ?: return
|
||||||
|
if (!deviceVibrator.hasVibrator()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
runCatching {
|
||||||
|
deviceVibrator.vibrate(effect)
|
||||||
|
}.onFailure { error ->
|
||||||
|
Timber.w(error, "Transfer haptics vibration skipped")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@ -0,0 +1,267 @@
|
|||||||
|
package com.github.nacabaro.vbhelper.screens.scanScreen.screens
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.FastOutLinearInEasing
|
||||||
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
|
import androidx.compose.animation.core.animateIntAsState
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.scale
|
||||||
|
import androidx.compose.ui.graphics.FilterQuality
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import com.github.nacabaro.vbhelper.components.TopBanner
|
||||||
|
import com.github.nacabaro.vbhelper.R
|
||||||
|
import com.github.nacabaro.vbhelper.utils.ImageBitmapData
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun TransferAnimationScreen(
|
||||||
|
topBannerText: String,
|
||||||
|
detectedTransportMessage: String?,
|
||||||
|
transferStatusMessage: String?,
|
||||||
|
characterPreview: ImageBitmapData? = null,
|
||||||
|
onClickCancel: () -> Unit,
|
||||||
|
isTransferring: Boolean = true,
|
||||||
|
) {
|
||||||
|
var pulseScale by remember { mutableStateOf(1f) }
|
||||||
|
var animationProgress by remember { mutableStateOf(0) }
|
||||||
|
|
||||||
|
val scale by animateFloatAsState(
|
||||||
|
targetValue = pulseScale,
|
||||||
|
animationSpec = tween(durationMillis = 800),
|
||||||
|
label = "pulse_scale"
|
||||||
|
)
|
||||||
|
|
||||||
|
val progress by animateIntAsState(
|
||||||
|
targetValue = animationProgress,
|
||||||
|
animationSpec = tween(durationMillis = 100, easing = FastOutLinearInEasing),
|
||||||
|
label = "progress"
|
||||||
|
)
|
||||||
|
|
||||||
|
LaunchedEffect(isTransferring) {
|
||||||
|
if (isTransferring) {
|
||||||
|
while (true) {
|
||||||
|
pulseScale = 1.2f
|
||||||
|
delay(400)
|
||||||
|
pulseScale = 1f
|
||||||
|
delay(400)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(isTransferring) {
|
||||||
|
if (isTransferring) {
|
||||||
|
while (animationProgress < 100) {
|
||||||
|
delay(50)
|
||||||
|
animationProgress += 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
TopBanner(
|
||||||
|
text = topBannerText,
|
||||||
|
onBackClick = onClickCancel
|
||||||
|
)
|
||||||
|
}
|
||||||
|
) { innerPadding ->
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.Center,
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(innerPadding)
|
||||||
|
.fillMaxSize()
|
||||||
|
) {
|
||||||
|
characterPreview?.let { preview ->
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.padding(bottom = 24.dp)
|
||||||
|
) {
|
||||||
|
Image(
|
||||||
|
bitmap = preview.imageBitmap,
|
||||||
|
contentDescription = "Transferred Digimon sprite",
|
||||||
|
modifier = Modifier
|
||||||
|
.size(preview.dpWidth, preview.dpHeight)
|
||||||
|
.padding(8.dp),
|
||||||
|
filterQuality = FilterQuality.None
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Animated pulsing circle indicator
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(120.dp)
|
||||||
|
.scale(scale)
|
||||||
|
.background(
|
||||||
|
color = Color(0xFF6366F1).copy(alpha = 0.2f),
|
||||||
|
shape = CircleShape
|
||||||
|
),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(80.dp)
|
||||||
|
.background(
|
||||||
|
color = Color(0xFF6366F1),
|
||||||
|
shape = CircleShape
|
||||||
|
),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "🔄",
|
||||||
|
fontSize = 40.sp,
|
||||||
|
modifier = Modifier.scale(if (scale > 1f) 1.1f else 1f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.action_place_near_reader),
|
||||||
|
fontSize = 16.sp,
|
||||||
|
modifier = Modifier.padding(top = 24.dp),
|
||||||
|
fontWeight = FontWeight.Medium
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!detectedTransportMessage.isNullOrBlank()) {
|
||||||
|
Text(
|
||||||
|
text = detectedTransportMessage,
|
||||||
|
modifier = Modifier.padding(top = 12.dp, start = 16.dp, end = 16.dp),
|
||||||
|
fontSize = 14.sp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!transferStatusMessage.isNullOrBlank()) {
|
||||||
|
Text(
|
||||||
|
text = transferStatusMessage,
|
||||||
|
modifier = Modifier.padding(top = 12.dp, start = 16.dp, end = 16.dp),
|
||||||
|
fontSize = 14.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = Color(0xFF6366F1)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Progress ring
|
||||||
|
if (isTransferring) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
progress = { progress / 100f },
|
||||||
|
modifier = Modifier
|
||||||
|
.size(100.dp)
|
||||||
|
.padding(top = 24.dp),
|
||||||
|
strokeWidth = 4.dp
|
||||||
|
)
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = "$progress%",
|
||||||
|
modifier = Modifier.padding(top = 8.dp),
|
||||||
|
fontSize = 12.sp,
|
||||||
|
color = Color.Gray
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Button(
|
||||||
|
onClick = onClickCancel,
|
||||||
|
modifier = Modifier.padding(top = 32.dp, bottom = 16.dp)
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.action_cancel))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun TransferCompleteScreen(
|
||||||
|
topBannerText: String,
|
||||||
|
resultMessage: String,
|
||||||
|
onClickOk: () -> Unit,
|
||||||
|
isSuccess: Boolean = true,
|
||||||
|
) {
|
||||||
|
var showCheckmark by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
val scale by animateFloatAsState(
|
||||||
|
targetValue = if (showCheckmark) 1f else 0f,
|
||||||
|
animationSpec = tween(durationMillis = 600, easing = FastOutLinearInEasing),
|
||||||
|
label = "checkmark_scale"
|
||||||
|
)
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
delay(300)
|
||||||
|
showCheckmark = true
|
||||||
|
}
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
TopBanner(
|
||||||
|
text = topBannerText,
|
||||||
|
onBackClick = {}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
) { innerPadding ->
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.Center,
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(innerPadding)
|
||||||
|
.fillMaxSize()
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(120.dp)
|
||||||
|
.scale(scale)
|
||||||
|
.background(
|
||||||
|
color = if (isSuccess) Color(0xFF10B981) else Color(0xFFEF4444),
|
||||||
|
shape = CircleShape
|
||||||
|
),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = if (isSuccess) "✓" else "✕",
|
||||||
|
fontSize = 60.sp,
|
||||||
|
color = Color.White,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = resultMessage,
|
||||||
|
fontSize = 16.sp,
|
||||||
|
modifier = Modifier.padding(top = 24.dp, start = 16.dp, end = 16.dp),
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
)
|
||||||
|
|
||||||
|
Button(
|
||||||
|
onClick = onClickOk,
|
||||||
|
modifier = Modifier.padding(top = 32.dp, bottom = 16.dp)
|
||||||
|
) {
|
||||||
|
Text("OK")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@ -38,6 +38,8 @@ fun CreditsScreen(
|
|||||||
SettingsEntry(title = "nacabaro", description = stringResource(R.string.credits_nacabaro_description)) { }
|
SettingsEntry(title = "nacabaro", description = stringResource(R.string.credits_nacabaro_description)) { }
|
||||||
SettingsEntry(title = "lightheel", description = stringResource(R.string.credits_lightheel_description)) { }
|
SettingsEntry(title = "lightheel", description = stringResource(R.string.credits_lightheel_description)) { }
|
||||||
SettingsEntry(title = "shvstrz", description = stringResource(R.string.credits_shvstrz_description)) { }
|
SettingsEntry(title = "shvstrz", description = stringResource(R.string.credits_shvstrz_description)) { }
|
||||||
|
|
||||||
|
SettingsEntry(title = "redeyez", description = stringResource(R.string.credits_RedEyez_description)) { }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -33,6 +33,7 @@ import com.github.nacabaro.vbhelper.di.VBHelper
|
|||||||
import com.github.nacabaro.vbhelper.navigation.NavigationItems
|
import com.github.nacabaro.vbhelper.navigation.NavigationItems
|
||||||
import com.github.nacabaro.vbhelper.screens.adventureScreen.AdventureScreenControllerImpl
|
import com.github.nacabaro.vbhelper.screens.adventureScreen.AdventureScreenControllerImpl
|
||||||
import com.github.nacabaro.vbhelper.source.StorageRepository
|
import com.github.nacabaro.vbhelper.source.StorageRepository
|
||||||
|
import com.github.nacabaro.vbhelper.source.VitalWearCharacterExporter
|
||||||
import com.github.nacabaro.vbhelper.utils.BitmapData
|
import com.github.nacabaro.vbhelper.utils.BitmapData
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import com.github.nacabaro.vbhelper.R
|
import com.github.nacabaro.vbhelper.R
|
||||||
|
|||||||
@ -0,0 +1,28 @@
|
|||||||
|
package com.github.nacabaro.vbhelper.source
|
||||||
|
|
||||||
|
import androidx.datastore.core.DataStore
|
||||||
|
import androidx.datastore.preferences.core.Preferences
|
||||||
|
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||||
|
import androidx.datastore.preferences.core.edit
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
|
||||||
|
class CardSettingsRepository(
|
||||||
|
private val dataStore: DataStore<Preferences>
|
||||||
|
) {
|
||||||
|
private companion object {
|
||||||
|
val ENABLE_DIM_TO_BEM_CONVERSION = booleanPreferencesKey("enable_dim_to_bem_conversion")
|
||||||
|
}
|
||||||
|
|
||||||
|
val enableDimToBemConversion: Flow<Boolean> = dataStore.data
|
||||||
|
.map { preferences ->
|
||||||
|
preferences[ENABLE_DIM_TO_BEM_CONVERSION] ?: false
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setEnableDimToBemConversion(enable: Boolean) {
|
||||||
|
dataStore.edit { preferences ->
|
||||||
|
preferences[ENABLE_DIM_TO_BEM_CONVERSION] = enable
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@ -0,0 +1,54 @@
|
|||||||
|
package com.github.nacabaro.vbhelper.source
|
||||||
|
|
||||||
|
import com.github.nacabaro.vbhelper.database.AppDatabase
|
||||||
|
import com.github.nacabaro.vbhelper.domain.device_data.VBCharacterData
|
||||||
|
import com.github.nacabaro.vbhelper.utils.DeviceType
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Utility for converting character device types.
|
||||||
|
* Used to fix mismatched device types when sending to physical NFC devices.
|
||||||
|
*/
|
||||||
|
class DeviceTypeConverter(private val database: AppDatabase) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prepares a BE character for VB-target exports without deleting BE stats.
|
||||||
|
* This keeps the BE profile intact while ensuring a VB sidecar exists.
|
||||||
|
*/
|
||||||
|
suspend fun convertBEToVB(characterId: Long): Boolean = withContext(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
val userCharacter = database.userCharacterDao().getCharacter(characterId)
|
||||||
|
|
||||||
|
if (userCharacter.characterType != DeviceType.BEDevice) {
|
||||||
|
// Already VB or other type, no conversion needed
|
||||||
|
return@withContext true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep BE typing/stats as-is; only ensure a VB sidecar profile exists for exports.
|
||||||
|
ensureVBCharacterDataExists(characterId)
|
||||||
|
|
||||||
|
true
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// Log but don't crash - character can still be sent as-is
|
||||||
|
android.util.Log.e("DeviceTypeConverter", "Failed to convert character $characterId to VB type", e)
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun ensureVBCharacterDataExists(characterId: Long) {
|
||||||
|
val vbData = database.userCharacterDao().getVbDataOrNull(characterId)
|
||||||
|
if (vbData == null) {
|
||||||
|
val vbCharacterData = VBCharacterData(
|
||||||
|
id = characterId,
|
||||||
|
generation = 0,
|
||||||
|
totalTrophies = 0
|
||||||
|
)
|
||||||
|
database.userCharacterDao().insertVBCharacterData(vbCharacterData)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@ -0,0 +1,114 @@
|
|||||||
|
package com.github.nacabaro.vbhelper.source
|
||||||
|
|
||||||
|
import android.content.ClipData
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import androidx.core.content.FileProvider
|
||||||
|
import com.github.cfogrady.vitalwear.protos.Character
|
||||||
|
import com.github.nacabaro.vbhelper.database.AppDatabase
|
||||||
|
import com.github.nacabaro.vbhelper.utils.DeviceType
|
||||||
|
import kotlinx.coroutines.flow.firstOrNull
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
class VitalWearCharacterExporter(
|
||||||
|
private val context: Context,
|
||||||
|
private val database: AppDatabase
|
||||||
|
) {
|
||||||
|
fun buildShareIntent(characterId: Long): Intent {
|
||||||
|
return runBlocking {
|
||||||
|
val characterWithSprites = database.userCharacterDao().getCharacterWithSprites(characterId)
|
||||||
|
val userCharacter = database.userCharacterDao().getCharacter(characterId)
|
||||||
|
val card = database.cardDao().getCardByCharacterIdSync(characterId)
|
||||||
|
?: error("Card not found for character $characterId")
|
||||||
|
val cardProgress = database.cardProgressDao().getCardProgressSync(card.id) ?: 0
|
||||||
|
val proto = Character.newBuilder()
|
||||||
|
.setCardId(card.cardId)
|
||||||
|
.setCardName(card.name)
|
||||||
|
.setCharacterStats(
|
||||||
|
Character.CharacterStats.newBuilder()
|
||||||
|
.setSlotId(database.userCharacterDao().getCharacterInfo(characterId).charaIndex)
|
||||||
|
.setVitals(characterWithSprites.vitalPoints)
|
||||||
|
.setTrainingTimeRemainingInSeconds(resolveTrainingSeconds(characterId, userCharacter.characterType))
|
||||||
|
.setTimeUntilNextTransformation(characterWithSprites.transformationCountdown.toLong() * 60L)
|
||||||
|
.setTrainedBp(resolveTrainedBp(characterId, userCharacter.characterType))
|
||||||
|
.setTrainedHp(resolveTrainedHp(characterId, userCharacter.characterType))
|
||||||
|
.setTrainedAp(resolveTrainedAp(characterId, userCharacter.characterType))
|
||||||
|
.setTrainedPp(characterWithSprites.trophies)
|
||||||
|
.setInjured(characterWithSprites.injuryStatus.name.lowercase().contains("inj"))
|
||||||
|
.setAccumulatedDailyInjuries(0)
|
||||||
|
.setTotalBattles(characterWithSprites.totalBattlesWon + characterWithSprites.totalBattlesLost)
|
||||||
|
.setCurrentPhaseBattles(characterWithSprites.currentPhaseBattlesWon + characterWithSprites.currentPhaseBattlesLost)
|
||||||
|
.setTotalWins(characterWithSprites.totalBattlesWon)
|
||||||
|
.setCurrentPhaseWins(characterWithSprites.currentPhaseBattlesWon)
|
||||||
|
.setMood(characterWithSprites.mood)
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
.setSettings(
|
||||||
|
Character.Settings.newBuilder()
|
||||||
|
.setTrainingInBackground(false)
|
||||||
|
.setAllowedBattles(Character.Settings.AllowedBattles.CARD_ONLY)
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
.putMaxAdventureCompletedByCard(card.name, (cardProgress - 1).coerceAtLeast(0))
|
||||||
|
.addAllTransformationHistory(
|
||||||
|
database.userCharacterDao().getTransformationHistoryForExport(characterId).map {
|
||||||
|
Character.TransformationEvent.newBuilder()
|
||||||
|
.setCardName(it.cardName)
|
||||||
|
.setPhase(0)
|
||||||
|
.setSlotId(it.monIndex)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val exportDir = File(context.cacheDir, "exports").apply { mkdirs() }
|
||||||
|
val exportFile = File(exportDir, "vbhelper_character_$characterId.vitalwear")
|
||||||
|
exportFile.writeBytes(proto.toByteArray())
|
||||||
|
val exportUri = FileProvider.getUriForFile(context, "${context.packageName}.provider", exportFile)
|
||||||
|
|
||||||
|
Intent(Intent.ACTION_SEND).apply {
|
||||||
|
`package` = "com.github.cfogrady.vitalwear"
|
||||||
|
type = VITALWEAR_CHARACTER_MIME
|
||||||
|
putExtra(Intent.EXTRA_STREAM, exportUri)
|
||||||
|
clipData = ClipData.newRawUri("", exportUri)
|
||||||
|
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun resolveTrainingSeconds(characterId: Long, deviceType: DeviceType): Long {
|
||||||
|
if (deviceType != DeviceType.BEDevice) {
|
||||||
|
return 0L
|
||||||
|
}
|
||||||
|
return database.userCharacterDao().getBeData(characterId).valueOrNull()?.remainingTrainingTimeInMinutes?.toLong()?.times(60L) ?: 0L
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun resolveTrainedBp(characterId: Long, deviceType: DeviceType): Int {
|
||||||
|
return if (deviceType == DeviceType.BEDevice) {
|
||||||
|
database.userCharacterDao().getBeData(characterId).valueOrNull()?.trainingBp ?: 0
|
||||||
|
} else 0
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun resolveTrainedHp(characterId: Long, deviceType: DeviceType): Int {
|
||||||
|
return if (deviceType == DeviceType.BEDevice) {
|
||||||
|
database.userCharacterDao().getBeData(characterId).valueOrNull()?.trainingHp ?: 0
|
||||||
|
} else 0
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun resolveTrainedAp(characterId: Long, deviceType: DeviceType): Int {
|
||||||
|
return if (deviceType == DeviceType.BEDevice) {
|
||||||
|
database.userCharacterDao().getBeData(characterId).valueOrNull()?.trainingAp ?: 0
|
||||||
|
} else 0
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun <T> kotlinx.coroutines.flow.Flow<T>.valueOrNull(): T? {
|
||||||
|
return runBlocking {
|
||||||
|
firstOrNull()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val VITALWEAR_CHARACTER_MIME = "application/x-vitalwear-character"
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,163 @@
|
|||||||
|
package com.github.nacabaro.vbhelper.source
|
||||||
|
|
||||||
|
import com.github.cfogrady.vbnfc.data.NfcCharacter
|
||||||
|
import com.github.cfogrady.vitalwear.protos.Character
|
||||||
|
import com.github.nacabaro.vbhelper.database.AppDatabase
|
||||||
|
import com.github.nacabaro.vbhelper.domain.device_data.BECharacterData
|
||||||
|
import com.github.nacabaro.vbhelper.domain.device_data.UserCharacter
|
||||||
|
import com.github.nacabaro.vbhelper.utils.DeviceType
|
||||||
|
import kotlin.math.max
|
||||||
|
|
||||||
|
class VitalWearCharacterImporter(
|
||||||
|
private val database: AppDatabase
|
||||||
|
) {
|
||||||
|
data class ImportResult(
|
||||||
|
val success: Boolean,
|
||||||
|
val message: String
|
||||||
|
)
|
||||||
|
|
||||||
|
fun importCharacter(character: Character): ImportResult {
|
||||||
|
val importedCard = resolveCard(character)
|
||||||
|
?: return ImportResult(
|
||||||
|
success = false,
|
||||||
|
message = "Matching card not found in VBHelper. Import that card first."
|
||||||
|
)
|
||||||
|
|
||||||
|
val slotId = character.characterStats.slotId
|
||||||
|
val cardCharacter = runCatching {
|
||||||
|
database.characterDao().getCharacterByMonIndex(slotId, importedCard.id)
|
||||||
|
}.getOrNull() ?: return ImportResult(
|
||||||
|
success = false,
|
||||||
|
message = "Character slot $slotId was not found on card ${importedCard.name}."
|
||||||
|
)
|
||||||
|
|
||||||
|
database.userCharacterDao().clearActiveCharacter()
|
||||||
|
|
||||||
|
val totalBattles = character.characterStats.totalBattles
|
||||||
|
val totalWins = character.characterStats.totalWins.coerceAtMost(totalBattles)
|
||||||
|
val currentPhaseBattles = character.characterStats.currentPhaseBattles
|
||||||
|
val currentPhaseWins = character.characterStats.currentPhaseWins.coerceAtMost(currentPhaseBattles)
|
||||||
|
|
||||||
|
val userCharacterId = database.userCharacterDao().insertCharacterData(
|
||||||
|
UserCharacter(
|
||||||
|
charId = cardCharacter.id,
|
||||||
|
ageInDays = max(character.transformationHistoryCount - 1, 0),
|
||||||
|
mood = character.characterStats.mood,
|
||||||
|
vitalPoints = character.characterStats.vitals,
|
||||||
|
transformationCountdown = secondsToMinutes(character.characterStats.timeUntilNextTransformation),
|
||||||
|
injuryStatus = resolveInjuryStatus(character.characterStats.injured),
|
||||||
|
trophies = character.characterStats.trainedPp,
|
||||||
|
currentPhaseBattlesWon = currentPhaseWins,
|
||||||
|
currentPhaseBattlesLost = (currentPhaseBattles - currentPhaseWins).coerceAtLeast(0),
|
||||||
|
totalBattlesWon = totalWins,
|
||||||
|
totalBattlesLost = (totalBattles - totalWins).coerceAtLeast(0),
|
||||||
|
activityLevel = 0,
|
||||||
|
heartRateCurrent = 0,
|
||||||
|
characterType = DeviceType.BEDevice,
|
||||||
|
isActive = true
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
database.userCharacterDao().insertBECharacterData(
|
||||||
|
BECharacterData(
|
||||||
|
id = userCharacterId,
|
||||||
|
trainingHp = character.characterStats.trainedHp,
|
||||||
|
trainingAp = character.characterStats.trainedAp,
|
||||||
|
trainingBp = character.characterStats.trainedBp,
|
||||||
|
remainingTrainingTimeInMinutes = secondsToMinutes(character.characterStats.trainingTimeRemainingInSeconds),
|
||||||
|
itemEffectMentalStateValue = 0,
|
||||||
|
itemEffectMentalStateMinutesRemaining = 0,
|
||||||
|
itemEffectActivityLevelValue = 0,
|
||||||
|
itemEffectActivityLevelMinutesRemaining = 0,
|
||||||
|
itemEffectVitalPointsChangeValue = 0,
|
||||||
|
itemEffectVitalPointsChangeMinutesRemaining = 0,
|
||||||
|
abilityRarity = resolveDefaultAbilityRarity(),
|
||||||
|
abilityType = 0,
|
||||||
|
abilityBranch = 0,
|
||||||
|
abilityReset = 0,
|
||||||
|
rank = 0,
|
||||||
|
itemType = 0,
|
||||||
|
itemMultiplier = 0,
|
||||||
|
itemRemainingTime = 0,
|
||||||
|
otp0 = "",
|
||||||
|
otp1 = "",
|
||||||
|
minorVersion = 0,
|
||||||
|
majorVersion = 0
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
database.dexDao().insertCharacter(slotId, importedCard.id, now)
|
||||||
|
|
||||||
|
for (transformation in character.transformationHistoryList) {
|
||||||
|
val transformationCard = resolveCard(transformation.cardName, character.cardId)
|
||||||
|
if (transformationCard != null) {
|
||||||
|
database.userCharacterDao().insertTransformation(
|
||||||
|
userCharacterId,
|
||||||
|
transformation.slotId,
|
||||||
|
transformationCard.id,
|
||||||
|
now
|
||||||
|
)
|
||||||
|
database.dexDao().insertCharacter(transformation.slotId, transformationCard.id, now)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for ((cardName, maxAdventureCompleted) in character.maxAdventureCompletedByCardMap) {
|
||||||
|
val adventureCard = resolveCard(cardName, null) ?: continue
|
||||||
|
val currentStage = (maxAdventureCompleted + 1).coerceAtLeast(0)
|
||||||
|
database.cardProgressDao().updateCardProgress(
|
||||||
|
currentStage = currentStage,
|
||||||
|
cardId = adventureCard.id,
|
||||||
|
unlocked = currentStage > adventureCard.stageCount
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return ImportResult(
|
||||||
|
success = true,
|
||||||
|
message = "Imported ${importedCard.name} slot $slotId from VitalWear."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun resolveCard(character: Character) = resolveCard(character.cardName, character.cardId)
|
||||||
|
|
||||||
|
private fun resolveCard(cardName: String?, cardId: Int?): com.github.nacabaro.vbhelper.domain.card.Card? {
|
||||||
|
if (!cardName.isNullOrBlank()) {
|
||||||
|
database.cardDao().getCardByName(cardName)?.let { return it }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cardId != null) {
|
||||||
|
val matches = database.cardDao().getCardByCardId(cardId)
|
||||||
|
if (matches.size == 1) {
|
||||||
|
return matches.first()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun secondsToMinutes(seconds: Long): Int {
|
||||||
|
if (seconds <= 0L) {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return (seconds / 60L).coerceAtMost(Int.MAX_VALUE.toLong()).toInt()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun resolveInjuryStatus(injured: Boolean): NfcCharacter.InjuryStatus {
|
||||||
|
val statuses = enumValues<NfcCharacter.InjuryStatus>()
|
||||||
|
if (!injured) {
|
||||||
|
return statuses.firstOrNull {
|
||||||
|
val normalized = it.name.lowercase()
|
||||||
|
normalized.contains("none") || normalized.contains("normal") || normalized.contains("healthy")
|
||||||
|
} ?: statuses.first()
|
||||||
|
}
|
||||||
|
|
||||||
|
return statuses.firstOrNull {
|
||||||
|
val normalized = it.name.lowercase()
|
||||||
|
normalized.contains("inj")
|
||||||
|
} ?: statuses.last()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun resolveDefaultAbilityRarity(): NfcCharacter.AbilityRarity {
|
||||||
|
return enumValues<NfcCharacter.AbilityRarity>().first()
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,38 @@
|
|||||||
|
package com.github.nacabaro.vbhelper.source
|
||||||
|
|
||||||
|
import com.github.cfogrady.vitalwear.protos.Character
|
||||||
|
import com.github.nacabaro.vbhelper.utils.DeviceType
|
||||||
|
|
||||||
|
internal const val MINIMUM_TRANSFORMATION_COUNTDOWN_MINUTES = 1
|
||||||
|
|
||||||
|
internal fun normalizeTransformationCountdownMinutes(
|
||||||
|
transformationCountdownMinutes: Int,
|
||||||
|
hasPossibleTransformations: Boolean,
|
||||||
|
): Int {
|
||||||
|
val sanitizedCountdown = transformationCountdownMinutes.coerceAtLeast(0)
|
||||||
|
if (!hasPossibleTransformations) {
|
||||||
|
return sanitizedCountdown
|
||||||
|
}
|
||||||
|
return sanitizedCountdown.coerceAtLeast(MINIMUM_TRANSFORMATION_COUNTDOWN_MINUTES)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun DeviceType.toTransferDeviceType(): Character.CharacterStats.TransferDeviceType {
|
||||||
|
return when (this) {
|
||||||
|
DeviceType.BEDevice -> Character.CharacterStats.TransferDeviceType.TRANSFER_DEVICE_TYPE_BE
|
||||||
|
DeviceType.VBDevice,
|
||||||
|
DeviceType.VitalWear -> Character.CharacterStats.TransferDeviceType.TRANSFER_DEVICE_TYPE_VB
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun resolveDeviceType(
|
||||||
|
transferDeviceType: Character.CharacterStats.TransferDeviceType,
|
||||||
|
fallbackIsBeCharacter: Boolean,
|
||||||
|
): DeviceType {
|
||||||
|
return when (transferDeviceType) {
|
||||||
|
Character.CharacterStats.TransferDeviceType.TRANSFER_DEVICE_TYPE_BE -> DeviceType.BEDevice
|
||||||
|
Character.CharacterStats.TransferDeviceType.TRANSFER_DEVICE_TYPE_VB -> DeviceType.VBDevice
|
||||||
|
Character.CharacterStats.TransferDeviceType.UNRECOGNIZED,
|
||||||
|
Character.CharacterStats.TransferDeviceType.TRANSFER_DEVICE_TYPE_UNSPECIFIED -> if (fallbackIsBeCharacter) DeviceType.BEDevice else DeviceType.VBDevice
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@ -0,0 +1,106 @@
|
|||||||
|
package com.github.nacabaro.vbhelper.transfer
|
||||||
|
|
||||||
|
import android.content.ContentProvider
|
||||||
|
import android.content.ContentValues
|
||||||
|
import android.database.Cursor
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Bundle
|
||||||
|
import com.github.cfogrady.vb.dim.card.DimReader
|
||||||
|
import com.github.nacabaro.vbhelper.di.VBHelper
|
||||||
|
import com.github.nacabaro.vbhelper.screens.settingsScreen.controllers.CardImportController
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ContentProvider that lets the VitalWear Companion app read and write card data into
|
||||||
|
* VBHelper's single database (internalDb). Both apps are signed with the same key, so
|
||||||
|
* the signature-level permission is automatically granted without user interaction.
|
||||||
|
*
|
||||||
|
* Usage from the companion:
|
||||||
|
* val names = call(METHOD_GET_CARD_NAMES)?.getStringArray(EXTRA_CARD_NAMES_LIST)
|
||||||
|
* call(METHOD_IMPORT_CARD, null, Bundle { putByteArray(EXTRA_CARD_BYTES, ...) })
|
||||||
|
*/
|
||||||
|
class CardImportProvider : ContentProvider() {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val AUTHORITY = "com.github.nacabaro.vbhelper.cardimport"
|
||||||
|
val URI: Uri = Uri.parse("content://$AUTHORITY")
|
||||||
|
|
||||||
|
/** Returns a Bundle with EXTRA_CARD_NAMES_LIST containing all imported card names. */
|
||||||
|
const val METHOD_GET_CARD_NAMES = "getCardNames"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses and stores a DIM card.
|
||||||
|
* Required extras: EXTRA_CARD_BYTES (ByteArray)
|
||||||
|
* Optional extras: EXTRA_CARD_NAME (String) — user-visible label; if omitted the DIM
|
||||||
|
* file's built-in name is used.
|
||||||
|
* Returns a Bundle with RESULT_OK = true on success.
|
||||||
|
*/
|
||||||
|
const val METHOD_IMPORT_CARD = "importCard"
|
||||||
|
|
||||||
|
const val EXTRA_CARD_BYTES = "cardBytes"
|
||||||
|
const val EXTRA_CARD_NAME = "cardName"
|
||||||
|
const val EXTRA_CARD_NAMES_LIST = "cardNames"
|
||||||
|
const val RESULT_OK = "ok"
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreate(): Boolean = true
|
||||||
|
|
||||||
|
override fun call(method: String, arg: String?, extras: Bundle?): Bundle {
|
||||||
|
val ctx = context ?: return Bundle().apply { putBoolean(RESULT_OK, false) }
|
||||||
|
val db = (ctx.applicationContext as VBHelper).container.db
|
||||||
|
|
||||||
|
return when (method) {
|
||||||
|
|
||||||
|
METHOD_GET_CARD_NAMES -> runCatching {
|
||||||
|
val names = db.cardDao().getAllCards().map { it.name }.toTypedArray()
|
||||||
|
Bundle().apply { putStringArray(EXTRA_CARD_NAMES_LIST, names) }
|
||||||
|
}.getOrElse {
|
||||||
|
Bundle().apply { putStringArray(EXTRA_CARD_NAMES_LIST, emptyArray()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
METHOD_IMPORT_CARD -> {
|
||||||
|
val cardBytes = extras?.getByteArray(EXTRA_CARD_BYTES)
|
||||||
|
?: return Bundle().apply { putBoolean(RESULT_OK, false) }
|
||||||
|
val customName = extras.getString(EXTRA_CARD_NAME)
|
||||||
|
|
||||||
|
runCatching {
|
||||||
|
runBlocking {
|
||||||
|
val dimReader = DimReader()
|
||||||
|
val peeked = cardBytes.inputStream().use { dimReader.readCard(it, false) }
|
||||||
|
val builtInName = peeked.spriteData.text
|
||||||
|
val dimId = peeked.header.dimId
|
||||||
|
|
||||||
|
val alreadyExists =
|
||||||
|
db.cardDao().getCardByName(builtInName) != null ||
|
||||||
|
db.cardDao().getCardByCardId(dimId).isNotEmpty()
|
||||||
|
|
||||||
|
if (!alreadyExists) {
|
||||||
|
CardImportController(db).importCard(cardBytes.inputStream())
|
||||||
|
// Rename to the user's custom label if it differs from the DIM's text
|
||||||
|
if (!customName.isNullOrBlank() && customName != builtInName) {
|
||||||
|
db.cardDao().getCardByName(builtInName)?.let { card ->
|
||||||
|
db.cardDao().renameCard(card.id.toInt(), customName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Bundle().apply { putBoolean(RESULT_OK, true) }
|
||||||
|
}.getOrElse {
|
||||||
|
Bundle().apply { putBoolean(RESULT_OK, false) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> Bundle().apply { putBoolean(RESULT_OK, false) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Unused mandatory overrides ----
|
||||||
|
override fun query(uri: Uri, projection: Array<out String>?, selection: String?,
|
||||||
|
selectionArgs: Array<out String>?, sortOrder: String?): Cursor? = null
|
||||||
|
override fun getType(uri: Uri): String? = null
|
||||||
|
override fun insert(uri: Uri, values: ContentValues?): Uri? = null
|
||||||
|
override fun delete(uri: Uri, selection: String?, selectionArgs: Array<out String>?): Int = 0
|
||||||
|
override fun update(uri: Uri, values: ContentValues?, selection: String?,
|
||||||
|
selectionArgs: Array<out String>?): Int = 0
|
||||||
|
}
|
||||||
|
|
||||||
@ -0,0 +1,112 @@
|
|||||||
|
package com.github.nacabaro.vbhelper.transfer
|
||||||
|
|
||||||
|
import com.github.nacabaro.vbhelper.daos.CharacterTransferPolicyDao
|
||||||
|
import com.github.nacabaro.vbhelper.domain.device_data.CharacterTransferPolicy
|
||||||
|
import com.github.nacabaro.vbhelper.utils.DeviceType
|
||||||
|
|
||||||
|
class CharacterTransferPolicyResolver(
|
||||||
|
private val policyDao: CharacterTransferPolicyDao,
|
||||||
|
) {
|
||||||
|
fun resolveExportFormat(
|
||||||
|
characterId: Long,
|
||||||
|
characterType: DeviceType,
|
||||||
|
transport: TransferTransport,
|
||||||
|
target: TransferTarget,
|
||||||
|
isBemCard: Boolean,
|
||||||
|
): ExportFormat {
|
||||||
|
val policy = policyDao.getByCharacterId(characterId)
|
||||||
|
return when (transport) {
|
||||||
|
TransferTransport.HCE -> policy?.preferredHceExportFormat
|
||||||
|
?: defaultHceExportFormat(characterType, isBemCard, target)
|
||||||
|
TransferTransport.NFCA -> policy?.preferredNfcaExportFormat
|
||||||
|
?: defaultNfcaExportFormat(characterType, isBemCard, target)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun policyForNfcaImport(
|
||||||
|
characterId: Long,
|
||||||
|
observedFormat: ExportFormat,
|
||||||
|
): CharacterTransferPolicy {
|
||||||
|
val nativeDeviceType = observedFormat.toNativeDeviceType()
|
||||||
|
return CharacterTransferPolicy(
|
||||||
|
characterId = characterId,
|
||||||
|
nativeDeviceType = nativeDeviceType,
|
||||||
|
preferredHceExportFormat = defaultHceExportFormat(nativeDeviceType, nativeDeviceType == DeviceType.BEDevice, TransferTarget.VITAL_WEAR),
|
||||||
|
preferredNfcaExportFormat = observedFormat,
|
||||||
|
lastObservedImportFormat = observedFormat,
|
||||||
|
lastTransferTransport = TransferTransport.NFCA,
|
||||||
|
lastTransferTarget = TransferTarget.REAL_BRACELET,
|
||||||
|
preserveVbRoundTrip = observedFormat == ExportFormat.VB,
|
||||||
|
preserveBeRoundTrip = observedFormat == ExportFormat.BE,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun policyForHceImport(
|
||||||
|
characterId: Long,
|
||||||
|
importedCardIsBem: Boolean,
|
||||||
|
resolvedDeviceType: DeviceType,
|
||||||
|
observedFormat: ExportFormat,
|
||||||
|
): CharacterTransferPolicy {
|
||||||
|
val nativeDeviceType = when {
|
||||||
|
importedCardIsBem -> DeviceType.BEDevice
|
||||||
|
resolvedDeviceType == DeviceType.BEDevice && observedFormat == ExportFormat.BE -> DeviceType.VBDevice
|
||||||
|
resolvedDeviceType == DeviceType.VitalWear -> DeviceType.VBDevice
|
||||||
|
else -> resolvedDeviceType
|
||||||
|
}
|
||||||
|
val preferredNfcaExportFormat = if (importedCardIsBem || nativeDeviceType == DeviceType.BEDevice) {
|
||||||
|
ExportFormat.BE
|
||||||
|
} else {
|
||||||
|
ExportFormat.VB
|
||||||
|
}
|
||||||
|
|
||||||
|
return CharacterTransferPolicy(
|
||||||
|
characterId = characterId,
|
||||||
|
nativeDeviceType = nativeDeviceType,
|
||||||
|
preferredHceExportFormat = defaultHceExportFormat(nativeDeviceType, importedCardIsBem, TransferTarget.VITAL_WEAR),
|
||||||
|
preferredNfcaExportFormat = preferredNfcaExportFormat,
|
||||||
|
lastObservedImportFormat = observedFormat,
|
||||||
|
lastTransferTransport = TransferTransport.HCE,
|
||||||
|
lastTransferTarget = TransferTarget.VITAL_WEAR,
|
||||||
|
preserveVbRoundTrip = preferredNfcaExportFormat == ExportFormat.VB,
|
||||||
|
preserveBeRoundTrip = importedCardIsBem || observedFormat == ExportFormat.BE,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun defaultHceExportFormat(
|
||||||
|
characterType: DeviceType,
|
||||||
|
isBemCard: Boolean,
|
||||||
|
target: TransferTarget,
|
||||||
|
): ExportFormat {
|
||||||
|
if (target != TransferTarget.VITAL_WEAR) {
|
||||||
|
return defaultNfcaExportFormat(characterType, isBemCard, target)
|
||||||
|
}
|
||||||
|
|
||||||
|
// VitalWear is the HCE endpoint. Default to BE projection so HCE transfers can carry the
|
||||||
|
// richer payload, while NFCA policy still controls what is sent back to real bracelets.
|
||||||
|
return ExportFormat.BE
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun defaultNfcaExportFormat(
|
||||||
|
characterType: DeviceType,
|
||||||
|
isBemCard: Boolean,
|
||||||
|
target: TransferTarget,
|
||||||
|
): ExportFormat {
|
||||||
|
require(target == TransferTarget.REAL_BRACELET) {
|
||||||
|
"NFCA exports are only supported for real bracelets."
|
||||||
|
}
|
||||||
|
return if (isBemCard || characterType == DeviceType.BEDevice) {
|
||||||
|
ExportFormat.BE
|
||||||
|
} else {
|
||||||
|
ExportFormat.VB
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ExportFormat.toNativeDeviceType(): DeviceType {
|
||||||
|
return when (this) {
|
||||||
|
ExportFormat.BE -> DeviceType.BEDevice
|
||||||
|
ExportFormat.VB -> DeviceType.VBDevice
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -0,0 +1,67 @@
|
|||||||
|
package com.github.nacabaro.vbhelper.transfer
|
||||||
|
|
||||||
|
import android.content.ContentProvider
|
||||||
|
import android.content.ContentValues
|
||||||
|
import android.database.Cursor
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Bundle
|
||||||
|
import com.github.cfogrady.vitalwear.common.data.SharedDatabaseFactory
|
||||||
|
|
||||||
|
class SharedTransferSeenProvider : ContentProvider() {
|
||||||
|
companion object {
|
||||||
|
const val AUTHORITY = "com.github.nacabaro.vbhelper.transferseen"
|
||||||
|
val URI: Uri = Uri.parse("content://$AUTHORITY")
|
||||||
|
|
||||||
|
const val METHOD_MARK_SEEN = "markSeen"
|
||||||
|
const val EXTRA_CARD_NAME = "cardName"
|
||||||
|
const val EXTRA_SLOT_ID = "slotId"
|
||||||
|
const val EXTRA_SEEN_AT_EPOCH_MILLIS = "seenAtEpochMillis"
|
||||||
|
const val RESULT_OK = "ok"
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreate(): Boolean = true
|
||||||
|
|
||||||
|
override fun call(method: String, arg: String?, extras: Bundle?): Bundle {
|
||||||
|
if (method != METHOD_MARK_SEEN) {
|
||||||
|
return Bundle().apply { putBoolean(RESULT_OK, false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
val context = context ?: return Bundle().apply { putBoolean(RESULT_OK, false) }
|
||||||
|
val cardName = extras?.getString(EXTRA_CARD_NAME).orEmpty()
|
||||||
|
val slotId = extras?.getInt(EXTRA_SLOT_ID, -1) ?: -1
|
||||||
|
val seenAt = extras?.getLong(EXTRA_SEEN_AT_EPOCH_MILLIS, 0L) ?: 0L
|
||||||
|
|
||||||
|
if (cardName.isBlank() || slotId < 0 || seenAt <= 0L) {
|
||||||
|
return Bundle().apply { putBoolean(RESULT_OK, false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
return runCatching {
|
||||||
|
SharedDatabaseFactory.getDatabase(context).transferSeenDao().markSeen(cardName, slotId, seenAt)
|
||||||
|
Bundle().apply { putBoolean(RESULT_OK, true) }
|
||||||
|
}.getOrElse {
|
||||||
|
Bundle().apply { putBoolean(RESULT_OK, false) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun query(
|
||||||
|
uri: Uri,
|
||||||
|
projection: Array<out String>?,
|
||||||
|
selection: String?,
|
||||||
|
selectionArgs: Array<out String>?,
|
||||||
|
sortOrder: String?
|
||||||
|
): Cursor? = null
|
||||||
|
|
||||||
|
override fun getType(uri: Uri): String? = null
|
||||||
|
|
||||||
|
override fun insert(uri: Uri, values: ContentValues?): Uri? = null
|
||||||
|
|
||||||
|
override fun delete(uri: Uri, selection: String?, selectionArgs: Array<out String>?): Int = 0
|
||||||
|
|
||||||
|
override fun update(
|
||||||
|
uri: Uri,
|
||||||
|
values: ContentValues?,
|
||||||
|
selection: String?,
|
||||||
|
selectionArgs: Array<out String>?
|
||||||
|
): Int = 0
|
||||||
|
}
|
||||||
|
|
||||||
@ -0,0 +1,31 @@
|
|||||||
|
package com.github.nacabaro.vbhelper.transfer
|
||||||
|
|
||||||
|
import com.github.cfogrady.vitalwear.protos.Character
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Real bracelets talk over NFC-A while VitalWear uses HCE / ISO-DEP.
|
||||||
|
* Keep transport, target, and payload format separate so one canonical character can be
|
||||||
|
* projected differently depending on where it is being transferred.
|
||||||
|
*/
|
||||||
|
enum class TransferTransport {
|
||||||
|
NFCA,
|
||||||
|
HCE,
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class TransferTarget {
|
||||||
|
REAL_BRACELET,
|
||||||
|
VITAL_WEAR,
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class ExportFormat {
|
||||||
|
VB,
|
||||||
|
BE,
|
||||||
|
}
|
||||||
|
|
||||||
|
fun ExportFormat.toTransferDeviceType(): Character.CharacterStats.TransferDeviceType {
|
||||||
|
return when (this) {
|
||||||
|
ExportFormat.VB -> Character.CharacterStats.TransferDeviceType.TRANSFER_DEVICE_TYPE_VB
|
||||||
|
ExportFormat.BE -> Character.CharacterStats.TransferDeviceType.TRANSFER_DEVICE_TYPE_BE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@ -0,0 +1,437 @@
|
|||||||
|
package com.github.nacabaro.vbhelper.transfer.hce
|
||||||
|
import android.nfc.tech.IsoDep
|
||||||
|
import android.os.SystemClock
|
||||||
|
import android.util.Log
|
||||||
|
import com.github.cfogrady.vitalwear.protos.Character
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
/**
|
||||||
|
* Phone-side ISO-DEP client that drives the VitalWear HCE session.
|
||||||
|
* Mirrors the APDU protocol defined in VitalWearHceProtocol on the watch.
|
||||||
|
*/
|
||||||
|
class VitalWearHceReaderClient(
|
||||||
|
private val isoDep: IsoDep,
|
||||||
|
private val fastMode: Boolean = true,
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
private const val MIN_CHUNK_SIZE = 256
|
||||||
|
private val bestChunkByDevice = linkedMapOf<String, Int>()
|
||||||
|
}
|
||||||
|
|
||||||
|
init {
|
||||||
|
// Import + DB writes can make COMMIT slower than default transceive time on some devices.
|
||||||
|
isoDep.timeout = 10_000
|
||||||
|
}
|
||||||
|
|
||||||
|
private val AID = byteArrayOf(
|
||||||
|
0xF0.toByte(), 0x56, 0x49, 0x54, 0x41, 0x4C, 0x57, 0x45, 0x41, 0x52
|
||||||
|
)
|
||||||
|
private val CLA: Byte = 0x80.toByte()
|
||||||
|
private val INS_NEGOTIATE: Byte = 0x10
|
||||||
|
private val INS_READ_CHUNK: Byte = 0x20
|
||||||
|
private val INS_WRITE_CHUNK: Byte = 0x30
|
||||||
|
private val INS_COMMIT: Byte = 0x40
|
||||||
|
private val INS_STATUS: Byte = 0x50
|
||||||
|
private val INS_SYNC_UI: Byte = 0x60
|
||||||
|
private val INS_VIBRATE: Byte = 0x70
|
||||||
|
private val MODE_WATCH_TO_PHONE: Byte = 0x01
|
||||||
|
private val MODE_PHONE_TO_WATCH: Byte = 0x02
|
||||||
|
private val VERSION: Byte = 0x01
|
||||||
|
private val SW_OK = 0x9000
|
||||||
|
private val STATUS_SUCCESS: Byte = 0x04
|
||||||
|
private val STATUS_FAILURE: Byte = 0x05
|
||||||
|
private val STATUS_SYNCING: Byte = 0x03
|
||||||
|
private val STATUS_ARMED_SEND: Byte = 0x01
|
||||||
|
private val STATUS_ARMED_RECEIVE: Byte = 0x02
|
||||||
|
private val STATUS_IDLE: Byte = 0x00
|
||||||
|
private val CONFIRMATION_MAX_POLLS = 84 // ~21s with adaptive delay schedule
|
||||||
|
private val PREFERRED_MAX_CHUNK_SIZE = 2048
|
||||||
|
// Fast mode is the default; ceremony APDUs are only enabled when fastMode is false.
|
||||||
|
private val ENABLE_TRANSFER_CEREMONY = !fastMode
|
||||||
|
|
||||||
|
private data class ReadPayloadResult(
|
||||||
|
val character: Character,
|
||||||
|
val session: VitalWearHceSessionInfo,
|
||||||
|
)
|
||||||
|
|
||||||
|
private data class HceTransferMetrics(
|
||||||
|
val direction: String,
|
||||||
|
val payloadBytes: Int,
|
||||||
|
val negotiatedChunkBytes: Int,
|
||||||
|
val apduCountTotal: Int,
|
||||||
|
val apduCountData: Int,
|
||||||
|
val statusPollCount: Int,
|
||||||
|
val tSelectNegotiateMs: Long,
|
||||||
|
val tChunkLoopMs: Long,
|
||||||
|
val tCommitAckMs: Long,
|
||||||
|
val tConfirmMs: Long,
|
||||||
|
val tTotalMs: Long,
|
||||||
|
val result: String,
|
||||||
|
) {
|
||||||
|
fun log() {
|
||||||
|
Log.i(
|
||||||
|
"HCE_CLIENT_METRICS",
|
||||||
|
"dir=$direction payloadBytes=$payloadBytes chunk=$negotiatedChunkBytes apduTotal=$apduCountTotal " +
|
||||||
|
"apduData=$apduCountData polls=$statusPollCount selectNegotiateMs=$tSelectNegotiateMs " +
|
||||||
|
"chunkLoopMs=$tChunkLoopMs commitAckMs=$tCommitAckMs confirmMs=$tConfirmMs totalMs=$tTotalMs result=$result"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** MOVE READ: read character bytes first, let caller import/validate, then COMMIT only on success.
|
||||||
|
* If [onCharacterRead] returns false, COMMIT is skipped so source can remain on the watch.
|
||||||
|
*/
|
||||||
|
fun moveCharacterFromWatch(onCharacterRead: (Character) -> Boolean): Boolean {
|
||||||
|
val readResult = readCharacterFromWatchWithoutCommit()
|
||||||
|
if (!onCharacterRead(readResult.character)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (!readResult.session.canCommit()) {
|
||||||
|
error("Session does not allow COMMIT for watch->phone transfer")
|
||||||
|
}
|
||||||
|
requireOk(sendApdu(INS_COMMIT, byteArrayOf()), "COMMIT")
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/** READ: watch has called armSend() — phone reads the character off the watch. */
|
||||||
|
fun receiveCharacterFromWatch(): Character {
|
||||||
|
val readResult = readCharacterFromWatchWithoutCommit()
|
||||||
|
if (!readResult.session.canCommit()) {
|
||||||
|
error("Session does not allow COMMIT for watch->phone transfer")
|
||||||
|
}
|
||||||
|
requireOk(sendApdu(INS_COMMIT, byteArrayOf()), "COMMIT")
|
||||||
|
return readResult.character
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun readCharacterFromWatchWithoutCommit(): ReadPayloadResult {
|
||||||
|
val transferStartMs = SystemClock.elapsedRealtime()
|
||||||
|
var apduCount = 0
|
||||||
|
var dataApduCount = 0
|
||||||
|
selectAid()
|
||||||
|
apduCount++
|
||||||
|
val session = negotiateSession(MODE_WATCH_TO_PHONE, desiredMaxChunkSize())
|
||||||
|
apduCount++
|
||||||
|
val negotiatedMs = SystemClock.elapsedRealtime()
|
||||||
|
check(session.isWatchToPhone()) { "NEGOTIATE direction mismatch for WATCH_TO_PHONE" }
|
||||||
|
Log.d("HCE_CLIENT", "Receiving ${session.payloadLength} bytes in chunks of ${session.maxChunkSize}")
|
||||||
|
val payload = ByteArray(session.payloadLength)
|
||||||
|
var offset = 0
|
||||||
|
while (offset < session.payloadLength) {
|
||||||
|
val chunkResponse = sendApdu(INS_READ_CHUNK, intToBytes(offset))
|
||||||
|
apduCount++
|
||||||
|
dataApduCount++
|
||||||
|
requireOk(chunkResponse, "READ_CHUNK @ $offset")
|
||||||
|
val chunkData = chunkResponse.dropLast(2).toByteArray()
|
||||||
|
chunkData.copyInto(payload, offset)
|
||||||
|
offset += chunkData.size
|
||||||
|
}
|
||||||
|
val chunksDoneMs = SystemClock.elapsedRealtime()
|
||||||
|
HceTransferMetrics(
|
||||||
|
direction = "WATCH_TO_PHONE_READ",
|
||||||
|
payloadBytes = payload.size,
|
||||||
|
negotiatedChunkBytes = session.maxChunkSize,
|
||||||
|
apduCountTotal = apduCount,
|
||||||
|
apduCountData = dataApduCount,
|
||||||
|
statusPollCount = 0,
|
||||||
|
tSelectNegotiateMs = negotiatedMs - transferStartMs,
|
||||||
|
tChunkLoopMs = chunksDoneMs - negotiatedMs,
|
||||||
|
tCommitAckMs = 0L,
|
||||||
|
tConfirmMs = 0L,
|
||||||
|
tTotalMs = chunksDoneMs - transferStartMs,
|
||||||
|
result = "read_complete",
|
||||||
|
).log()
|
||||||
|
return ReadPayloadResult(
|
||||||
|
character = Character.parseFrom(payload),
|
||||||
|
session = session,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** WRITE: watch has called armReceive() — phone pushes a character to the watch. */
|
||||||
|
fun sendCharacterToWatch(character: Character, closeSyncUiAfterCommit: Boolean = true) {
|
||||||
|
val session = sendCharacterToWatchInternal(
|
||||||
|
character = character,
|
||||||
|
closeSyncUiAfterCommit = closeSyncUiAfterCommit,
|
||||||
|
requestedChunkSize = desiredMaxChunkSize(),
|
||||||
|
)
|
||||||
|
rememberBestChunk(session.maxChunkSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun sendCharacterToWatchInternal(
|
||||||
|
character: Character,
|
||||||
|
closeSyncUiAfterCommit: Boolean,
|
||||||
|
requestedChunkSize: Int,
|
||||||
|
): VitalWearHceSessionInfo {
|
||||||
|
val transferStartMs = SystemClock.elapsedRealtime()
|
||||||
|
var apduCount = 0
|
||||||
|
var dataApduCount = 0
|
||||||
|
selectAid()
|
||||||
|
apduCount++
|
||||||
|
if (ENABLE_TRANSFER_CEREMONY) {
|
||||||
|
// Optional toy-like ceremony; kept behind a toggle to avoid transfer overhead.
|
||||||
|
sendApdu(INS_VIBRATE, byteArrayOf())
|
||||||
|
apduCount++
|
||||||
|
sendApdu(INS_SYNC_UI, byteArrayOf(0x01))
|
||||||
|
apduCount++
|
||||||
|
}
|
||||||
|
|
||||||
|
val payload = character.toByteArray()
|
||||||
|
val session = negotiateSession(MODE_PHONE_TO_WATCH, requestedChunkSize)
|
||||||
|
apduCount++
|
||||||
|
val negotiatedMs = SystemClock.elapsedRealtime()
|
||||||
|
check(session.isPhoneToWatch()) { "NEGOTIATE direction mismatch for PHONE_TO_WATCH" }
|
||||||
|
Log.d("HCE_CLIENT", "Sending ${payload.size} bytes in chunks of ${session.maxChunkSize}")
|
||||||
|
var offset = 0
|
||||||
|
while (offset < payload.size) {
|
||||||
|
val chunkEnd = (offset + session.maxChunkSize).coerceAtMost(payload.size)
|
||||||
|
val chunk = payload.copyOfRange(offset, chunkEnd)
|
||||||
|
requireOk(sendApdu(INS_WRITE_CHUNK, intToBytes(offset) + chunk), "WRITE_CHUNK @ $offset")
|
||||||
|
apduCount++
|
||||||
|
dataApduCount++
|
||||||
|
offset = chunkEnd
|
||||||
|
}
|
||||||
|
val chunksDoneMs = SystemClock.elapsedRealtime()
|
||||||
|
if (!session.canCommit()) {
|
||||||
|
error("Session does not allow COMMIT for phone->watch transfer")
|
||||||
|
}
|
||||||
|
val commitStartMs = SystemClock.elapsedRealtime()
|
||||||
|
requireOk(sendApdu(INS_COMMIT, byteArrayOf()), "COMMIT")
|
||||||
|
apduCount++
|
||||||
|
val commitDoneMs = SystemClock.elapsedRealtime()
|
||||||
|
|
||||||
|
if (ENABLE_TRANSFER_CEREMONY && closeSyncUiAfterCommit) {
|
||||||
|
sendApdu(INS_SYNC_UI, byteArrayOf(0x00))
|
||||||
|
apduCount++
|
||||||
|
}
|
||||||
|
HceTransferMetrics(
|
||||||
|
direction = "PHONE_TO_WATCH_WRITE",
|
||||||
|
payloadBytes = payload.size,
|
||||||
|
negotiatedChunkBytes = session.maxChunkSize,
|
||||||
|
apduCountTotal = apduCount,
|
||||||
|
apduCountData = dataApduCount,
|
||||||
|
statusPollCount = 0,
|
||||||
|
tSelectNegotiateMs = negotiatedMs - transferStartMs,
|
||||||
|
tChunkLoopMs = chunksDoneMs - negotiatedMs,
|
||||||
|
tCommitAckMs = commitDoneMs - commitStartMs,
|
||||||
|
tConfirmMs = 0L,
|
||||||
|
tTotalMs = commitDoneMs - transferStartMs,
|
||||||
|
result = "commit_acked",
|
||||||
|
).log()
|
||||||
|
return session
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WRITE + confirmation polling.
|
||||||
|
* Returns true only when the watch reports import success.
|
||||||
|
*/
|
||||||
|
fun sendCharacterToWatchAndConfirm(character: Character): Boolean {
|
||||||
|
var lastError: Exception? = null
|
||||||
|
for (candidateChunk in chunkCandidates()) {
|
||||||
|
val confirmStartMs = SystemClock.elapsedRealtime()
|
||||||
|
val session = try {
|
||||||
|
sendCharacterToWatchInternal(
|
||||||
|
character = character,
|
||||||
|
closeSyncUiAfterCommit = false,
|
||||||
|
requestedChunkSize = candidateChunk,
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
lastError = e
|
||||||
|
Log.w("HCE_CLIENT", "Chunk attempt failed before confirmation at chunk=$candidateChunk", e)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (!session.canPollStatus()) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
var polls = 0
|
||||||
|
var apduCount = 0
|
||||||
|
var result = "timeout"
|
||||||
|
val confirmed = runBlocking {
|
||||||
|
repeat(CONFIRMATION_MAX_POLLS) {
|
||||||
|
val statusResponse = sendApdu(INS_STATUS, byteArrayOf())
|
||||||
|
apduCount++
|
||||||
|
polls++
|
||||||
|
requireOk(statusResponse, "STATUS")
|
||||||
|
val statusByte = statusResponse.dropLast(2).firstOrNull()
|
||||||
|
Log.d("HCE_CLIENT", "STATUS poll[$it]: statusByte=${statusByte?.toInt()?.and(0xFF)}")
|
||||||
|
if (statusByte == STATUS_SUCCESS) {
|
||||||
|
result = "success"
|
||||||
|
return@runBlocking true
|
||||||
|
}
|
||||||
|
if (statusByte == STATUS_FAILURE) {
|
||||||
|
result = "failure"
|
||||||
|
return@runBlocking false
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
statusByte == STATUS_SYNCING ||
|
||||||
|
statusByte == STATUS_ARMED_RECEIVE ||
|
||||||
|
statusByte == STATUS_ARMED_SEND ||
|
||||||
|
statusByte == STATUS_IDLE
|
||||||
|
) {
|
||||||
|
delay(delayForPoll(it))
|
||||||
|
return@repeat
|
||||||
|
}
|
||||||
|
// Unknown status byte: keep waiting briefly instead of failing early.
|
||||||
|
delay(delayForPoll(it))
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
if (ENABLE_TRANSFER_CEREMONY) {
|
||||||
|
// End the sync UI after we have consumed terminal status.
|
||||||
|
runCatching {
|
||||||
|
sendApdu(INS_SYNC_UI, byteArrayOf(0x00))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
HceTransferMetrics(
|
||||||
|
direction = "PHONE_TO_WATCH_CONFIRM",
|
||||||
|
payloadBytes = character.serializedSize,
|
||||||
|
negotiatedChunkBytes = session.maxChunkSize,
|
||||||
|
apduCountTotal = apduCount,
|
||||||
|
apduCountData = 0,
|
||||||
|
statusPollCount = polls,
|
||||||
|
tSelectNegotiateMs = 0L,
|
||||||
|
tChunkLoopMs = 0L,
|
||||||
|
tCommitAckMs = 0L,
|
||||||
|
tConfirmMs = SystemClock.elapsedRealtime() - confirmStartMs,
|
||||||
|
tTotalMs = SystemClock.elapsedRealtime() - confirmStartMs,
|
||||||
|
result = "$result(requested=$candidateChunk)",
|
||||||
|
).log()
|
||||||
|
if (confirmed) {
|
||||||
|
rememberBestChunk(session.maxChunkSize)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
// Explicit failure means import rejected payload; retrying chunk size won't help.
|
||||||
|
if (result == "failure") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lastError?.let { throw it }
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verification step for two-tap transfer UX:
|
||||||
|
* confirms the watch is armed to receive before the actual write step.
|
||||||
|
*/
|
||||||
|
fun verifyWatchReadyToReceive(): Boolean {
|
||||||
|
selectAid()
|
||||||
|
val session = negotiateSession(MODE_PHONE_TO_WATCH, desiredMaxChunkSize())
|
||||||
|
return session.canCommit()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun selectAid() {
|
||||||
|
val selectApdu = byteArrayOf(0x00, 0xA4.toByte(), 0x04, 0x00, AID.size.toByte()) + AID
|
||||||
|
val response = isoDep.transceive(selectApdu)
|
||||||
|
if (statusWord(response) != SW_OK) error("SELECT AID failed: ${response.toHex()}")
|
||||||
|
}
|
||||||
|
private fun sendApdu(ins: Byte, data: ByteArray): ByteArray {
|
||||||
|
val apdu = if (data.size <= 0xFF) {
|
||||||
|
byteArrayOf(CLA, ins, 0x00, 0x00, data.size.toByte()) + data
|
||||||
|
} else {
|
||||||
|
byteArrayOf(
|
||||||
|
CLA,
|
||||||
|
ins,
|
||||||
|
0x00,
|
||||||
|
0x00,
|
||||||
|
0x00,
|
||||||
|
((data.size ushr 8) and 0xFF).toByte(),
|
||||||
|
(data.size and 0xFF).toByte(),
|
||||||
|
) + data
|
||||||
|
}
|
||||||
|
return isoDep.transceive(apdu)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun negotiateSession(mode: Byte, requestedChunkSize: Int): VitalWearHceSessionInfo {
|
||||||
|
val negResponse = sendApdu(
|
||||||
|
INS_NEGOTIATE,
|
||||||
|
byteArrayOf(mode, VERSION) + intToShortBytes(requestedChunkSize)
|
||||||
|
)
|
||||||
|
requireOk(negResponse, "NEGOTIATE")
|
||||||
|
val negotiatedMode = when (negResponse[1]) {
|
||||||
|
MODE_WATCH_TO_PHONE -> VitalWearHceTransferDirection.WATCH_TO_PHONE
|
||||||
|
MODE_PHONE_TO_WATCH -> VitalWearHceTransferDirection.PHONE_TO_WATCH
|
||||||
|
else -> error("NEGOTIATE returned unknown direction: ${negResponse[1]}")
|
||||||
|
}
|
||||||
|
val maxChunk = ((negResponse[2].toInt() and 0xFF) shl 8) or (negResponse[3].toInt() and 0xFF)
|
||||||
|
val payloadLen =
|
||||||
|
((negResponse[4].toInt() and 0xFF) shl 24) or
|
||||||
|
((negResponse[5].toInt() and 0xFF) shl 16) or
|
||||||
|
((negResponse[6].toInt() and 0xFF) shl 8) or
|
||||||
|
(negResponse[7].toInt() and 0xFF)
|
||||||
|
return VitalWearHceSession(
|
||||||
|
direction = negotiatedMode,
|
||||||
|
maxChunkSize = maxChunk,
|
||||||
|
payloadLength = payloadLen,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun desiredMaxChunkSize(): Int {
|
||||||
|
val transceiveLimit = runCatching { isoDep.maxTransceiveLength }.getOrNull()
|
||||||
|
val maxByDevice = if (transceiveLimit != null && transceiveLimit > 0) {
|
||||||
|
// Extended APDU write overhead: 7-byte header + 4-byte offset.
|
||||||
|
(transceiveLimit - 11).coerceAtLeast(1)
|
||||||
|
} else {
|
||||||
|
PREFERRED_MAX_CHUNK_SIZE
|
||||||
|
}
|
||||||
|
val cappedByDevice = maxByDevice.coerceAtMost(PREFERRED_MAX_CHUNK_SIZE)
|
||||||
|
val cachedBest = cachedBestChunk()
|
||||||
|
return if (cachedBest == null) {
|
||||||
|
cappedByDevice
|
||||||
|
} else {
|
||||||
|
cachedBest.coerceIn(MIN_CHUNK_SIZE, cappedByDevice)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun chunkCandidates(): List<Int> {
|
||||||
|
val maxChunk = desiredMaxChunkSize()
|
||||||
|
val baselineCandidates = listOf(maxChunk, 2048, 1536, 1024, 768, 512, MIN_CHUNK_SIZE)
|
||||||
|
return baselineCandidates
|
||||||
|
.map { it.coerceAtMost(maxChunk) }
|
||||||
|
.filter { it in MIN_CHUNK_SIZE..maxChunk }
|
||||||
|
.distinct()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun deviceKey(): String {
|
||||||
|
val tagId = isoDep.tag?.id ?: return "unknown-device"
|
||||||
|
return tagId.toHex()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun cachedBestChunk(): Int? {
|
||||||
|
synchronized(bestChunkByDevice) {
|
||||||
|
return bestChunkByDevice[deviceKey()]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun rememberBestChunk(chunkSize: Int) {
|
||||||
|
synchronized(bestChunkByDevice) {
|
||||||
|
bestChunkByDevice[deviceKey()] = chunkSize
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun delayForPoll(pollIndex: Int): Long {
|
||||||
|
return when {
|
||||||
|
pollIndex < 20 -> 80L
|
||||||
|
pollIndex < 56 -> 175L
|
||||||
|
else -> 300L
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun intToShortBytes(value: Int): ByteArray = byteArrayOf(
|
||||||
|
((value ushr 8) and 0xFF).toByte(),
|
||||||
|
(value and 0xFF).toByte()
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun requireOk(response: ByteArray, context: String) {
|
||||||
|
if (statusWord(response) != SW_OK) error("$context failed: ${response.toHex()}")
|
||||||
|
}
|
||||||
|
private fun statusWord(response: ByteArray): Int {
|
||||||
|
if (response.size < 2) return -1
|
||||||
|
return ((response[response.size - 2].toInt() and 0xFF) shl 8) or
|
||||||
|
(response[response.size - 1].toInt() and 0xFF)
|
||||||
|
}
|
||||||
|
private fun intToBytes(value: Int): ByteArray = byteArrayOf(
|
||||||
|
((value ushr 24) and 0xFF).toByte(),
|
||||||
|
((value ushr 16) and 0xFF).toByte(),
|
||||||
|
((value ushr 8) and 0xFF).toByte(),
|
||||||
|
(value and 0xFF).toByte()
|
||||||
|
)
|
||||||
|
private fun ByteArray.toHex() = joinToString("") { "%02X".format(it) }
|
||||||
|
}
|
||||||
@ -0,0 +1,40 @@
|
|||||||
|
package com.github.nacabaro.vbhelper.transfer.hce
|
||||||
|
|
||||||
|
internal enum class VitalWearHceTransferDirection {
|
||||||
|
WATCH_TO_PHONE,
|
||||||
|
PHONE_TO_WATCH,
|
||||||
|
}
|
||||||
|
|
||||||
|
internal interface VitalWearHceSessionInfo {
|
||||||
|
val direction: VitalWearHceTransferDirection
|
||||||
|
val maxChunkSize: Int
|
||||||
|
val payloadLength: Int
|
||||||
|
|
||||||
|
fun isWatchToPhone(): Boolean {
|
||||||
|
return direction == VitalWearHceTransferDirection.WATCH_TO_PHONE
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isPhoneToWatch(): Boolean {
|
||||||
|
return direction == VitalWearHceTransferDirection.PHONE_TO_WATCH
|
||||||
|
}
|
||||||
|
|
||||||
|
fun canCommit(): Boolean {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
fun canPollStatus(): Boolean {
|
||||||
|
// Import confirmation polling only applies for phone->watch transfers.
|
||||||
|
return isPhoneToWatch()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal data class VitalWearHceSession(
|
||||||
|
override val direction: VitalWearHceTransferDirection,
|
||||||
|
override val maxChunkSize: Int,
|
||||||
|
override val payloadLength: Int,
|
||||||
|
) : VitalWearHceSessionInfo
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@ -1,6 +1,14 @@
|
|||||||
package com.github.nacabaro.vbhelper.utils
|
package com.github.nacabaro.vbhelper.utils
|
||||||
|
|
||||||
enum class DeviceType {
|
enum class DeviceType {
|
||||||
VBDevice,
|
VBDevice, // Bandai Vital Bracelet
|
||||||
BEDevice
|
BEDevice, // Bandai BE Bracelet
|
||||||
|
VitalWear // WearOS VitalWear app
|
||||||
|
}
|
||||||
|
|
||||||
|
fun detectDeviceType(deviceName: String): DeviceType = when {
|
||||||
|
deviceName.startsWith("VitalWear", ignoreCase = true) || deviceName.startsWith("VW-", ignoreCase = true) -> DeviceType.VitalWear
|
||||||
|
deviceName.startsWith("BE-", ignoreCase = true) -> DeviceType.BEDevice
|
||||||
|
deviceName.startsWith("VB-", ignoreCase = true) -> DeviceType.VBDevice
|
||||||
|
else -> DeviceType.VBDevice // Default/fallback, can be refined
|
||||||
}
|
}
|
||||||
@ -0,0 +1,51 @@
|
|||||||
|
syntax = "proto3";
|
||||||
|
|
||||||
|
option java_package = "com.github.cfogrady.vitalwear.protos";
|
||||||
|
option java_multiple_files = true;
|
||||||
|
|
||||||
|
message Character {
|
||||||
|
string card_name = 1;
|
||||||
|
int32 card_id = 2;
|
||||||
|
|
||||||
|
message CharacterStats {
|
||||||
|
int32 slot_id = 1;
|
||||||
|
int32 vitals = 2;
|
||||||
|
int64 training_time_remaining_in_seconds = 3;
|
||||||
|
int64 time_until_next_transformation = 4;
|
||||||
|
int32 trained_bp = 5;
|
||||||
|
int32 trained_hp = 6;
|
||||||
|
int32 trained_ap = 7;
|
||||||
|
int32 trained_pp = 8;
|
||||||
|
bool injured = 9;
|
||||||
|
int32 accumulated_daily_injuries = 10;
|
||||||
|
int32 total_battles = 11;
|
||||||
|
int32 current_phase_battles = 12;
|
||||||
|
int32 total_wins = 13;
|
||||||
|
int32 current_phase_wins = 14;
|
||||||
|
int32 mood = 15;
|
||||||
|
}
|
||||||
|
CharacterStats character_stats = 3;
|
||||||
|
|
||||||
|
message Settings {
|
||||||
|
bool training_in_background = 1;
|
||||||
|
enum AllowedBattles {
|
||||||
|
CARD_ONLY = 0;
|
||||||
|
ALL_FRANCHISE = 1;
|
||||||
|
ALL_FRANCHISE_AND_DIM = 2;
|
||||||
|
ALL = 3;
|
||||||
|
}
|
||||||
|
AllowedBattles allowed_battles = 2;
|
||||||
|
optional int32 assumed_franchise = 3;
|
||||||
|
}
|
||||||
|
Settings settings = 4;
|
||||||
|
|
||||||
|
message TransformationEvent {
|
||||||
|
string card_name = 1;
|
||||||
|
int32 phase = 2;
|
||||||
|
int32 slot_id = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
repeated TransformationEvent transformation_history = 5;
|
||||||
|
|
||||||
|
map<string, int32> max_adventure_completed_by_card = 6;
|
||||||
|
}
|
||||||
151
app/src/main/res/values-ja/strings.xml
Normal file
151
app/src/main/res/values-ja/strings.xml
Normal file
@ -0,0 +1,151 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<string name="app_name">VBHelper</string>
|
||||||
|
<string name="beta_warning_button_dismiss">閉じる</string>
|
||||||
|
<string name="nav_scan">スキャン</string>
|
||||||
|
<string name="nav_battle">バトル</string>
|
||||||
|
<string name="nav_home">ホーム</string>
|
||||||
|
<string name="nav_card_adventure">アドベンチャー</string>
|
||||||
|
<string name="nav_storage">ストレージ</string>
|
||||||
|
<string name="nav_settings">設定</string>
|
||||||
|
<string name="nav_viewer">閲覧</string>
|
||||||
|
<string name="nav_card">カード</string>
|
||||||
|
<string name="nav_items">アイテム</string>
|
||||||
|
<string name="nav_my_items">所持アイテム</string>
|
||||||
|
<string name="nav_items_store">ストア</string>
|
||||||
|
<string name="nav_apply_item">使用</string>
|
||||||
|
<string name="nav_adventure">アドベンチャー</string>
|
||||||
|
<string name="nav_credits">クレジット</string>
|
||||||
|
<string name="adventure_title">アドベンチャー</string>
|
||||||
|
<string name="adventure_empty_state">何もありません。</string>
|
||||||
|
<string name="home_title">VB Helper</string>
|
||||||
|
<string name="scan_secrets_not_imported">" APKの読み込みがされていません。設定から読み込をして下さい。 "</string>
|
||||||
|
<string name="scan_title">デバイスをスキャン</string>
|
||||||
|
<string name="scan_sent_dim_success">DIMの転送に成功しました。</string>
|
||||||
|
<string name="scan_nfc_must_be_enabled">NFC機能を有効にして下さい。</string>
|
||||||
|
<string name="settings_title">設定</string>
|
||||||
|
<string name="scan_missing_secrets">必要なデータが不足しています。設定を開き、Vital ArenaのAPKを読み込んでください。</string>
|
||||||
|
<string name="scan_sent_character_success">転送が成功しました。</string>
|
||||||
|
<string name="scan_error_generic">エラー</string>
|
||||||
|
<string name="settings_about_desc">このアプリについて</string>
|
||||||
|
<string name="settings_export_data_title">データベースの書き出し</string>
|
||||||
|
<string name="settings_export_data_desc">" データベースを書き出します。"</string>
|
||||||
|
<string name="settings_import_data_title">データベースの読み込み</string>
|
||||||
|
<string name="settings_import_data_desc">" データベースを読み込みます。"</string>
|
||||||
|
<string name="credits_title">クレジット</string>
|
||||||
|
<string name="battles_coming_soon">Coming soon</string>
|
||||||
|
<string name="cards_my_cards_title">カードリスト</string>
|
||||||
|
<string name="writing_character_action_title">キャラクター情報書き込み中</string>
|
||||||
|
<string name="items_title">アイテム</string>
|
||||||
|
<string name="items_no_items">アイテムはありません。</string>
|
||||||
|
<string name="items_store_credits">%1$d クレジット</string>
|
||||||
|
<string name="scan_vb_to_app">デバイスからアプリへ</string>
|
||||||
|
<string name="scan_app_to_vb">アプリからデバイスへ</string>
|
||||||
|
<string name="read_character_title">キャラクターを読み込みます。</string>
|
||||||
|
<string name="read_character_prepare_device">バイタルブレスを準備してください。</string>
|
||||||
|
<string name="read_character_go_to_connect">準備が出来たら転送を押してください。</string>
|
||||||
|
<string name="read_character_confirm">転送</string>
|
||||||
|
<string name="card_adventure_missions_title">アドベンチャーミッション</string>
|
||||||
|
<string name="card_entry_characters_obtained">%1$d / %2$d のキャラクターを取得しました。</string>
|
||||||
|
<string name="card_entry_edit">編集</string>
|
||||||
|
<string name="card_entry_edit">編集</string>
|
||||||
|
<string name="card_entry_edit">編集</string>
|
||||||
|
<string name="card_entry_edit">編集</string>
|
||||||
|
<string name="card_entry_delete">削除</string>
|
||||||
|
<string name="home_vbdim_current_phase_win">フェーズ勝率 %</string>
|
||||||
|
<string name="home_vbdim_special_missions">スペシャルミッション</string>
|
||||||
|
<string name="storage_delete_character">削除</string>
|
||||||
|
<string name="storage_close">閉じる</string>
|
||||||
|
<string name="storage_character_image_description">キャラクター画像</string>
|
||||||
|
<string name="storage_send_to_watch">転送</string>
|
||||||
|
<string name="storage_set_active">選択</string>
|
||||||
|
<string name="storage_send_on_adventure">アドベンチャーへ出発</string>
|
||||||
|
<string name="scan_no_nfc_on_device">NFC機能が見つかりません。</string>
|
||||||
|
<string name="scan_tag_not_vb">検出されたタグはバイタルブレスでは使用出来ません。</string>
|
||||||
|
<string name="settings_section_nfc">NFC</string>
|
||||||
|
<string name="settings_section_dim_bem">DiM / BEm</string>
|
||||||
|
<string name="settings_section_data">データ管理</string>
|
||||||
|
<string name="settings_import_apk_title">アプリの読み込み</string>
|
||||||
|
<string name="settings_import_apk_desc">" Vital Arena 2.1.0のAPKファイルを読み込みします。"</string>
|
||||||
|
<string name="settings_import_card_title">カードの読み込み</string>
|
||||||
|
<string name="settings_section_about">概要 / クレジット</string>
|
||||||
|
<string name="dex_chara_fusions_button">ジョグレス</string>
|
||||||
|
<string name="dex_chara_close_button">閉じる</string>
|
||||||
|
<string name="settings_import_card_desc">" Dim / BEm カードファイルを読み込みます。"</string>
|
||||||
|
<string name="settings_credits_title">クレジット</string>
|
||||||
|
<string name="settings_credits_desc">開発者について</string>
|
||||||
|
<string name="settings_about_title">概要</string>
|
||||||
|
<string name="beta_warning_message_main">" このアプリは現在アルファ版です。今後のアップデートにより保存されているデータの全てが削除される可能性がある為、大切なデータの保存はしないようにお願い致します。ご不便をおかけして申し訳ございません。 "</string>
|
||||||
|
<string name="beta_warning_message_compatibility">" このアプリはオリジナルのVBおよびVHで動作するようになりました。 "</string>
|
||||||
|
<string name="beta_warning_message_thanks">" ご理解とご協力に感謝いたします。\n 開発チーム一同"</string>
|
||||||
|
<string name="home_vbdim_vitals">バイタル</string>
|
||||||
|
<string name="home_adventure_mission_finished">" 1人のキャラクターがアドベンチャーミッションを完了しました。 "</string>
|
||||||
|
<string name="scan_secrets_not_initialized">" 初期化が出来ませんでした。もう一度試して下さい。 "</string>
|
||||||
|
<string name="home_special_mission_delete_main">現在の進捗状況が失われますが、本当にこのスペシャルミッションを削除しますか?</string>
|
||||||
|
<string name="home_special_mission_delete_dismiss">閉じる</string>
|
||||||
|
<string name="home_special_mission_delete_remove">削除</string>
|
||||||
|
<string name="item_dialog_use">アイテム使用</string>
|
||||||
|
<string name="item_dialog_purchase">購入</string>
|
||||||
|
<string name="item_dialog_cancel">キャンセル</string>
|
||||||
|
<string name="dex_chara_stage_attribute">Stg: %1$s, Atr: %2$s</string>
|
||||||
|
<string name="dex_chara_unknown_name">\?\\?\\?\\?\\?\\?\\?\\?\\?\\?\\?\\?\\?\\?\\?\\?</string>
|
||||||
|
<string name="battles_online_title">オンラインバトル</string>
|
||||||
|
<string name="obtained_item_you_also_got_credits">%1$d クレジットを獲得しました。</string>
|
||||||
|
<string name="obtained_item_dismiss">閉じる</string>
|
||||||
|
<string name="obtained_item_dismiss">閉じる</string>
|
||||||
|
<string name="nav_dex">図鑑</string>
|
||||||
|
<string name="action_place_near_reader">バイタルブレス本体を近くに置いて下さい。</string>
|
||||||
|
<string name="action_cancel">キャンセル</string>
|
||||||
|
<string name="card_view_discovered_characters">解放済みキャラクター</string>
|
||||||
|
<string name="dex_chara_icon_description">キャラクターアイコン</string>
|
||||||
|
<string name="dex_chara_name_icon_description">名前</string>
|
||||||
|
<string name="credits_section_reverse_engineering">リバースエンジニアリング</string>
|
||||||
|
<string name="credits_section_reverse_engineering">リバースエンジニアリング</string>
|
||||||
|
<string name="credits_section_app_development">アプリケーション開発</string>
|
||||||
|
<string name="credits_cyanic_description">" ファームウェアの解析を行い、開発をサポートしてくれました。"</string>
|
||||||
|
<string name="credits_cfogrady_description">" vb-lib-nfc開発およびアプリの開発に協力して頂きました。"</string>
|
||||||
|
<string name="credits_nacabaro_description">" アプリの開発をしました。"</string>
|
||||||
|
<string name="credits_lightheel_description">" アプリのサーバー含む対戦機能を開発して頂きました。現在も開発を継続して頂いています。"</string>
|
||||||
|
<string name="credits_shvstrz_description">" アプリのアイコンデザインをして頂きました。"</string>
|
||||||
|
<string name="write_card_title">カード情報書き込み中</string>
|
||||||
|
<string name="write_card_icon_description">カードアイコン</string>
|
||||||
|
<string name="write_card_device_ready">デバイスの準備をして下さい。</string>
|
||||||
|
<string name="write_card_required_card">%1$s カードが必要です。</string>
|
||||||
|
<string name="write_card_confirm">転送</string>
|
||||||
|
<string name="write_character_success">" カードの読み込みが完了しました。"</string>
|
||||||
|
<string name="write_character_wait_ready">" デバイスの準備をしてから確認を押して下さい。"</string>
|
||||||
|
<string name="write_character_confirm">確認</string>
|
||||||
|
<string name="home_vbdim_trophies">トロフィー</string>
|
||||||
|
<string name="items_purchase_success">購入しました。</string>
|
||||||
|
<string name="obtained_item_you_have">%1$d 個入手しました。</string>
|
||||||
|
<string name="choose_character_title">キャラクターを選択</string>
|
||||||
|
<string name="choose_character_item_applied">アイテムを使用しました。</string>
|
||||||
|
<string name="item_dialog_costs_credits">%1$d クレジット</string>
|
||||||
|
<string name="dex_chara_stats">HP: %1$d, BP: %2$d, AP: %3$d</string>
|
||||||
|
<string name="dex_chara_stage_attribute_unknown">Stg: -, Atr: -</string>
|
||||||
|
<string name="dex_chara_stats_unknown">HP: -, BP: -, AP: -</string>
|
||||||
|
<string name="dex_chara_requirements">" Tr: %1$d; Bt: %2$d; Vr: %3$d; Wr: %4$d%%; Ct: %5$dh "</string>
|
||||||
|
<string name="dex_chara_adventure_level">AdvLvl %1$d</string>
|
||||||
|
<string name="storage_my_characters_title">キャラクターリスト</string>
|
||||||
|
<string name="storage_nothing_to_see_here">表示するデータはありません。</string>
|
||||||
|
<string name="storage_in_adventure_toast">このキャラクターは冒険中です。</string>
|
||||||
|
<string name="home_vbdim_next_timer">次のタイマー</string>
|
||||||
|
<string name="home_vbdim_total_battle_win">総勝率 %</string>
|
||||||
|
<string name="special_mission_none">ミッションが選択されていません。</string>
|
||||||
|
<string name="special_mission_steps">%1$d 歩</string>
|
||||||
|
<string name="write_character_title">キャラクター情報書き込み中</string>
|
||||||
|
<string name="write_character_icon_description">キャラクターアイコン</string>
|
||||||
|
<string name="items_not_enough_credits">クレジットが足りません。</string>
|
||||||
|
<string name="special_mission_vitals_progress">獲得バイタル値 %1$d</string>
|
||||||
|
<string name="special_mission_icon_content_description">スペシャルミッションアイコン</string>
|
||||||
|
<string name="reading_character_title">キャラクター情報読み込み中</string>
|
||||||
|
<string name="sending_card_title">カード情報送信中</string>
|
||||||
|
<string name="item_dialog_you_have_quantity">所持数: %1$d</string>
|
||||||
|
<string name="home_vbdim_mood">メンタル</string>
|
||||||
|
<string name="special_mission_battles">バトル %1$d 回</string>
|
||||||
|
<string name="special_mission_wins">勝利 %1$d 回</string>
|
||||||
|
<string name="special_mission_vitals">バイタル値 %1$d</string>
|
||||||
|
<string name="special_mission_steps_progress">歩数 %1$d 歩</string>
|
||||||
|
<string name="special_mission_battles_progress">バトル %1$d 回</string>
|
||||||
|
<string name="special_mission_wins_progress">勝利 %1$d 回</string>
|
||||||
|
</resources>
|
||||||
@ -49,9 +49,7 @@
|
|||||||
|
|
||||||
<string name="scan_no_nfc_on_device">O dispositivo não possui NFC!</string>
|
<string name="scan_no_nfc_on_device">O dispositivo não possui NFC!</string>
|
||||||
<string name="scan_tag_not_vb">A tag detectada não é do Vital Bracelet.</string>
|
<string name="scan_tag_not_vb">A tag detectada não é do Vital Bracelet.</string>
|
||||||
<string name="scan_missing_secrets">
|
<string name="scan_missing_secrets">" Segredos ausentes. Vá em Configurações e importe o APK do Vital Arena. "</string>
|
||||||
Segredos ausentes. Vá em Configurações e importe o APK do Vital Arena.
|
|
||||||
</string>
|
|
||||||
<string name="scan_sent_character_success">Personagem enviado com sucesso!</string>
|
<string name="scan_sent_character_success">Personagem enviado com sucesso!</string>
|
||||||
<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>
|
||||||
@ -94,9 +92,7 @@
|
|||||||
<string name="credits_section_reverse_engineering">Engenharia reversa</string>
|
<string name="credits_section_reverse_engineering">Engenharia reversa</string>
|
||||||
<string name="credits_section_app_development">Desenvolvimento do aplicativo</string>
|
<string name="credits_section_app_development">Desenvolvimento do aplicativo</string>
|
||||||
|
|
||||||
<string name="credits_cyanic_description">
|
<string name="credits_cyanic_description">" Reverteu o firmware e nos ajudou durante o desenvolvimento. "</string>
|
||||||
Reverteu o firmware e nos ajudou durante o desenvolvimento.
|
|
||||||
</string>
|
|
||||||
<string name="credits_cfogrady_description">
|
<string name="credits_cfogrady_description">
|
||||||
Desenvolveu vb-lib-nfc e parte deste aplicativo.
|
Desenvolveu vb-lib-nfc e parte deste aplicativo.
|
||||||
</string>
|
</string>
|
||||||
@ -128,9 +124,7 @@
|
|||||||
|
|
||||||
<string name="write_character_title">Escrevendo personagem</string>
|
<string name="write_character_title">Escrevendo personagem</string>
|
||||||
<string name="write_character_icon_description">Ícone do personagem</string>
|
<string name="write_character_icon_description">Ícone do personagem</string>
|
||||||
<string name="write_character_success">
|
<string name="write_character_success">" Cartão instalado com sucesso!! "</string>
|
||||||
Cartão instalado com sucesso!!
|
|
||||||
</string>
|
|
||||||
<string name="write_character_wait_ready">
|
<string name="write_character_wait_ready">
|
||||||
Aguarde até que seu dispositivo esteja pronto e então toque em Confirmar
|
Aguarde até que seu dispositivo esteja pronto e então toque em Confirmar
|
||||||
</string>
|
</string>
|
||||||
|
|||||||
@ -36,26 +36,20 @@
|
|||||||
<string name="adventure_empty_state">Nothing to see here</string>
|
<string name="adventure_empty_state">Nothing to see here</string>
|
||||||
|
|
||||||
<string name="home_title">VB Helper</string>
|
<string name="home_title">VB Helper</string>
|
||||||
<string name="home_adventure_mission_finished">
|
<string name="home_adventure_mission_finished">" One of your characters has finished their adventure mission! "</string>
|
||||||
One of your characters has finished their adventure mission!
|
|
||||||
</string>
|
|
||||||
|
|
||||||
<string name="scan_secrets_not_initialized">
|
<string name="scan_secrets_not_initialized">" Secrets is not yet initialized. Try again. "</string>
|
||||||
Secrets is not yet initialized. Try again.
|
|
||||||
</string>
|
|
||||||
<string name="scan_secrets_not_imported">
|
<string name="scan_secrets_not_imported">
|
||||||
Secrets not yet imported. Go to Settings and Import APK.
|
Secrets not yet imported. Go to Settings and Import APK.
|
||||||
</string>
|
</string>
|
||||||
|
|
||||||
<string name="scan_title">Scan a Vital Bracelet</string>
|
<string name="scan_title">Scan a Vital Bracelet</string>
|
||||||
<string name="scan_vb_to_app">Vital Bracelet to App</string>
|
<string name="scan_vb_to_app">Watch to VBH</string>
|
||||||
<string name="scan_app_to_vb">App to Vital Bracelet</string>
|
<string name="scan_app_to_vb">VBH to Watch</string>
|
||||||
|
|
||||||
<string name="scan_no_nfc_on_device">No NFC on device!</string>
|
<string name="scan_no_nfc_on_device">No NFC on device!</string>
|
||||||
<string name="scan_tag_not_vb">Tag detected is not VB</string>
|
<string name="scan_tag_not_vb">Tag detected is not VB</string>
|
||||||
<string name="scan_missing_secrets">
|
<string name="scan_missing_secrets">" Missing Secrets. Go to settings and import Vital Arena APK. "</string>
|
||||||
Missing Secrets. Go to settings and import Vital Arena APK.
|
|
||||||
</string>
|
|
||||||
<string name="scan_sent_character_success">Sent character successfully!</string>
|
<string name="scan_sent_character_success">Sent character successfully!</string>
|
||||||
<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>
|
||||||
@ -71,12 +65,12 @@
|
|||||||
|
|
||||||
<string name="settings_import_apk_title">Import APK</string>
|
<string name="settings_import_apk_title">Import APK</string>
|
||||||
<string name="settings_import_apk_desc">
|
<string name="settings_import_apk_desc">
|
||||||
Import Secrets From Vital Arena 2.1.0 APK
|
Import Secrets From Vital Arena APK
|
||||||
</string>
|
</string>
|
||||||
|
|
||||||
<string name="settings_import_card_title">Import card</string>
|
<string name="settings_import_card_title">Import card</string>
|
||||||
<string name="settings_import_card_desc">
|
<string name="settings_import_card_desc">
|
||||||
Import DiM/BEm card file
|
Import DiM/BEm
|
||||||
</string>
|
</string>
|
||||||
|
|
||||||
<string name="settings_credits_title">Credits</string>
|
<string name="settings_credits_title">Credits</string>
|
||||||
@ -99,9 +93,7 @@
|
|||||||
<string name="credits_section_reverse_engineering">Reverse engineering</string>
|
<string name="credits_section_reverse_engineering">Reverse engineering</string>
|
||||||
<string name="credits_section_app_development">Application development</string>
|
<string name="credits_section_app_development">Application development</string>
|
||||||
|
|
||||||
<string name="credits_cyanic_description">
|
<string name="credits_cyanic_description">" Reversed the firmware and helped us during development. "</string>
|
||||||
Reversed the firmware and helped us during development.
|
|
||||||
</string>
|
|
||||||
<string name="credits_cfogrady_description">
|
<string name="credits_cfogrady_description">
|
||||||
Developed vb-lib-nfc and part of this application.
|
Developed vb-lib-nfc and part of this application.
|
||||||
</string>
|
</string>
|
||||||
@ -114,7 +106,9 @@
|
|||||||
<string name="credits_shvstrz_description">
|
<string name="credits_shvstrz_description">
|
||||||
Designing the app icon in SVG.
|
Designing the app icon in SVG.
|
||||||
</string>
|
</string>
|
||||||
|
<string name="credits_RedEyez_description">
|
||||||
|
Devout Debugger.
|
||||||
|
</string>
|
||||||
<string name="action_place_near_reader">Place your Vital Bracelet near the reader...</string>
|
<string name="action_place_near_reader">Place your Vital Bracelet near the reader...</string>
|
||||||
<string name="action_cancel">Cancel</string>
|
<string name="action_cancel">Cancel</string>
|
||||||
|
|
||||||
@ -134,9 +128,7 @@
|
|||||||
|
|
||||||
<string name="write_character_title">Writing character</string>
|
<string name="write_character_title">Writing character</string>
|
||||||
<string name="write_character_icon_description">Character icon</string>
|
<string name="write_character_icon_description">Character icon</string>
|
||||||
<string name="write_character_success">
|
<string name="write_character_success">" Card installed successfully!! "</string>
|
||||||
Card installed successfully!!
|
|
||||||
</string>
|
|
||||||
<string name="write_character_wait_ready">
|
<string name="write_character_wait_ready">
|
||||||
Wait until your device is ready, then tap Confirm
|
Wait until your device is ready, then tap Confirm
|
||||||
</string>
|
</string>
|
||||||
@ -194,11 +186,12 @@
|
|||||||
<string name="card_entry_delete">Delete card</string>
|
<string name="card_entry_delete">Delete card</string>
|
||||||
|
|
||||||
<string name="storage_my_characters_title">My characters</string>
|
<string name="storage_my_characters_title">My characters</string>
|
||||||
<string name="storage_nothing_to_see_here">Nothing to see here</string>
|
<string name="storage_nothing_to_see_here">Storage is Empty</string>
|
||||||
<string name="storage_in_adventure_toast">This character is in an adventure</string>
|
<string name="storage_in_adventure_toast">This character is in an adventure</string>
|
||||||
|
|
||||||
<string name="storage_character_image_description">Character image</string>
|
<string name="storage_character_image_description">Character image</string>
|
||||||
<string name="storage_send_to_watch">Send to watch</string>
|
<string name="storage_send_to_watch">Send to watch</string>
|
||||||
|
<string name="storage_send_to_vitalwear">Send to VitalWear</string>
|
||||||
<string name="storage_set_active">Set active</string>
|
<string name="storage_set_active">Set active</string>
|
||||||
<string name="storage_send_on_adventure">Send on adventure</string>
|
<string name="storage_send_on_adventure">Send on adventure</string>
|
||||||
<string name="storage_delete_character">Delete character</string>
|
<string name="storage_delete_character">Delete character</string>
|
||||||
|
|||||||
6
app/src/main/res/xml/provider_paths.xml
Normal file
6
app/src/main/res/xml/provider_paths.xml
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<paths xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<cache-path
|
||||||
|
name="exports"
|
||||||
|
path="exports" />
|
||||||
|
</paths>
|
||||||
@ -0,0 +1,25 @@
|
|||||||
|
package com.github.nacabaro.vbhelper.screens.scanScreen.converters
|
||||||
|
|
||||||
|
import com.github.nacabaro.vbhelper.utils.DeviceType
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class ToNfcConverterProfileSelectionTest {
|
||||||
|
@Test
|
||||||
|
fun shouldEncodeAsBem_usesStoredTypeWhenForcedProfileMissing() {
|
||||||
|
assertTrue(ToNfcConverter.shouldEncodeAsBem(null, DeviceType.BEDevice))
|
||||||
|
assertFalse(ToNfcConverter.shouldEncodeAsBem(null, DeviceType.VBDevice))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun shouldEncodeAsBem_forcedVBWinsOverStoredBE() {
|
||||||
|
assertFalse(ToNfcConverter.shouldEncodeAsBem(DeviceType.VBDevice, DeviceType.BEDevice))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun shouldEncodeAsBem_forcedBEWinsOverStoredVB() {
|
||||||
|
assertTrue(ToNfcConverter.shouldEncodeAsBem(DeviceType.BEDevice, DeviceType.VBDevice))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@ -0,0 +1,58 @@
|
|||||||
|
package com.github.nacabaro.vbhelper.source
|
||||||
|
|
||||||
|
import com.github.cfogrady.vbnfc.data.NfcCharacter
|
||||||
|
import com.github.nacabaro.vbhelper.domain.device_data.BECharacterData
|
||||||
|
import com.github.nacabaro.vbhelper.utils.DeviceType
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class VitalWearCharacterExporterTest {
|
||||||
|
@Test
|
||||||
|
fun resolveTrainingSeconds_preservesBeTrainingMinutesAsSeconds() {
|
||||||
|
val beData = testBeData(remainingTrainingTimeInMinutes = 37)
|
||||||
|
|
||||||
|
assertEquals(37L * 60L, resolveTrainingSeconds(DeviceType.BEDevice, beData))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun resolveTrainingSeconds_defaultsNonBeCharactersToVitalWearTrainingWindow() {
|
||||||
|
assertEquals(
|
||||||
|
DEFAULT_VITALWEAR_TRAINING_TIME_SECONDS,
|
||||||
|
resolveTrainingSeconds(DeviceType.VBDevice, beData = null)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun resolveTrainingSeconds_keepsZeroForBeCharactersWithoutStoredBeData() {
|
||||||
|
assertEquals(0L, resolveTrainingSeconds(DeviceType.BEDevice, beData = null))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun testBeData(remainingTrainingTimeInMinutes: Int): BECharacterData {
|
||||||
|
return BECharacterData(
|
||||||
|
id = 1L,
|
||||||
|
trainingHp = 0,
|
||||||
|
trainingAp = 0,
|
||||||
|
trainingBp = 0,
|
||||||
|
remainingTrainingTimeInMinutes = remainingTrainingTimeInMinutes,
|
||||||
|
itemEffectMentalStateValue = 0,
|
||||||
|
itemEffectMentalStateMinutesRemaining = 0,
|
||||||
|
itemEffectActivityLevelValue = 0,
|
||||||
|
itemEffectActivityLevelMinutesRemaining = 0,
|
||||||
|
itemEffectVitalPointsChangeValue = 0,
|
||||||
|
itemEffectVitalPointsChangeMinutesRemaining = 0,
|
||||||
|
abilityRarity = NfcCharacter.AbilityRarity.entries.first(),
|
||||||
|
abilityType = 0,
|
||||||
|
abilityBranch = 0,
|
||||||
|
abilityReset = 0,
|
||||||
|
rank = 0,
|
||||||
|
itemType = 0,
|
||||||
|
itemMultiplier = 0,
|
||||||
|
itemRemainingTime = 0,
|
||||||
|
otp0 = "",
|
||||||
|
otp1 = "",
|
||||||
|
minorVersion = 0,
|
||||||
|
majorVersion = 0,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@ -0,0 +1,106 @@
|
|||||||
|
package com.github.nacabaro.vbhelper.source
|
||||||
|
|
||||||
|
import com.github.cfogrady.vitalwear.protos.Character
|
||||||
|
import com.github.nacabaro.vbhelper.domain.card.Card
|
||||||
|
import com.github.nacabaro.vbhelper.utils.DeviceType
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNotNull
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class VitalWearCharacterImporterTest {
|
||||||
|
@Test
|
||||||
|
fun normalizeTransformationCountdownMinutes_keepsOneMinuteMinimumWhenCharacterCanStillEvolve() {
|
||||||
|
assertEquals(1, normalizeTransformationCountdownMinutes(0, hasPossibleTransformations = true))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun normalizeTransformationCountdownMinutes_preservesPositiveCountdownWhenCharacterCanStillEvolve() {
|
||||||
|
assertEquals(12, normalizeTransformationCountdownMinutes(12, hasPossibleTransformations = true))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun normalizeTransformationCountdownMinutes_allowsZeroWhenNoTransformationsRemain() {
|
||||||
|
assertEquals(0, normalizeTransformationCountdownMinutes(0, hasPossibleTransformations = false))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun resolveDeviceType_prefersExplicitTransferTypeOverFallbackHeuristic() {
|
||||||
|
assertEquals(
|
||||||
|
DeviceType.VBDevice,
|
||||||
|
resolveDeviceType(
|
||||||
|
transferDeviceType = Character.CharacterStats.TransferDeviceType.TRANSFER_DEVICE_TYPE_VB,
|
||||||
|
fallbackIsBeCharacter = true,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun resolveDeviceType_fallsBackToBeWhenTransferTypeIsUnspecified() {
|
||||||
|
assertEquals(
|
||||||
|
DeviceType.BEDevice,
|
||||||
|
resolveDeviceType(
|
||||||
|
transferDeviceType = Character.CharacterStats.TransferDeviceType.TRANSFER_DEVICE_TYPE_UNSPECIFIED,
|
||||||
|
fallbackIsBeCharacter = true,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun resolveDeviceType_usesFallbackWhenTransferTypeIsUnrecognized() {
|
||||||
|
assertEquals(
|
||||||
|
DeviceType.VBDevice,
|
||||||
|
resolveDeviceType(
|
||||||
|
transferDeviceType = Character.CharacterStats.TransferDeviceType.UNRECOGNIZED,
|
||||||
|
fallbackIsBeCharacter = false,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun selectImportedCard_prefersUniqueCardIdWhenNameWasRenamed() {
|
||||||
|
val selected = selectImportedCard(
|
||||||
|
candidates = listOf(
|
||||||
|
testCard(id = 1, cardId = 77, name = "My Custom DIM"),
|
||||||
|
testCard(id = 2, cardId = 88, name = "Other"),
|
||||||
|
),
|
||||||
|
incomingCardName = "Impulse City",
|
||||||
|
incomingCardId = 77,
|
||||||
|
)
|
||||||
|
|
||||||
|
assertNotNull(selected)
|
||||||
|
assertEquals("My Custom DIM", selected?.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun selectImportedCard_matchesNormalizedRenamedNameWhenCardIdMissing() {
|
||||||
|
val selected = selectImportedCard(
|
||||||
|
candidates = listOf(
|
||||||
|
testCard(id = 1, cardId = 0, name = "Impulse City!"),
|
||||||
|
),
|
||||||
|
incomingCardName = "impulse-city",
|
||||||
|
incomingCardId = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
assertNotNull(selected)
|
||||||
|
assertEquals("Impulse City!", selected?.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun cardNamesMatch_ignoresCaseAndPunctuation() {
|
||||||
|
assertEquals(true, cardNamesMatch("My DIM: Zero", "my-dim zero"))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun testCard(id: Long, cardId: Int, name: String): Card {
|
||||||
|
return Card(
|
||||||
|
id = id,
|
||||||
|
cardId = cardId,
|
||||||
|
logo = byteArrayOf(),
|
||||||
|
logoWidth = 0,
|
||||||
|
logoHeight = 0,
|
||||||
|
name = name,
|
||||||
|
stageCount = 0,
|
||||||
|
isBEm = false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Loading…
x
Reference in New Issue
Block a user