Make VBH-VW transfer DB owner via provider and shared transfer DAO

This commit is contained in:
Taveon Nelson 2026-04-21 18:57:38 -05:00
parent d40428ccfd
commit 895b704c8d
14 changed files with 344 additions and 15 deletions

View File

@ -2,6 +2,10 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android" <manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"> xmlns:tools="http://schemas.android.com/tools">
<permission
android:name="com.github.nacabaro.vbhelper.permission.ACCESS_TRANSFER_SEEN"
android:protectionLevel="signature" />
<uses-permission android:name="android.permission.NFC" /> <uses-permission android:name="android.permission.NFC" />
<uses-feature android:name="android.hardware.nfc" android:required="true" /> <uses-feature android:name="android.hardware.nfc" android:required="true" />
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
@ -31,6 +35,12 @@
android:name="android.support.FILE_PROVIDER_PATHS" android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/provider_paths" /> android:resource="@xml/provider_paths" />
</provider> </provider>
<provider
android:name=".transfer.SharedTransferSeenProvider"
android:authorities="com.github.nacabaro.vbhelper.transferseen"
android:exported="true"
android:readPermission="com.github.nacabaro.vbhelper.permission.ACCESS_TRANSFER_SEEN"
android:writePermission="com.github.nacabaro.vbhelper.permission.ACCESS_TRANSFER_SEEN" />
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true" android:exported="true"

View File

@ -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 }
}
}
}

View File

@ -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
}

View File

@ -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)
}
}
}

View File

@ -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"
}
}

View File

@ -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

View File

@ -36,7 +36,7 @@ import com.github.nacabaro.vbhelper.domain.device_data.VitalWearCharacterSetting
import com.github.nacabaro.vbhelper.domain.items.Items import com.github.nacabaro.vbhelper.domain.items.Items
@Database( @Database(
version = 2, version = 5,
entities = [ entities = [
Card::class, Card::class,
CardProgress::class, CardProgress::class,
@ -104,5 +104,40 @@ abstract class AppDatabase : RoomDatabase() {
) )
} }
} }
val MIGRATION_2_3 = object : Migration(2, 3) {
override fun migrate(db: SupportSQLiteDatabase) {
val backfillTimestamp = System.currentTimeMillis()
db.execSQL(
"""
INSERT INTO `TransformationHistory`(`monId`, `stageId`, `transformationDate`)
SELECT uc.`id`, uc.`charId`, $backfillTimestamp
FROM `UserCharacter` uc
WHERE NOT EXISTS (
SELECT 1
FROM `TransformationHistory` th
WHERE th.`monId` = uc.`id`
)
""".trimIndent()
)
}
}
val MIGRATION_3_5 = object : Migration(3, 5) {
override fun migrate(db: SupportSQLiteDatabase) {
// TransferSeen moved to shared_transfer.db
db.execSQL("DROP TABLE IF EXISTS `TransferSeen`")
db.execSQL("DROP INDEX IF EXISTS `index_TransferSeen_cardLookupKey_slotId`")
}
}
val MIGRATION_4_5 = object : Migration(4, 5) {
override fun migrate(db: SupportSQLiteDatabase) {
// TransferSeen moved to shared_transfer.db
db.execSQL("DROP TABLE IF EXISTS `TransferSeen`")
db.execSQL("DROP INDEX IF EXISTS `index_TransferSeen_cardLookupKey_slotId`")
}
}
} }
} }

View File

@ -1,11 +1,13 @@
package com.github.nacabaro.vbhelper.di package com.github.nacabaro.vbhelper.di
import com.github.cfogrady.vitalwear.common.data.SharedTransferSeenDao
import com.github.nacabaro.vbhelper.database.AppDatabase import com.github.nacabaro.vbhelper.database.AppDatabase
import com.github.nacabaro.vbhelper.source.CurrencyRepository import com.github.nacabaro.vbhelper.source.CurrencyRepository
import com.github.nacabaro.vbhelper.source.DataStoreSecretsRepository import com.github.nacabaro.vbhelper.source.DataStoreSecretsRepository
interface AppContainer { interface AppContainer {
val db: AppDatabase val db: AppDatabase
val transferSeenDao: SharedTransferSeenDao
val dataStoreSecretsRepository: DataStoreSecretsRepository val dataStoreSecretsRepository: DataStoreSecretsRepository
val currencyRepository: CurrencyRepository val currencyRepository: CurrencyRepository
} }

View File

@ -4,8 +4,10 @@ import androidx.datastore.dataStore
import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.preferencesDataStore import androidx.datastore.preferences.preferencesDataStore
import androidx.room.Room import androidx.room.Room
import com.github.nacabaro.vbhelper.database.AppDatabase import com.github.cfogrady.vitalwear.common.data.SharedDatabaseFactory
import com.github.cfogrady.vitalwear.common.data.SharedTransferSeenDao
import com.github.nacabaro.vbhelper.di.AppContainer import com.github.nacabaro.vbhelper.di.AppContainer
import com.github.nacabaro.vbhelper.database.AppDatabase
import com.github.nacabaro.vbhelper.source.CurrencyRepository import com.github.nacabaro.vbhelper.source.CurrencyRepository
import com.github.nacabaro.vbhelper.source.DataStoreSecretsRepository import com.github.nacabaro.vbhelper.source.DataStoreSecretsRepository
import com.github.nacabaro.vbhelper.source.SecretsSerializer import com.github.nacabaro.vbhelper.source.SecretsSerializer
@ -32,9 +34,16 @@ class DefaultAppContainer(private val context: Context) : AppContainer {
) )
.createFromAsset("items.db") .createFromAsset("items.db")
.addMigrations(AppDatabase.MIGRATION_1_2) .addMigrations(AppDatabase.MIGRATION_1_2)
.addMigrations(AppDatabase.MIGRATION_2_3)
.addMigrations(AppDatabase.MIGRATION_3_5)
.addMigrations(AppDatabase.MIGRATION_4_5)
.build() .build()
} }
override val transferSeenDao: SharedTransferSeenDao by lazy {
SharedDatabaseFactory.getDatabase(context).transferSeenDao()
}
override val dataStoreSecretsRepository = DataStoreSecretsRepository(context.secretsStore) override val dataStoreSecretsRepository = DataStoreSecretsRepository(context.secretsStore)
override val currencyRepository = CurrencyRepository(context.currencyStore) override val currencyRepository = CurrencyRepository(context.currencyStore)

View File

@ -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

View File

@ -66,6 +66,9 @@ class ScanScreenControllerImpl(
0xF0.toByte(), 0x56, 0x49, 0x54, 0x41, 0x4C, 0x57, 0x45, 0x41, 0x52 0xF0.toByte(), 0x56, 0x49, 0x54, 0x41, 0x4C, 0x57, 0x45, 0x41, 0x52
) )
private const val SW_OK = 0x9000 private const val SW_OK = 0x9000
// Lifecycle key for the always-on NFC suppressor that prevents Android from launching
// com.android.apps.tag/.TagViewer whenever the watch's HCE comes into range.
private const val LIFECYCLE_KEY_NFC_SUPPRESSOR = "nfc_suppressor"
} }
init { init {
@ -75,6 +78,57 @@ class ScanScreenControllerImpl(
} }
nfcAdapter = maybeNfcAdapter nfcAdapter = maybeNfcAdapter
checkSecrets() checkSecrets()
registerNfcSuppressor()
}
/**
* Registers a lifecycle listener that keeps reader mode active (with a no-op callback)
* whenever this Activity is in the foreground. This prevents Android's default NFC dispatch
* system from launching com.android.apps.tag/.TagViewer when the watch's HCE service is
* detected while the user hasn't explicitly pressed a transfer button yet.
*
* When the user presses Read/Write/CheckCard, [handleTag] replaces this suppressor with
* the real transfer callback via [NfcAdapter.enableReaderMode]. After the transfer
* completes, [enableNfcSuppressor] is called again to restore the passive suppressor.
*/
private fun registerNfcSuppressor() {
registerActivityLifecycleListener(
LIFECYCLE_KEY_NFC_SUPPRESSOR,
object : ActivityLifecycleListener {
override fun onResume() {
enableNfcSuppressor()
}
override fun onPause() {
disableReaderModeSafely()
}
}
)
}
/**
* Enables reader mode with a silent no-op callback. Calling [NfcAdapter.enableReaderMode]
* suppresses Android's default tag-dispatch system (and therefore the "new tag scanned"
* TagViewer screen) for as long as this Activity is in the foreground. The actual transfer
* logic is wired up separately via [handleTag] when the user presses a button.
*/
private fun enableNfcSuppressor() {
if (!nfcAdapter.isEnabled) return
val options = Bundle()
options.putInt(NfcAdapter.EXTRA_READER_PRESENCE_CHECK_DELAY, 250)
val flags = NfcAdapter.FLAG_READER_NFC_A or
NfcAdapter.FLAG_READER_NFC_B or
NfcAdapter.FLAG_READER_NO_PLATFORM_SOUNDS or
NfcAdapter.FLAG_READER_SKIP_NDEF_CHECK
runCatching {
nfcAdapter.enableReaderMode(
componentActivity,
{ /* suppress default dispatch — user must tap a transfer button */ },
flags,
options
)
}.onFailure {
Log.w("NFC", "Failed to enable NFC suppressor reader mode", it)
}
} }
// ---- Read (phone receives character FROM watch or bracelet) -------------------- // ---- Read (phone receives character FROM watch or bracelet) --------------------
@ -103,7 +157,7 @@ class ScanScreenControllerImpl(
}, },
isoDepHandler = { isoDep -> isoDepHandler = { isoDep ->
val application = componentActivity.applicationContext as VBHelper val application = componentActivity.applicationContext as VBHelper
val importer = VitalWearCharacterImporter(application.container.db) val importer = VitalWearCharacterImporter(application.container.db, application.container.transferSeenDao)
try { try {
var importResult: VitalWearCharacterImporter.ImportResult? = null var importResult: VitalWearCharacterImporter.ImportResult? = null
val moved = VitalWearHceReaderClient(isoDep).moveCharacterFromWatch { character -> val moved = VitalWearHceReaderClient(isoDep).moveCharacterFromWatch { character ->
@ -176,7 +230,7 @@ class ScanScreenControllerImpl(
runCatching { runCatching {
var importResult: VitalWearCharacterImporter.ImportResult? = null var importResult: VitalWearCharacterImporter.ImportResult? = null
val moved = hceClient.moveCharacterFromWatch { character -> val moved = hceClient.moveCharacterFromWatch { character ->
val result = VitalWearCharacterImporter(application.container.db).importCharacter(character) val result = VitalWearCharacterImporter(application.container.db, application.container.transferSeenDao).importCharacter(character)
importResult = result importResult = result
result.success result.success
} }
@ -236,7 +290,9 @@ class ScanScreenControllerImpl(
lastTagId = null lastTagId = null
lastTagHandledAtMs = 0L lastTagHandledAtMs = 0L
} }
disableReaderModeSafely() // Re-arm the suppressor so the TagViewer doesn't appear while the user is still
// on the scan screen but hasn't pressed a transfer button yet.
enableNfcSuppressor()
} }
override fun registerActivityLifecycleListener(key: String, activityLifecycleListener: ActivityLifecycleListener) { override fun registerActivityLifecycleListener(key: String, activityLifecycleListener: ActivityLifecycleListener) {
@ -370,7 +426,9 @@ class ScanScreenControllerImpl(
Log.w("NFC", "Failed to ignore handled tag", it) Log.w("NFC", "Failed to ignore handled tag", it)
} }
disableReaderModeSafely() // Restore the passive suppressor so devices can remain close without Android
// launching the TagViewer between transfers or while waiting for the next button press.
enableNfcSuppressor()
isHandlingTag.set(false) isHandlingTag.set(false)
} }

View File

@ -19,6 +19,7 @@ class FromNfcConverter (
) { ) {
private val application = componentActivity.applicationContext as VBHelper private val application = componentActivity.applicationContext as VBHelper
private val database = application.container.db private val database = application.container.db
private val transferSeenDao = application.container.transferSeenDao
fun addCharacterUsingCard( fun addCharacterUsingCard(
@ -118,6 +119,9 @@ class FromNfcConverter (
.userCharacterDao() .userCharacterDao()
.insertCharacterData(characterData) .insertCharacterData(characterData)
val seenTimestamp = System.currentTimeMillis()
markSeen(cardData, nfcCharacter.charIndex.toInt(), seenTimestamp)
if (nfcCharacter is BENfcCharacter) { if (nfcCharacter is BENfcCharacter) {
addBeCharacterToDatabase( addBeCharacterToDatabase(
characterId = characterId, characterId = characterId,
@ -292,12 +296,14 @@ class FromNfcConverter (
database database
.dexDao() .dexDao()
.insertCharacter( .insertCharacter(item.toCharIndex.toInt(), dimData.id, date)
item.toCharIndex.toInt(), transferSeenDao.markSeen(dimData.name, item.toCharIndex.toInt(), date)
dimData.id,
date
)
} }
} }
} }
private fun markSeen(cardData: Card, slotId: Int, timestamp: Long) {
database.dexDao().insertCharacter(slotId, cardData.id, timestamp)
transferSeenDao.markSeen(cardData.name, slotId, timestamp)
}
} }

View File

@ -1,6 +1,7 @@
package com.github.nacabaro.vbhelper.source package com.github.nacabaro.vbhelper.source
import com.github.cfogrady.vbnfc.data.NfcCharacter import com.github.cfogrady.vbnfc.data.NfcCharacter
import com.github.cfogrady.vitalwear.common.data.SharedTransferSeenDao
import com.github.cfogrady.vitalwear.protos.Character import com.github.cfogrady.vitalwear.protos.Character
import com.github.nacabaro.vbhelper.database.AppDatabase import com.github.nacabaro.vbhelper.database.AppDatabase
import com.github.nacabaro.vbhelper.domain.card.Card import com.github.nacabaro.vbhelper.domain.card.Card
@ -13,7 +14,8 @@ import kotlinx.coroutines.runBlocking
import kotlin.math.max import kotlin.math.max
class VitalWearCharacterImporter( class VitalWearCharacterImporter(
private val database: AppDatabase private val database: AppDatabase,
private val transferSeenDao: SharedTransferSeenDao,
) { ) {
data class ImportResult( data class ImportResult(
val success: Boolean, val success: Boolean,
@ -113,8 +115,9 @@ class VitalWearCharacterImporter(
} }
val now = System.currentTimeMillis() val now = System.currentTimeMillis()
database.dexDao().insertCharacter(slotId, importedCard.id, now) markSeen(importedCard.name, slotId, importedCard.id, now)
var insertedTransformationCount = 0
for (transformation in character.transformationHistoryList) { for (transformation in character.transformationHistoryList) {
val transformationCard = resolveRelatedCard( val transformationCard = resolveRelatedCard(
incomingCardName = transformation.cardName, incomingCardName = transformation.cardName,
@ -129,10 +132,21 @@ class VitalWearCharacterImporter(
transformationCard.id, transformationCard.id,
now now
) )
database.dexDao().insertCharacter(transformation.slotId, transformationCard.id, now) insertedTransformationCount++
markSeen(transformationCard.name, transformation.slotId, transformationCard.id, now)
} }
} }
if (insertedTransformationCount == 0) {
// Keep HomeScreen renderable for freshly imported characters with empty history.
database.userCharacterDao().insertTransformation(
userCharacterId,
slotId,
importedCard.id,
now
)
}
for ((cardName, maxAdventureCompleted) in character.maxAdventureCompletedByCardMap) { for ((cardName, maxAdventureCompleted) in character.maxAdventureCompletedByCardMap) {
val adventureCard = resolveRelatedCard( val adventureCard = resolveRelatedCard(
incomingCardName = cardName, incomingCardName = cardName,
@ -210,6 +224,11 @@ class VitalWearCharacterImporter(
private fun resolveDefaultAbilityRarity(): NfcCharacter.AbilityRarity { private fun resolveDefaultAbilityRarity(): NfcCharacter.AbilityRarity {
return enumValues<NfcCharacter.AbilityRarity>().first() return enumValues<NfcCharacter.AbilityRarity>().first()
} }
private fun markSeen(cardName: String, slotId: Int, cardId: Long, timestamp: Long) {
database.dexDao().insertCharacter(slotId, cardId, timestamp)
transferSeenDao.markSeen(cardName, slotId, timestamp)
}
} }
internal fun selectImportedCard( internal fun selectImportedCard(

View File

@ -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
}