Compare commits

...
30 Commits
Author SHA1 Message Date
nacabaro e5ccff16d0 Pulidora industrial
- Removed all the warnings during building
- Upgraded all the libraries
- Upgraded grandle and the SDK version
- Fix japanese strings
- Added vibration permission
2026-06-29 16:15:12 +00:00
nacabaroandGitHub 4b866aad8a Merge pull request #57 from b13st/fix/special-missions-nan-crash
Fix special missions crash and NaN% stat display
2026-06-24 23:05:51 +02:00
nacabaroandGitHub 33ad2728b8 Merge pull request #56 from b13st/fix/hce-character-cloning
Fix/hce character cloning
2026-06-24 23:04:53 +02:00
b13st 05f04fdc49 Fix LaunchedEffect key so characterToNfc runs when active character loads
When launching the scan screen from the home button, characterId is
loaded asynchronously from the active character. The previous key
(storageRepository) never re-triggered the effect, so characterToNfc
(which sets pendingExportCharacterId) was skipped if characterId was
null on first composition, making the HCE delete unreachable.
2026-06-24 15:40:19 +02:00
b13st 07315fe11b Delete character from app after successful HCE transfer to VitalWear watch
When sending a character to the VitalWear watch via HCE, the transfer
is atomic (single NFC interaction). The character was being left in the
app after transfer, allowing it to be cloned indefinitely.

Delete the character from the database immediately after sendCharacterToWatch
succeeds, matching the behavior of physical bracelet transfers which delete
the character once both write phases complete.
2026-06-24 15:29:58 +02:00
b13st 2e72b543f6 Fix special missions crash and NaN% stat display
- Fix crash when tapping a completed special mission: onClickCollect
  was ignoring the missionId passed by SpecialMissionsEntry and using
  selectedSpecialMissionId (-1 by default), causing Room to query id=-1
  on a non-nullable Flow which throws when no row exists.
- Fix NaN% in current phase win rate: the guard was checking
  totalBattlesLost==0 but dividing by currentPhaseBattles, producing
  0/0=NaN when total>0 but current phase has no battles yet.
- Guard against empty items list in clearSpecialMission to avoid
  NoSuchElementException from random() on an empty collection.
2026-06-24 15:24:54 +02:00
nacabaroandGitHub 81bfec5ac5 Merge pull request #55 from b13st/fix/vitalwear-hce-transfer
Fix VitalWear HCE transfer in both directions
2026-06-23 20:30:46 +00:00
nacabaroandGitHub 8ec98b9d39 Merge pull request #54 from shiki8826/main
Thank you very much
2026-06-23 20:30:04 +00:00
b13st e549fb4469 Fix VitalWear HCE transfer in both directions
- Detect ISO-DEP tags (VitalWear watch) vs NFC-A (physical bracelets)
- Watch->Phone: use VitalWearHceReaderClient + VitalWearCharacterImporter
- Phone->Watch: use VitalWearHceReaderClient + VitalWearCharacterExporter
- Add AppDatabase to ScanScreenControllerImpl for HCE import/export
- Extract buildCharacterProto() from VitalWearCharacterExporter
- Add JitPack repository to settings.gradle.kts
2026-06-23 13:44:26 +02:00
shiki8826 8d62437aae 日本語翻訳の修正及び追加項目の翻訳 2026-06-19 20:46:23 +09:00
nacabaro 7a8815694d Remove test file, i'm not figuring this out tonight 2026-06-18 22:44:14 +00:00
nacabaro 464f2e48a7 Add basic migration to support the vitalwear thing 2026-06-18 22:37:51 +00:00
nacabaro f641e8aaa0 Compile fix before merging 2026-06-18 22:17:52 +00:00
nacabaroandGitHub c67dc4bd02 Merge pull request #52 from shiki8826/main
Japanese Translation
2026-06-13 00:51:40 +02:00
nacabaroandGitHub 8aa31e819b Merge pull request #53 from 457R0/main
This should be the correct one without problem-report
2026-06-13 00:50:49 +02:00
shiki8826 bd8883e5d1 日本語翻訳追加 2026-06-09 23:11:30 +09:00
shiki8826 b094d6ccd3 日本語翻訳追加 2026-06-09 22:57:47 +09:00
Taveon Nelson 6c257b2080 HCE Savepoint
This is HCE before companion merge
2026-05-26 03:36:45 -05:00
Taveon Nelson 04ea584c4f Delete problems-report.html
removing useless files
2026-05-26 03:20:04 -05:00
Taveon Nelson 1a83527590 speeding up HCE 2026-05-18 17:28:24 -05:00
Taveon Nelson 431be5faea Battlescreen login loop fix 2026-05-18 05:46:40 -05:00
Taveon Nelson 9aa62f0d81 companion reup 2026-05-18 05:17:08 -05:00
Taveon Nelson 8fd4f6c414 VBH is the companion app now
Got tired of having to reinstall all three apps so i ported the companion apps features to vitalwear, now you only need VBH and Vitalwear.

I've also added emblems to items in icons so user isnt confused.
2026-05-18 04:57:57 -05:00
457R0 f829efae68 Overwrite with local version 2026-05-03 18:07:07 -05:00
Taveon Nelson 895b704c8d Make VBH-VW transfer DB owner via provider and shared transfer DAO 2026-04-21 18:57:38 -05:00
Taveon Nelson d40428ccfd Reduce duplicate HCE scans and suppress NFC redispatch 2026-04-21 15:00:14 -05:00
Taveon Nelson cd9a6d5d80 Add VitalWear HCE reader client 2026-04-21 14:41:06 -05:00
Taveon Nelson e0a086d9aa This patch enables transfer between vitalwear and VBH 2026-04-21 14:36:27 -05:00
Taveon Nelson e787ea4a19 Initial commit of VBHelper 2026-04-18 17:19:34 -05:00
Taveon Nelson 5273bae7b6 Update: changes and new files from local development 2026-04-18 07:24:06 -05:00
99 changed files with 6613 additions and 272 deletions
+14 -21
View File
@@ -2,22 +2,21 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins { plugins {
alias(libs.plugins.android.application) alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose) alias(libs.plugins.kotlin.compose)
id("com.google.devtools.ksp") version "2.3.0" alias(libs.plugins.vksp)
id("com.google.protobuf") alias(libs.plugins.vprotobuf)
} }
android { android {
namespace = "com.github.nacabaro.vbhelper" namespace = "com.github.nacabaro.vbhelper"
compileSdk = 36 compileSdk = 37
defaultConfig { defaultConfig {
applicationId = "com.github.nacabaro.vbhelper" applicationId = "com.github.nacabaro.vbhelper"
minSdk = 28 minSdk = 28
targetSdk = 36 targetSdk = 37
versionCode = 1 versionCode = 1
versionName = "Alpha 0.6.3" versionName = "Alpha 0.6.4"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
} }
@@ -56,9 +55,6 @@ protobuf {
artifact = "com.google.protobuf:protoc:4.27.0" artifact = "com.google.protobuf:protoc:4.27.0"
} }
// Generates the java Protobuf-lite code for the Protobufs in this project. See
// https://github.com/google/protobuf-gradle-plugin#customizing-protobuf-compilation
// for more information.
generateProtoTasks { generateProtoTasks {
all().forEach { task -> all().forEach { task ->
task.builtins { task.builtins {
@@ -90,6 +86,11 @@ dependencies {
implementation(libs.androidx.ui.tooling.preview) implementation(libs.androidx.ui.tooling.preview)
implementation(libs.androidx.material3) implementation(libs.androidx.material3)
implementation(libs.androidx.compose.material.icons.extended) implementation(libs.androidx.compose.material.icons.extended)
implementation(libs.play.services.wearable)
implementation(libs.kotlinx.coroutines.play.services)
implementation(libs.timber)
implementation(libs.tinylog.api)
implementation(libs.tinylog.impl)
debugImplementation(libs.androidx.ui.tooling) debugImplementation(libs.androidx.ui.tooling)
debugImplementation(libs.androidx.ui.test.manifest) debugImplementation(libs.androidx.ui.test.manifest)
@@ -104,18 +105,10 @@ dependencies {
androidTestImplementation(platform(libs.androidx.compose.bom)) androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.ui.test.junit4) androidTestImplementation(libs.androidx.ui.test.junit4)
debugImplementation(libs.androidx.ui.tooling) implementation(libs.retrofit)
debugImplementation(libs.androidx.ui.test.manifest) implementation(libs.retrofit.converter.gson)
implementation("androidx.navigation:navigation-compose:2.7.0") implementation(libs.gson)
implementation("com.google.android.material:material:1.2.0")
implementation(libs.protobuf.javalite)
implementation("androidx.compose.material:material")
implementation("androidx.datastore:datastore-preferences:1.1.7")
implementation("com.squareup.retrofit2:retrofit:2.9.0")
implementation("com.squareup.retrofit2:converter-gson:2.9.0")
implementation("com.google.code.gson:gson:2.10.1")
// HTTP request logging // HTTP request logging
implementation("com.squareup.okhttp3:logging-interceptor:4.11.0") implementation(libs.okhttp.logging.interceptor)
} }
File diff suppressed because it is too large Load Diff
+19 -3
View File
@@ -7,6 +7,7 @@
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" /> <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" /> <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.VIBRATE" />
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" <uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE"
tools:ignore="ScopedStorage" /> tools:ignore="ScopedStorage" />
@@ -22,10 +23,18 @@
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"
android:label="@string/app_name"
android:theme="@style/Theme.VBHelper"> android:theme="@style/Theme.VBHelper">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
@@ -42,8 +51,15 @@
<action android:name="android.intent.action.VIEW" /> <action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" /> <category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" /> <category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="http" android:host="localhost" android:port="8080" android:pathPrefix="/authenticate" /> <data android:scheme="http" />
<data android:scheme="http" android:host="127.0.0.1" android:port="8080" android:pathPrefix="/authenticate" /> <data android:host="localhost" android:port="8080" />
<data android:host="127.0.0.1" android:port="8080" />
<data android:pathPrefix="/authenticate" />
</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> </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) {
@@ -40,7 +49,8 @@ class MainActivity : ComponentActivity() {
application.container.dataStoreSecretsRepository.secretsFlow, application.container.dataStoreSecretsRepository.secretsFlow,
this, this,
this::registerActivityLifecycleListener, this::registerActivityLifecycleListener,
this::unregisterActivityLifecycleListener this::unregisterActivityLifecycleListener,
application.container.db,
) )
val settingsScreenController = SettingsScreenControllerImpl(this) val settingsScreenController = SettingsScreenControllerImpl(this)
val itemsScreenController = ItemsScreenControllerImpl(this) val itemsScreenController = ItemsScreenControllerImpl(this)
@@ -54,6 +64,8 @@ class MainActivity : ComponentActivity() {
enableEdgeToEdge() enableEdgeToEdge()
initialRoute = getInitialRouteFromIntent(intent)
setContent { setContent {
VBHelperTheme { VBHelperTheme {
MainApplication( MainApplication(
@@ -64,12 +76,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 +102,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 +176,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 +189,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)
}
}
@@ -2,11 +2,13 @@ package com.github.nacabaro.vbhelper.daos
import androidx.room.Dao import androidx.room.Dao
import androidx.room.Query import androidx.room.Query
import androidx.room.RewriteQueriesToDropUnusedColumns
import com.github.nacabaro.vbhelper.dtos.CharacterDtos import com.github.nacabaro.vbhelper.dtos.CharacterDtos
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@Dao @Dao
@RewriteQueriesToDropUnusedColumns
interface AdventureDao { interface AdventureDao {
@Query(""" @Query("""
INSERT INTO Adventure (characterId, originalDuration, finishesAdventure) INSERT INTO Adventure (characterId, originalDuration, finishesAdventure)
@@ -13,12 +13,18 @@ interface CardDao {
@Insert(onConflict = OnConflictStrategy.IGNORE) @Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun insertNewCard(card: Card): Long suspend fun insertNewCard(card: Card): Long
@Query("SELECT * FROM Card")
fun getAllCards(): List<Card>
@Query("SELECT * FROM Card WHERE cardId = :id") @Query("SELECT * FROM Card WHERE cardId = :id")
fun getCardByCardId(id: Int): List<Card> fun getCardByCardId(id: Int): List<Card>
@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 +36,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)
@@ -2,11 +2,13 @@ package com.github.nacabaro.vbhelper.daos
import androidx.room.Dao import androidx.room.Dao
import androidx.room.Query import androidx.room.Query
import androidx.room.RewriteQueriesToDropUnusedColumns
import com.github.cfogrady.vbnfc.data.NfcCharacter import com.github.cfogrady.vbnfc.data.NfcCharacter
import com.github.nacabaro.vbhelper.dtos.CharacterDtos import com.github.nacabaro.vbhelper.dtos.CharacterDtos
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@Dao @Dao
@RewriteQueriesToDropUnusedColumns
interface CardFusionsDao { interface CardFusionsDao {
@Query(""" @Query("""
INSERT INTO INSERT INTO
@@ -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
@@ -5,6 +5,7 @@ import androidx.room.Insert
import androidx.room.OnConflictStrategy import androidx.room.OnConflictStrategy
import androidx.room.Query import androidx.room.Query
import androidx.room.Upsert import androidx.room.Upsert
import androidx.room.RewriteQueriesToDropUnusedColumns
import com.github.nacabaro.vbhelper.domain.card.CardCharacter import com.github.nacabaro.vbhelper.domain.card.CardCharacter
import com.github.nacabaro.vbhelper.domain.device_data.UserCharacter import com.github.nacabaro.vbhelper.domain.device_data.UserCharacter
import com.github.nacabaro.vbhelper.domain.device_data.BECharacterData import com.github.nacabaro.vbhelper.domain.device_data.BECharacterData
@@ -16,6 +17,7 @@ import com.github.nacabaro.vbhelper.dtos.CharacterDtos
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@Dao @Dao
@RewriteQueriesToDropUnusedColumns
interface UserCharacterDao { interface UserCharacterDao {
@Insert @Insert
fun insertCharacterData(characterData: UserCharacter): Long fun insertCharacterData(characterData: UserCharacter): Long
@@ -55,6 +57,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
@@ -115,6 +133,9 @@ interface UserCharacterDao {
@Query("SELECT * FROM VBCharacterData WHERE id = :id") @Query("SELECT * FROM VBCharacterData WHERE id = :id")
fun getVbData(id: Long): Flow<VBCharacterData> fun getVbData(id: Long): Flow<VBCharacterData>
@Query("SELECT * FROM VBCharacterData WHERE id = :id")
suspend fun getVbDataOrNull(id: Long): VBCharacterData?
@Query("SELECT * FROM SpecialMissions WHERE characterId = :id") @Query("SELECT * FROM SpecialMissions WHERE characterId = :id")
fun getSpecialMissions(id: Long): Flow<List<SpecialMissions>> fun getSpecialMissions(id: Long): Flow<List<SpecialMissions>>
@@ -0,0 +1,17 @@
package com.github.nacabaro.vbhelper.daos
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import com.github.nacabaro.vbhelper.domain.device_data.VitalWearCharacterSettings
@Dao
interface VitalWearSettingsDao {
@Query("SELECT * FROM VitalWearCharacterSettings WHERE characterId = :characterId LIMIT 1")
suspend fun getByCharacterId(characterId: Long): VitalWearCharacterSettings?
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsert(settings: VitalWearCharacterSettings)
}
@@ -2,6 +2,8 @@ package com.github.nacabaro.vbhelper.database
import androidx.room.Database import androidx.room.Database
import androidx.room.RoomDatabase import androidx.room.RoomDatabase
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
import com.github.nacabaro.vbhelper.daos.AdventureDao import com.github.nacabaro.vbhelper.daos.AdventureDao
import com.github.nacabaro.vbhelper.daos.CardAdventureDao import com.github.nacabaro.vbhelper.daos.CardAdventureDao
import com.github.nacabaro.vbhelper.daos.CharacterDao import com.github.nacabaro.vbhelper.daos.CharacterDao
@@ -13,6 +15,10 @@ import com.github.nacabaro.vbhelper.daos.ItemDao
import com.github.nacabaro.vbhelper.daos.SpecialMissionDao import com.github.nacabaro.vbhelper.daos.SpecialMissionDao
import com.github.nacabaro.vbhelper.daos.SpriteDao import com.github.nacabaro.vbhelper.daos.SpriteDao
import com.github.nacabaro.vbhelper.daos.UserCharacterDao import com.github.nacabaro.vbhelper.daos.UserCharacterDao
import com.github.nacabaro.vbhelper.daos.VitalWearSettingsDao
import com.github.nacabaro.vbhelper.daos.CharacterTransferPolicyDao
import com.github.nacabaro.vbhelper.companion.validation.ValidatedCardDao
import com.github.nacabaro.vbhelper.companion.validation.ValidatedCardEntity
import com.github.nacabaro.vbhelper.domain.card.Background import com.github.nacabaro.vbhelper.domain.card.Background
import com.github.nacabaro.vbhelper.domain.card.CardCharacter import com.github.nacabaro.vbhelper.domain.card.CardCharacter
import com.github.nacabaro.vbhelper.domain.card.Card import com.github.nacabaro.vbhelper.domain.card.Card
@@ -29,10 +35,13 @@ import com.github.nacabaro.vbhelper.domain.device_data.TransformationHistory
import com.github.nacabaro.vbhelper.domain.device_data.UserCharacter import com.github.nacabaro.vbhelper.domain.device_data.UserCharacter
import com.github.nacabaro.vbhelper.domain.device_data.VBCharacterData import com.github.nacabaro.vbhelper.domain.device_data.VBCharacterData
import com.github.nacabaro.vbhelper.domain.device_data.VitalsHistory import com.github.nacabaro.vbhelper.domain.device_data.VitalsHistory
import com.github.nacabaro.vbhelper.domain.device_data.VitalWearCharacterSettings
import com.github.nacabaro.vbhelper.domain.device_data.CharacterTransferPolicy
import com.github.nacabaro.vbhelper.domain.items.Items import com.github.nacabaro.vbhelper.domain.items.Items
@Database( @Database(
version = 1, version = 3,
exportSchema = false,
entities = [ entities = [
Card::class, Card::class,
CardProgress::class, CardProgress::class,
@@ -50,7 +59,10 @@ import com.github.nacabaro.vbhelper.domain.items.Items
Items::class, Items::class,
Adventure::class, Adventure::class,
Background::class, Background::class,
PossibleTransformations::class PossibleTransformations::class,
ValidatedCardEntity::class,
VitalWearCharacterSettings::class,
CharacterTransferPolicy::class
] ]
) )
abstract class AppDatabase : RoomDatabase() { abstract class AppDatabase : RoomDatabase() {
@@ -65,4 +77,35 @@ abstract class AppDatabase : RoomDatabase() {
abstract fun specialMissionDao(): SpecialMissionDao abstract fun specialMissionDao(): SpecialMissionDao
abstract fun cardAdventureDao(): CardAdventureDao abstract fun cardAdventureDao(): CardAdventureDao
abstract fun cardFusionsDao(): CardFusionsDao abstract fun cardFusionsDao(): CardFusionsDao
abstract fun validatedCardDao(): ValidatedCardDao
abstract fun vitalWearSettingsDao(): VitalWearSettingsDao
abstract fun characterTransferPolicyDao(): CharacterTransferPolicyDao
companion object {
val MIGRATION_1_2 = object : Migration(1, 2) {
override fun migrate(db: SupportSQLiteDatabase) {
// Create new tables
db.execSQL("CREATE TABLE IF NOT EXISTS `Items` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `description` TEXT NOT NULL, `itemIcon` INTEGER NOT NULL, `itemLength` INTEGER NOT NULL, `price` INTEGER NOT NULL, `quantity` INTEGER NOT NULL, `itemType` TEXT NOT NULL, PRIMARY KEY(`id`))")
db.execSQL("CREATE TABLE IF NOT EXISTS `SpecialMissions` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `characterId` INTEGER NOT NULL, `goal` INTEGER NOT NULL, `watchId` INTEGER NOT NULL, `progress` INTEGER NOT NULL, `status` TEXT NOT NULL, `timeElapsedInMinutes` INTEGER NOT NULL, `timeLimitInMinutes` INTEGER NOT NULL, `missionType` TEXT NOT NULL, FOREIGN KEY(`characterId`) REFERENCES `UserCharacter`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )")
db.execSQL("CREATE TABLE IF NOT EXISTS `VBCharacterData` (`id` INTEGER NOT NULL, `generation` INTEGER NOT NULL, `totalTrophies` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`id`) REFERENCES `UserCharacter`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )")
db.execSQL("CREATE TABLE IF NOT EXISTS `TransformationHistory` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `monId` INTEGER NOT NULL, `stageId` INTEGER NOT NULL, `transformationDate` INTEGER NOT NULL, FOREIGN KEY(`monId`) REFERENCES `UserCharacter`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`stageId`) REFERENCES `CardCharacter`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )")
db.execSQL("CREATE TABLE IF NOT EXISTS `VitalsHistory` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `charId` INTEGER NOT NULL, `year` INTEGER NOT NULL, `month` INTEGER NOT NULL, `day` INTEGER NOT NULL, `vitalPoints` INTEGER NOT NULL, FOREIGN KEY(`charId`) REFERENCES `UserCharacter`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )")
db.execSQL("CREATE TABLE IF NOT EXISTS `Dex` (`id` INTEGER NOT NULL, `discoveredOn` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`id`) REFERENCES `CardCharacter`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )")
db.execSQL("CREATE TABLE IF NOT EXISTS `Adventure` (`characterId` INTEGER NOT NULL, `originalDuration` INTEGER NOT NULL, `finishesAdventure` INTEGER NOT NULL, PRIMARY KEY(`characterId`), FOREIGN KEY(`characterId`) REFERENCES `UserCharacter`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )")
db.execSQL("CREATE TABLE IF NOT EXISTS `Background` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `cardId` INTEGER NOT NULL, `background` BLOB NOT NULL, `backgroundWidth` INTEGER NOT NULL, `backgroundHeight` INTEGER NOT NULL, FOREIGN KEY(`cardId`) REFERENCES `Card`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )")
db.execSQL("CREATE TABLE IF NOT EXISTS `PossibleTransformations` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `charaId` INTEGER NOT NULL, `requiredVitals` INTEGER NOT NULL, `requiredTrophies` INTEGER NOT NULL, `requiredBattles` INTEGER NOT NULL, `requiredWinRate` INTEGER NOT NULL, `changeTimerHours` INTEGER NOT NULL, `requiredAdventureLevelCompleted` INTEGER NOT NULL, `toCharaId` INTEGER, FOREIGN KEY(`charaId`) REFERENCES `CardCharacter`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`toCharaId`) REFERENCES `CardCharacter`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )")
db.execSQL("CREATE TABLE IF NOT EXISTS `validated` (`cardId` INTEGER NOT NULL, PRIMARY KEY(`cardId`))")
db.execSQL("CREATE TABLE IF NOT EXISTS `VitalWearCharacterSettings` (`characterId` INTEGER NOT NULL, `trainingInBackground` INTEGER NOT NULL, `allowedBattles` INTEGER NOT NULL, `accumulatedDailyInjuries` INTEGER NOT NULL, PRIMARY KEY(`characterId`))")
db.execSQL("CREATE TABLE IF NOT EXISTS `CharacterTransferPolicy` (`characterId` INTEGER NOT NULL, `nativeDeviceType` TEXT NOT NULL, `preferredHceExportFormat` TEXT NOT NULL, `preferredNfcaExportFormat` TEXT NOT NULL, `lastObservedImportFormat` TEXT, `lastTransferTransport` TEXT, `lastTransferTarget` TEXT, `preserveVbRoundTrip` INTEGER NOT NULL, `preserveBeRoundTrip` INTEGER NOT NULL, PRIMARY KEY(`characterId`), FOREIGN KEY(`characterId`) REFERENCES `UserCharacter`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )")
// Add columns to existing tables
try {
db.execSQL("ALTER TABLE `UserCharacter` ADD COLUMN `characterType` TEXT NOT NULL DEFAULT 'BEDevice'")
} catch (e: Exception) {}
try {
db.execSQL("ALTER TABLE `Card` ADD COLUMN `isBEm` INTEGER NOT NULL DEFAULT 0")
} catch (e: Exception) {}
}
}
}
} }
@@ -3,9 +3,13 @@ package com.github.nacabaro.vbhelper.di
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
import com.github.nacabaro.vbhelper.companion.validation.ValidatedCardManager
import com.github.nacabaro.vbhelper.companion.logs.CompanionLogService
interface AppContainer { interface AppContainer {
val db: AppDatabase val db: AppDatabase
val dataStoreSecretsRepository: DataStoreSecretsRepository val dataStoreSecretsRepository: DataStoreSecretsRepository
val currencyRepository: CurrencyRepository val currencyRepository: CurrencyRepository
val validatedCardManager: ValidatedCardManager
val companionLogService: CompanionLogService
} }
@@ -8,6 +8,8 @@ import com.github.nacabaro.vbhelper.database.AppDatabase
import com.github.nacabaro.vbhelper.di.AppContainer import com.github.nacabaro.vbhelper.di.AppContainer
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.companion.validation.ValidatedCardManager
import com.github.nacabaro.vbhelper.companion.logs.CompanionLogService
import com.github.nacabaro.vbhelper.source.SecretsSerializer import com.github.nacabaro.vbhelper.source.SecretsSerializer
import com.github.nacabaro.vbhelper.source.proto.Secrets import com.github.nacabaro.vbhelper.source.proto.Secrets
@@ -30,6 +32,7 @@ class DefaultAppContainer(private val context: Context) : AppContainer {
klass = AppDatabase::class.java, klass = AppDatabase::class.java,
"internalDb" "internalDb"
) )
.addMigrations(AppDatabase.MIGRATION_1_2)
.createFromAsset("items.db") .createFromAsset("items.db")
.build() .build()
} }
@@ -37,5 +40,11 @@ class DefaultAppContainer(private val context: Context) : AppContainer {
override val dataStoreSecretsRepository = DataStoreSecretsRepository(context.secretsStore) override val dataStoreSecretsRepository = DataStoreSecretsRepository(context.secretsStore)
override val currencyRepository = CurrencyRepository(context.currencyStore) override val currencyRepository = CurrencyRepository(context.currencyStore)
override val validatedCardManager by lazy {
ValidatedCardManager(db.validatedCardDao())
}
override val companionLogService = CompanionLogService()
} }
@@ -2,10 +2,18 @@ package com.github.nacabaro.vbhelper.di
import DefaultAppContainer import DefaultAppContainer
import android.app.Application import android.app.Application
import com.github.nacabaro.vbhelper.companion.validation.ValidatedCardManager
import com.github.nacabaro.vbhelper.companion.logs.CompanionLogService
class VBHelper : Application() { class VBHelper : Application() {
lateinit var container: DefaultAppContainer lateinit var container: DefaultAppContainer
val validatedCardManager: ValidatedCardManager
get() = container.validatedCardManager
val companionLogService: CompanionLogService
get() = container.companionLogService
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
container = DefaultAppContainer(applicationContext) container = DefaultAppContainer(applicationContext)
@@ -1,6 +1,7 @@
package com.github.nacabaro.vbhelper.domain.card package com.github.nacabaro.vbhelper.domain.card
import androidx.room.Entity import androidx.room.Entity
import androidx.room.Index
import androidx.room.ForeignKey import androidx.room.ForeignKey
import androidx.room.PrimaryKey import androidx.room.PrimaryKey
@@ -12,7 +13,8 @@ import androidx.room.PrimaryKey
childColumns = ["cardId"], childColumns = ["cardId"],
onDelete = ForeignKey.CASCADE onDelete = ForeignKey.CASCADE
) )
] ],
indices = [Index(value = ["cardId"])]
) )
data class Background ( data class Background (
@PrimaryKey(autoGenerate = true) val id: Long, @PrimaryKey(autoGenerate = true) val id: Long,
@@ -20,4 +22,28 @@ data class Background (
val background: ByteArray, val background: ByteArray,
val backgroundWidth: Int, val backgroundWidth: Int,
val backgroundHeight: Int val backgroundHeight: Int
) ) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as Background
if (id != other.id) return false
if (cardId != other.cardId) return false
if (!background.contentEquals(other.background)) return false
if (backgroundWidth != other.backgroundWidth) return false
if (backgroundHeight != other.backgroundHeight) return false
return true
}
override fun hashCode(): Int {
var result = id.hashCode()
result = 31 * result + cardId.hashCode()
result = 31 * result + background.contentHashCode()
result = 31 * result + backgroundWidth
result = 31 * result + backgroundHeight
return result
}
}
@@ -14,4 +14,34 @@ data class Card(
val name: String, val name: String,
val stageCount: Int, val stageCount: Int,
val isBEm: Boolean val isBEm: Boolean
) ) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as Card
if (id != other.id) return false
if (cardId != other.cardId) return false
if (!logo.contentEquals(other.logo)) return false
if (logoWidth != other.logoWidth) return false
if (logoHeight != other.logoHeight) return false
if (name != other.name) return false
if (stageCount != other.stageCount) return false
if (isBEm != other.isBEm) return false
return true
}
override fun hashCode(): Int {
var result = id.hashCode()
result = 31 * result + cardId
result = 31 * result + logo.contentHashCode()
result = 31 * result + logoWidth
result = 31 * result + logoHeight
result = 31 * result + name.hashCode()
result = 31 * result + stageCount
result = 31 * result + isBEm.hashCode()
return result
}
}
@@ -1,6 +1,7 @@
package com.github.nacabaro.vbhelper.domain.card package com.github.nacabaro.vbhelper.domain.card
import androidx.room.Entity import androidx.room.Entity
import androidx.room.Index
import androidx.room.ForeignKey import androidx.room.ForeignKey
import androidx.room.PrimaryKey import androidx.room.PrimaryKey
@@ -18,6 +19,10 @@ import androidx.room.PrimaryKey
childColumns = ["cardId"], childColumns = ["cardId"],
onDelete = ForeignKey.CASCADE onDelete = ForeignKey.CASCADE
) )
],
indices = [
Index(value = ["characterId"]),
Index(value = ["cardId"])
] ]
) )
data class CardAdventure( data class CardAdventure(
@@ -1,6 +1,7 @@
package com.github.nacabaro.vbhelper.domain.card package com.github.nacabaro.vbhelper.domain.card
import androidx.room.Entity import androidx.room.Entity
import androidx.room.Index
import androidx.room.ForeignKey import androidx.room.ForeignKey
import androidx.room.PrimaryKey import androidx.room.PrimaryKey
import com.github.cfogrady.vbnfc.data.NfcCharacter import com.github.cfogrady.vbnfc.data.NfcCharacter
@@ -20,6 +21,10 @@ import com.github.nacabaro.vbhelper.domain.characters.Sprite
childColumns = ["spriteId"], childColumns = ["spriteId"],
onDelete = ForeignKey.CASCADE onDelete = ForeignKey.CASCADE
) )
],
indices = [
Index(value = ["cardId"]),
Index(value = ["spriteId"])
] ]
) )
@@ -41,4 +46,42 @@ data class CardCharacter (
val nameSprite: ByteArray, val nameSprite: ByteArray,
val nameWidth: Int, val nameWidth: Int,
val nameHeight: Int val nameHeight: Int
) ) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as CardCharacter
if (id != other.id) return false
if (cardId != other.cardId) return false
if (spriteId != other.spriteId) return false
if (charaIndex != other.charaIndex) return false
if (stage != other.stage) return false
if (attribute != other.attribute) return false
if (baseHp != other.baseHp) return false
if (baseBp != other.baseBp) return false
if (baseAp != other.baseAp) return false
if (!nameSprite.contentEquals(other.nameSprite)) return false
if (nameWidth != other.nameWidth) return false
if (nameHeight != other.nameHeight) return false
return true
}
override fun hashCode(): Int {
var result = id.hashCode()
result = 31 * result + cardId.hashCode()
result = 31 * result + spriteId.hashCode()
result = 31 * result + charaIndex
result = 31 * result + stage
result = 31 * result + attribute.hashCode()
result = 31 * result + baseHp
result = 31 * result + baseBp
result = 31 * result + baseAp
result = 31 * result + nameSprite.contentHashCode()
result = 31 * result + nameWidth
result = 31 * result + nameHeight
return result
}
}
@@ -1,6 +1,7 @@
package com.github.nacabaro.vbhelper.domain.card package com.github.nacabaro.vbhelper.domain.card
import androidx.room.Entity import androidx.room.Entity
import androidx.room.Index
import androidx.room.ForeignKey import androidx.room.ForeignKey
import androidx.room.PrimaryKey import androidx.room.PrimaryKey
import com.github.cfogrady.vbnfc.data.NfcCharacter import com.github.cfogrady.vbnfc.data.NfcCharacter
@@ -19,6 +20,10 @@ import com.github.cfogrady.vbnfc.data.NfcCharacter
childColumns = ["toCharaId"], childColumns = ["toCharaId"],
onDelete = ForeignKey.CASCADE onDelete = ForeignKey.CASCADE
) )
],
indices = [
Index(value = ["fromCharaId"]),
Index(value = ["toCharaId"])
] ]
) )
data class CardFusions( data class CardFusions(
@@ -1,6 +1,7 @@
package com.github.nacabaro.vbhelper.domain.card package com.github.nacabaro.vbhelper.domain.card
import androidx.room.Entity import androidx.room.Entity
import androidx.room.Index
import androidx.room.ForeignKey import androidx.room.ForeignKey
import androidx.room.PrimaryKey import androidx.room.PrimaryKey
@@ -18,6 +19,10 @@ import androidx.room.PrimaryKey
childColumns = ["toCharaId"], childColumns = ["toCharaId"],
onDelete = ForeignKey.CASCADE onDelete = ForeignKey.CASCADE
) )
],
indices = [
Index(value = ["charaId"]),
Index(value = ["toCharaId"])
] ]
) )
data class PossibleTransformations ( data class PossibleTransformations (
@@ -20,4 +20,48 @@ data class Sprite(
val spriteDodge: ByteArray, val spriteDodge: ByteArray,
val width: Int, val width: Int,
val height: Int val height: Int
) ) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as Sprite
if (id != other.id) return false
if (!spriteIdle1.contentEquals(other.spriteIdle1)) return false
if (!spriteIdle2.contentEquals(other.spriteIdle2)) return false
if (!spriteWalk1.contentEquals(other.spriteWalk1)) return false
if (!spriteWalk2.contentEquals(other.spriteWalk2)) return false
if (!spriteRun1.contentEquals(other.spriteRun1)) return false
if (!spriteRun2.contentEquals(other.spriteRun2)) return false
if (!spriteTrain1.contentEquals(other.spriteTrain1)) return false
if (!spriteTrain2.contentEquals(other.spriteTrain2)) return false
if (!spriteHappy.contentEquals(other.spriteHappy)) return false
if (!spriteSleep.contentEquals(other.spriteSleep)) return false
if (!spriteAttack.contentEquals(other.spriteAttack)) return false
if (!spriteDodge.contentEquals(other.spriteDodge)) return false
if (width != other.width) return false
if (height != other.height) return false
return true
}
override fun hashCode(): Int {
var result = id
result = 31 * result + spriteIdle1.contentHashCode()
result = 31 * result + spriteIdle2.contentHashCode()
result = 31 * result + spriteWalk1.contentHashCode()
result = 31 * result + spriteWalk2.contentHashCode()
result = 31 * result + spriteRun1.contentHashCode()
result = 31 * result + spriteRun2.contentHashCode()
result = 31 * result + spriteTrain1.contentHashCode()
result = 31 * result + spriteTrain2.contentHashCode()
result = 31 * result + spriteHappy.contentHashCode()
result = 31 * result + spriteSleep.contentHashCode()
result = 31 * result + spriteAttack.contentHashCode()
result = 31 * result + spriteDodge.contentHashCode()
result = 31 * result + width
result = 31 * result + height
return result
}
}
@@ -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,
)
@@ -1,6 +1,7 @@
package com.github.nacabaro.vbhelper.domain.device_data package com.github.nacabaro.vbhelper.domain.device_data
import androidx.room.Entity import androidx.room.Entity
import androidx.room.Index
import androidx.room.ForeignKey import androidx.room.ForeignKey
import androidx.room.PrimaryKey import androidx.room.PrimaryKey
import com.github.cfogrady.vbnfc.vb.SpecialMission import com.github.cfogrady.vbnfc.vb.SpecialMission
@@ -13,7 +14,8 @@ import com.github.cfogrady.vbnfc.vb.SpecialMission
childColumns = ["characterId"], childColumns = ["characterId"],
onDelete = ForeignKey.CASCADE onDelete = ForeignKey.CASCADE
) )
] ],
indices = [Index(value = ["characterId"])]
) )
data class SpecialMissions ( data class SpecialMissions (
@PrimaryKey(autoGenerate = true) var id: Long = 0, @PrimaryKey(autoGenerate = true) var id: Long = 0,
@@ -1,6 +1,7 @@
package com.github.nacabaro.vbhelper.domain.device_data package com.github.nacabaro.vbhelper.domain.device_data
import androidx.room.Entity import androidx.room.Entity
import androidx.room.Index
import androidx.room.ForeignKey import androidx.room.ForeignKey
import androidx.room.PrimaryKey import androidx.room.PrimaryKey
import com.github.nacabaro.vbhelper.domain.card.CardCharacter import com.github.nacabaro.vbhelper.domain.card.CardCharacter
@@ -19,6 +20,10 @@ import com.github.nacabaro.vbhelper.domain.card.CardCharacter
childColumns = ["stageId"], childColumns = ["stageId"],
onDelete = ForeignKey.CASCADE onDelete = ForeignKey.CASCADE
) )
],
indices = [
Index(value = ["monId"]),
Index(value = ["stageId"])
] ]
) )
data class TransformationHistory ( data class TransformationHistory (
@@ -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
}
@@ -1,6 +1,7 @@
package com.github.nacabaro.vbhelper.domain.device_data package com.github.nacabaro.vbhelper.domain.device_data
import androidx.room.Entity import androidx.room.Entity
import androidx.room.Index
import androidx.room.ForeignKey import androidx.room.ForeignKey
import androidx.room.PrimaryKey import androidx.room.PrimaryKey
import com.github.cfogrady.vbnfc.data.NfcCharacter import com.github.cfogrady.vbnfc.data.NfcCharacter
@@ -15,7 +16,8 @@ import com.github.nacabaro.vbhelper.domain.card.CardCharacter
childColumns = ["charId"], childColumns = ["charId"],
onDelete = ForeignKey.CASCADE onDelete = ForeignKey.CASCADE
) )
] ],
indices = [Index(value = ["charId"])]
) )
/** /**
* UserCharacter represents and instance of a character. The charId should map to a Character * UserCharacter represents and instance of a character. The charId should map to a Character
@@ -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,
)
@@ -1,6 +1,7 @@
package com.github.nacabaro.vbhelper.domain.device_data package com.github.nacabaro.vbhelper.domain.device_data
import androidx.room.Entity import androidx.room.Entity
import androidx.room.Index
import androidx.room.ForeignKey import androidx.room.ForeignKey
import androidx.room.PrimaryKey import androidx.room.PrimaryKey
@@ -12,7 +13,8 @@ import androidx.room.PrimaryKey
childColumns = ["charId"], childColumns = ["charId"],
onDelete = ForeignKey.CASCADE onDelete = ForeignKey.CASCADE
) )
] ],
indices = [Index(value = ["charId"])]
) )
data class VitalsHistory ( data class VitalsHistory (
@PrimaryKey(autoGenerate = true) val id: Long = 0, @PrimaryKey(autoGenerate = true) val id: Long = 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
@@ -9,7 +9,35 @@ object CardDtos {
val logoHeight: Int, val logoHeight: Int,
val totalCharacters: Int, val totalCharacters: Int,
val obtainedCharacters: Int, val obtainedCharacters: Int,
) ) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as CardProgress
if (cardId != other.cardId) return false
if (cardName != other.cardName) return false
if (!cardLogo.contentEquals(other.cardLogo)) return false
if (logoWidth != other.logoWidth) return false
if (logoHeight != other.logoHeight) return false
if (totalCharacters != other.totalCharacters) return false
if (obtainedCharacters != other.obtainedCharacters) return false
return true
}
override fun hashCode(): Int {
var result = cardId.hashCode()
result = 31 * result + cardName.hashCode()
result = 31 * result + cardLogo.contentHashCode()
result = 31 * result + logoWidth
result = 31 * result + logoHeight
result = 31 * result + totalCharacters
result = 31 * result + obtainedCharacters
return result
}
}
data class CardAdventureWithSprites ( data class CardAdventureWithSprites (
val characterName: ByteArray, val characterName: ByteArray,
@@ -23,11 +51,67 @@ object CardDtos {
val characterDp: Int, val characterDp: Int,
val characterHp: Int, val characterHp: Int,
val steps: Int, val steps: Int,
) ) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as CardAdventureWithSprites
if (!characterName.contentEquals(other.characterName)) return false
if (characterNameWidth != other.characterNameWidth) return false
if (characterNameHeight != other.characterNameHeight) return false
if (!characterIdleSprite.contentEquals(other.characterIdleSprite)) return false
if (characterIdleSpriteWidth != other.characterIdleSpriteWidth) return false
if (characterIdleSpriteHeight != other.characterIdleSpriteHeight) return false
if (characterAp != other.characterAp) return false
if (characterBp != other.characterBp) return false
if (characterDp != other.characterDp) return false
if (characterHp != other.characterHp) return false
if (steps != other.steps) return false
return true
}
override fun hashCode(): Int {
var result = characterName.contentHashCode()
result = 31 * result + characterNameWidth
result = 31 * result + characterNameHeight
result = 31 * result + characterIdleSprite.contentHashCode()
result = 31 * result + characterIdleSpriteWidth
result = 31 * result + characterIdleSpriteHeight
result = 31 * result + characterAp
result = 31 * result + (characterBp ?: 0)
result = 31 * result + characterDp
result = 31 * result + characterHp
result = 31 * result + steps
return result
}
}
data class CardIcon ( data class CardIcon (
val cardIcon: ByteArray, val cardIcon: ByteArray,
val cardIconWidth: Int, val cardIconWidth: Int,
val cardIconHeight: Int val cardIconHeight: Int
) ) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as CardIcon
if (!cardIcon.contentEquals(other.cardIcon)) return false
if (cardIconWidth != other.cardIconWidth) return false
if (cardIconHeight != other.cardIconHeight) return false
return true
}
override fun hashCode(): Int {
var result = cardIcon.contentHashCode()
result = 31 * result + cardIconWidth
result = 31 * result + cardIconHeight
return result
}
}
} }
@@ -33,7 +33,75 @@ object CharacterDtos {
val isBemCard: Boolean, val isBemCard: Boolean,
val isInAdventure: Boolean, val isInAdventure: Boolean,
val active: Boolean val active: Boolean
) ) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as CharacterWithSprites
if (id != other.id) return false
if (charId != other.charId) return false
if (stage != other.stage) return false
if (attribute != other.attribute) return false
if (ageInDays != other.ageInDays) return false
if (mood != other.mood) return false
if (vitalPoints != other.vitalPoints) return false
if (transformationCountdown != other.transformationCountdown) return false
if (injuryStatus != other.injuryStatus) return false
if (trophies != other.trophies) return false
if (currentPhaseBattlesWon != other.currentPhaseBattlesWon) return false
if (currentPhaseBattlesLost != other.currentPhaseBattlesLost) return false
if (totalBattlesWon != other.totalBattlesWon) return false
if (totalBattlesLost != other.totalBattlesLost) return false
if (activityLevel != other.activityLevel) return false
if (heartRateCurrent != other.heartRateCurrent) return false
if (characterType != other.characterType) return false
if (!spriteIdle.contentEquals(other.spriteIdle)) return false
if (!spriteIdle2.contentEquals(other.spriteIdle2)) return false
if (spriteWidth != other.spriteWidth) return false
if (spriteHeight != other.spriteHeight) return false
if (!nameSprite.contentEquals(other.nameSprite)) return false
if (nameSpriteWidth != other.nameSpriteWidth) return false
if (nameSpriteHeight != other.nameSpriteHeight) return false
if (isBemCard != other.isBemCard) return false
if (isInAdventure != other.isInAdventure) return false
if (active != other.active) return false
return true
}
override fun hashCode(): Int {
var result = id.hashCode()
result = 31 * result + charId.hashCode()
result = 31 * result + stage
result = 31 * result + attribute.hashCode()
result = 31 * result + ageInDays
result = 31 * result + mood
result = 31 * result + vitalPoints
result = 31 * result + transformationCountdown
result = 31 * result + injuryStatus.hashCode()
result = 31 * result + trophies
result = 31 * result + currentPhaseBattlesWon
result = 31 * result + currentPhaseBattlesLost
result = 31 * result + totalBattlesWon
result = 31 * result + totalBattlesLost
result = 31 * result + activityLevel
result = 31 * result + heartRateCurrent
result = 31 * result + characterType.hashCode()
result = 31 * result + spriteIdle.contentHashCode()
result = 31 * result + spriteIdle2.contentHashCode()
result = 31 * result + spriteWidth
result = 31 * result + spriteHeight
result = 31 * result + nameSprite.contentHashCode()
result = 31 * result + nameSpriteWidth
result = 31 * result + nameSpriteHeight
result = 31 * result + isBemCard.hashCode()
result = 31 * result + isInAdventure.hashCode()
result = 31 * result + active.hashCode()
return result
}
}
data class CardCharacterInfo( data class CardCharacterInfo(
val cardId: Long, val cardId: Long,
@@ -50,6 +118,39 @@ object CharacterDtos {
val spriteHeight: Int, val spriteHeight: Int,
val monIndex: Int, val monIndex: Int,
val transformationDate: Long val transformationDate: Long
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as TransformationHistory
if (id != other.id) return false
if (!spriteIdle.contentEquals(other.spriteIdle)) return false
if (spriteWidth != other.spriteWidth) return false
if (spriteHeight != other.spriteHeight) return false
if (monIndex != other.monIndex) return false
if (transformationDate != other.transformationDate) return false
return true
}
override fun hashCode(): Int {
var result = id.hashCode()
result = 31 * result + spriteIdle.contentHashCode()
result = 31 * result + spriteWidth
result = 31 * result + spriteHeight
result = 31 * result + monIndex
result = 31 * result + transformationDate.hashCode()
return result
}
}
data class TransformationHistoryExport(
val stageId: Long,
val monIndex: Int,
val cardName: String,
val transformationDate: Long
) )
data class CardCharaProgress( data class CardCharaProgress(
@@ -66,14 +167,76 @@ object CharacterDtos {
val baseAp: Int, val baseAp: Int,
val stage: Int, val stage: Int,
val attribute: NfcCharacter.Attribute, val attribute: NfcCharacter.Attribute,
) ) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as CardCharaProgress
if (id != other.id) return false
if (!spriteIdle.contentEquals(other.spriteIdle)) return false
if (spriteWidth != other.spriteWidth) return false
if (spriteHeight != other.spriteHeight) return false
if (!nameSprite.contentEquals(other.nameSprite)) return false
if (nameSpriteWidth != other.nameSpriteWidth) return false
if (nameSpriteHeight != other.nameSpriteHeight) return false
if (discoveredOn != other.discoveredOn) return false
if (baseHp != other.baseHp) return false
if (baseBp != other.baseBp) return false
if (baseAp != other.baseAp) return false
if (stage != other.stage) return false
if (attribute != other.attribute) return false
return true
}
override fun hashCode(): Int {
var result = id.hashCode()
result = 31 * result + spriteIdle.contentHashCode()
result = 31 * result + spriteWidth
result = 31 * result + spriteHeight
result = 31 * result + nameSprite.contentHashCode()
result = 31 * result + nameSpriteWidth
result = 31 * result + nameSpriteHeight
result = 31 * result + (discoveredOn?.hashCode() ?: 0)
result = 31 * result + baseHp
result = 31 * result + baseBp
result = 31 * result + baseAp
result = 31 * result + stage
result = 31 * result + attribute.hashCode()
return result
}
}
data class CardProgress( data class CardProgress(
val id: Long, val id: Long,
val spriteIdle: ByteArray, val spriteIdle: ByteArray,
val spriteWidth: Int, val spriteWidth: Int,
val spriteHeight: Int, val spriteHeight: Int,
) ) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as CardProgress
if (id != other.id) return false
if (!spriteIdle.contentEquals(other.spriteIdle)) return false
if (spriteWidth != other.spriteWidth) return false
if (spriteHeight != other.spriteHeight) return false
return true
}
override fun hashCode(): Int {
var result = id.hashCode()
result = 31 * result + spriteIdle.contentHashCode()
result = 31 * result + spriteWidth
result = 31 * result + spriteHeight
return result
}
}
data class AdventureCharacterWithSprites( data class AdventureCharacterWithSprites(
var id: Long = 0, var id: Long = 0,
@@ -99,7 +262,67 @@ object CharacterDtos {
val isBemCard: Boolean, val isBemCard: Boolean,
val finishesAdventure: Long, val finishesAdventure: Long,
val originalTimeInMinutes: Long val originalTimeInMinutes: Long
) ) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as AdventureCharacterWithSprites
if (id != other.id) return false
if (charId != other.charId) return false
if (stage != other.stage) return false
if (attribute != other.attribute) return false
if (ageInDays != other.ageInDays) return false
if (mood != other.mood) return false
if (vitalPoints != other.vitalPoints) return false
if (transformationCountdown != other.transformationCountdown) return false
if (injuryStatus != other.injuryStatus) return false
if (trophies != other.trophies) return false
if (currentPhaseBattlesWon != other.currentPhaseBattlesWon) return false
if (currentPhaseBattlesLost != other.currentPhaseBattlesLost) return false
if (totalBattlesWon != other.totalBattlesWon) return false
if (totalBattlesLost != other.totalBattlesLost) return false
if (activityLevel != other.activityLevel) return false
if (heartRateCurrent != other.heartRateCurrent) return false
if (characterType != other.characterType) return false
if (!spriteIdle.contentEquals(other.spriteIdle)) return false
if (spriteWidth != other.spriteWidth) return false
if (spriteHeight != other.spriteHeight) return false
if (isBemCard != other.isBemCard) return false
if (finishesAdventure != other.finishesAdventure) return false
if (originalTimeInMinutes != other.originalTimeInMinutes) return false
return true
}
override fun hashCode(): Int {
var result = id.hashCode()
result = 31 * result + charId.hashCode()
result = 31 * result + stage
result = 31 * result + attribute.hashCode()
result = 31 * result + ageInDays
result = 31 * result + mood
result = 31 * result + vitalPoints
result = 31 * result + transformationCountdown
result = 31 * result + injuryStatus.hashCode()
result = 31 * result + trophies
result = 31 * result + currentPhaseBattlesWon
result = 31 * result + currentPhaseBattlesLost
result = 31 * result + totalBattlesWon
result = 31 * result + totalBattlesLost
result = 31 * result + activityLevel
result = 31 * result + heartRateCurrent
result = 31 * result + characterType.hashCode()
result = 31 * result + spriteIdle.contentHashCode()
result = 31 * result + spriteWidth
result = 31 * result + spriteHeight
result = 31 * result + isBemCard.hashCode()
result = 31 * result + finishesAdventure.hashCode()
result = 31 * result + originalTimeInMinutes.hashCode()
return result
}
}
data class EvolutionRequirementsWithSpritesAndObtained( data class EvolutionRequirementsWithSpritesAndObtained(
val charaId: Long, val charaId: Long,
@@ -114,7 +337,45 @@ object CharacterDtos {
val requiredWinRate: Int, val requiredWinRate: Int,
val changeTimerHours: Int, val changeTimerHours: Int,
val requiredAdventureLevelCompleted: Int val requiredAdventureLevelCompleted: Int
) ) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as EvolutionRequirementsWithSpritesAndObtained
if (charaId != other.charaId) return false
if (fromCharaId != other.fromCharaId) return false
if (!spriteIdle.contentEquals(other.spriteIdle)) return false
if (spriteWidth != other.spriteWidth) return false
if (spriteHeight != other.spriteHeight) return false
if (discoveredOn != other.discoveredOn) return false
if (requiredTrophies != other.requiredTrophies) return false
if (requiredVitals != other.requiredVitals) return false
if (requiredBattles != other.requiredBattles) return false
if (requiredWinRate != other.requiredWinRate) return false
if (changeTimerHours != other.changeTimerHours) return false
if (requiredAdventureLevelCompleted != other.requiredAdventureLevelCompleted) return false
return true
}
override fun hashCode(): Int {
var result = charaId.hashCode()
result = 31 * result + fromCharaId.hashCode()
result = 31 * result + spriteIdle.contentHashCode()
result = 31 * result + spriteWidth
result = 31 * result + spriteHeight
result = 31 * result + (discoveredOn?.hashCode() ?: 0)
result = 31 * result + requiredTrophies
result = 31 * result + requiredVitals
result = 31 * result + requiredBattles
result = 31 * result + requiredWinRate
result = 31 * result + changeTimerHours
result = 31 * result + requiredAdventureLevelCompleted
return result
}
}
data class FusionsWithSpritesAndObtained( data class FusionsWithSpritesAndObtained(
val charaId: Long, val charaId: Long,
@@ -124,5 +385,33 @@ object CharacterDtos {
val spriteHeight: Int, val spriteHeight: Int,
val discoveredOn: Long?, val discoveredOn: Long?,
val fusionAttribute: NfcCharacter.Attribute val fusionAttribute: NfcCharacter.Attribute
) ) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as FusionsWithSpritesAndObtained
if (charaId != other.charaId) return false
if (fromCharaId != other.fromCharaId) return false
if (!spriteIdle.contentEquals(other.spriteIdle)) return false
if (spriteWidth != other.spriteWidth) return false
if (spriteHeight != other.spriteHeight) return false
if (discoveredOn != other.discoveredOn) return false
if (fusionAttribute != other.fusionAttribute) return false
return true
}
override fun hashCode(): Int {
var result = charaId.hashCode()
result = 31 * result + fromCharaId.hashCode()
result = 31 * result + spriteIdle.contentHashCode()
result = 31 * result + spriteWidth
result = 31 * result + spriteHeight
result = 31 * result + (discoveredOn?.hashCode() ?: 0)
result = 31 * result + fusionAttribute.hashCode()
return result
}
}
} }
@@ -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
} }
} }
}
) )
} }
} }
@@ -1,10 +1,15 @@
package com.github.nacabaro.vbhelper.screens package com.github.nacabaro.vbhelper.screens
/**
* This is me, nacabaro.
* File related to battles.
* TODO: Refactor this, 2400+ lines feels like a lot.
*/
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.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
//import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
@@ -28,10 +33,6 @@ import androidx.compose.ui.unit.sp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.LinearProgressIndicator
//import androidx.compose.material3.ExposedDropdownMenuBox
//import androidx.compose.material3.ExposedDropdownMenuDefaults
//import androidx.compose.material3.OutlinedTextField
//import androidx.compose.material3.DropdownMenuItem
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
@@ -51,8 +52,6 @@ import android.content.Intent
import android.net.Uri import android.net.Uri
import android.media.MediaPlayer import android.media.MediaPlayer
import android.os.Environment import android.os.Environment
//import androidx.compose.animation.core.animateFloatAsState
//import androidx.compose.animation.core.tween
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
@@ -72,13 +71,8 @@ import com.github.nacabaro.vbhelper.battle.AnimatedSpriteImage
import com.github.nacabaro.vbhelper.battle.HitEffectOverlay import com.github.nacabaro.vbhelper.battle.HitEffectOverlay
import com.github.nacabaro.vbhelper.battle.BattleAuthContainer import com.github.nacabaro.vbhelper.battle.BattleAuthContainer
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.collect
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
//import kotlin.math.sin
//import kotlin.math.PI
//import androidx.compose.animation.core.animateDpAsState
//import androidx.compose.animation.core.animateIntAsState
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Shadow import androidx.compose.ui.graphics.Shadow
@@ -86,24 +80,20 @@ import androidx.compose.ui.text.TextStyle
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.asImageBitmap
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
//import android.os.Environment
import java.io.File import java.io.File
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import com.github.nacabaro.vbhelper.di.VBHelper import com.github.nacabaro.vbhelper.di.VBHelper
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
//import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
@Composable /*@Composable
fun isLandscapeMode(): Boolean { fun isLandscapeMode(): Boolean {
val configuration = LocalConfiguration.current val configuration = LocalConfiguration.current
return configuration.screenWidthDp > configuration.screenHeightDp return configuration.screenWidthDp > configuration.screenHeightDp
} }*/
@Composable @Composable
fun getLandscapeModifier(): Modifier { fun getLandscapeModifier(): Modifier {
@@ -1735,7 +1725,7 @@ fun BattlesScreen() {
val currentCharacter = activeUserCharacter val currentCharacter = activeUserCharacter
if (currentCharacter != null && canBattle && playerBattleType != null) { if (currentCharacter != null && canBattle && playerBattleType != null) {
try { try {
RetrofitHelper().getOpponents(context, playerBattleType!!) { opponents -> RetrofitHelper().getOpponents(context, playerBattleType) { opponents ->
try { try {
// Create a new list to trigger UI recomposition // Create a new list to trigger UI recomposition
opponentsList = ArrayList(opponents.opponentsList) opponentsList = ArrayList(opponents.opponentsList)
@@ -2415,7 +2405,7 @@ fun BattlesScreen() {
// Also check winner field if it's not empty // Also check winner field if it's not empty
val playerWonFromWinner = activeCardId?.let { cardId -> val playerWonFromWinner = activeCardId?.let { cardId ->
val winner = apiResult.winner ?: "" val winner = apiResult.winner
if (winner.isNotEmpty()) { if (winner.isNotEmpty()) {
if (winner.contains("|")) { if (winner.contains("|")) {
// Pipe-separated format: first part is the winner // Pipe-separated format: first part is the winner
@@ -2442,7 +2432,7 @@ fun BattlesScreen() {
println("BATTLESCREEN: Battle result (first call) - winner: '${apiResult.winner}', playerHP: ${apiResult.playerHP}, opponentHP: ${apiResult.opponentHP}, playerWonFromHP: $playerWonFromHP, playerWonFromWinner: $playerWonFromWinner, final playerWon: $playerWon") println("BATTLESCREEN: Battle result (first call) - winner: '${apiResult.winner}', playerHP: ${apiResult.playerHP}, opponentHP: ${apiResult.opponentHP}, playerWonFromHP: $playerWonFromHP, playerWonFromWinner: $playerWonFromWinner, final playerWon: $playerWon")
// Store winner name for display (will be updated in cleanup call if available) // Store winner name for display (will be updated in cleanup call if available)
winnerName = apiResult.winner ?: if (playerWon) "You" else "Opponent" winnerName = apiResult.winner
isWinnerLoaded = true isWinnerLoaded = true
// Then send the cleanup call - this will have the actual winner name // Then send the cleanup call - this will have the actual winner name
@@ -2465,7 +2455,7 @@ fun BattlesScreen() {
// Primary method: Check HP values (opponentHP <= 0 means opponent lost = player won) // Primary method: Check HP values (opponentHP <= 0 means opponent lost = player won)
// Secondary method: Check winner name (if winner doesn't match opponent, player won) // Secondary method: Check winner name (if winner doesn't match opponent, player won)
val opponentName = selectedOpponent?.name ?: "" val opponentName = selectedOpponent?.name ?: ""
val winner = cleanupResult.winner ?: "" val winner = cleanupResult.winner
// Primary: HP-based determination (most reliable) // Primary: HP-based determination (most reliable)
// If opponentHP <= 0, opponent is dead = player won // If opponentHP <= 0, opponent is dead = player won
@@ -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(
} }
} }
} }
@@ -45,11 +45,12 @@ class HomeScreenControllerImpl(
.clearSpecialMission(missionId) .clearSpecialMission(missionId)
if (missionStatus.status == SpecialMission.Status.COMPLETED) { if (missionStatus.status == SpecialMission.Status.COMPLETED) {
val randomItem = database val allItems = database.itemDao().getAllItems().first()
.itemDao() if (allItems.isEmpty()) {
.getAllItems() onCleared(null, null)
.first() return@launch
.random() }
val randomItem = allItems.random()
val randomItemAmount = (Random.nextFloat() * 5).roundToInt() val randomItemAmount = (Random.nextFloat() * 5).roundToInt()
@@ -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 -> ""
}
}
@@ -142,7 +142,7 @@ fun VBDiMHomeScreen(
ItemDisplay( ItemDisplay(
icon = R.drawable.baseline_swords_24, icon = R.drawable.baseline_swords_24,
textValue = when { textValue = when {
activeMon.totalBattlesLost == 0 -> "0.00 %" activeMon.currentPhaseBattlesWon + activeMon.currentPhaseBattlesLost == 0 -> "0.00 %"
else -> { else -> {
val battleWinPercentage = val battleWinPercentage =
activeMon.currentPhaseBattlesWon.toFloat() / (activeMon.currentPhaseBattlesWon + activeMon.currentPhaseBattlesLost).toFloat() activeMon.currentPhaseBattlesWon.toFloat() / (activeMon.currentPhaseBattlesWon + activeMon.currentPhaseBattlesLost).toFloat()
@@ -150,7 +150,7 @@ fun VBDiMHomeScreen(
Locale.getDefault(), Locale.getDefault(),
"%.2f", "%.2f",
battleWinPercentage * 100 battleWinPercentage * 100
) + " %" // Specify locale ) + " %"
} }
}, },
definition = stringResource(R.string.home_vbdim_current_phase_win), definition = stringResource(R.string.home_vbdim_current_phase_win),
@@ -193,9 +193,9 @@ fun VBDiMHomeScreen(
onClickMission = { missionId -> onClickMission = { missionId ->
selectedSpecialMissionId = missionId selectedSpecialMissionId = missionId
}, },
onClickCollect = { onClickCollect = { missionId ->
homeScreenController homeScreenController
.clearSpecialMission(selectedSpecialMissionId, onClickCollect) .clearSpecialMission(missionId, onClickCollect)
} }
) )
} }
@@ -4,9 +4,9 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material3.PrimaryTabRow
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Tab import androidx.compose.material3.Tab
import androidx.compose.material3.TabRow
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@@ -34,7 +34,7 @@ fun ItemsScreen(
topBar = { topBar = {
Column { Column {
TopBanner(text = stringResource(R.string.items_title)) TopBanner(text = stringResource(R.string.items_title))
TabRow( PrimaryTabRow(
selectedTabIndex = selectedTabItem, selectedTabIndex = selectedTabItem,
modifier = Modifier modifier = Modifier
) { ) {
@@ -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,
}
@@ -44,14 +44,8 @@ fun ScanScreen(
val context = LocalContext.current val context = LocalContext.current
LaunchedEffect(storageRepository) { LaunchedEffect(characterId) {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
/*
First check if there is a character sent through the navigation system
If there is not, that means we got here through the home screen nfc button
If we got here through the home screen, it does not hurt to check if there is
an active character.
*/
if (characterId != null && nfcCharacter == null) { if (characterId != null && nfcCharacter == null) {
nfcCharacter = scanScreenController.characterToNfc(characterId) nfcCharacter = scanScreenController.characterToNfc(characterId)
} }
@@ -3,6 +3,7 @@ package com.github.nacabaro.vbhelper.screens.scanScreen
import android.content.Intent import android.content.Intent
import android.nfc.NfcAdapter import android.nfc.NfcAdapter
import android.nfc.Tag import android.nfc.Tag
import android.nfc.tech.IsoDep
import android.nfc.tech.NfcA import android.nfc.tech.NfcA
import android.os.Bundle import android.os.Bundle
import android.provider.Settings import android.provider.Settings
@@ -15,12 +16,16 @@ import com.github.cfogrady.vbnfc.be.BENfcCharacter
import com.github.cfogrady.vbnfc.data.NfcCharacter import com.github.cfogrady.vbnfc.data.NfcCharacter
import com.github.cfogrady.vbnfc.vb.VBNfcCharacter import com.github.cfogrady.vbnfc.vb.VBNfcCharacter
import com.github.nacabaro.vbhelper.ActivityLifecycleListener import com.github.nacabaro.vbhelper.ActivityLifecycleListener
import com.github.nacabaro.vbhelper.database.AppDatabase
import com.github.nacabaro.vbhelper.domain.card.Card import com.github.nacabaro.vbhelper.domain.card.Card
import com.github.nacabaro.vbhelper.screens.scanScreen.converters.FromNfcConverter import com.github.nacabaro.vbhelper.screens.scanScreen.converters.FromNfcConverter
import com.github.nacabaro.vbhelper.screens.scanScreen.converters.ToNfcConverter import com.github.nacabaro.vbhelper.screens.scanScreen.converters.ToNfcConverter
import com.github.nacabaro.vbhelper.source.VitalWearCharacterExporter
import com.github.nacabaro.vbhelper.source.VitalWearCharacterImporter
import com.github.nacabaro.vbhelper.source.getCryptographicTransformerMap import com.github.nacabaro.vbhelper.source.getCryptographicTransformerMap
import com.github.nacabaro.vbhelper.source.isMissingSecrets import com.github.nacabaro.vbhelper.source.isMissingSecrets
import com.github.nacabaro.vbhelper.source.proto.Secrets import com.github.nacabaro.vbhelper.source.proto.Secrets
import com.github.nacabaro.vbhelper.transfer.hce.VitalWearHceReaderClient
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
@@ -32,8 +37,10 @@ class ScanScreenControllerImpl(
private val componentActivity: ComponentActivity, private val componentActivity: ComponentActivity,
private val registerActivityLifecycleListener: (String, ActivityLifecycleListener)->Unit, private val registerActivityLifecycleListener: (String, ActivityLifecycleListener)->Unit,
private val unregisterActivityLifecycleListener: (String)->Unit, private val unregisterActivityLifecycleListener: (String)->Unit,
private val database: AppDatabase,
): ScanScreenController { ): ScanScreenController {
private var lastScannedCharacter: NfcCharacter? = null private var lastScannedCharacter: NfcCharacter? = null
private var pendingExportCharacterId: Long? = null
private val nfcAdapter: NfcAdapter private val nfcAdapter: NfcAdapter
init { init {
@@ -46,16 +53,46 @@ 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,
handlerFunc = { 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)
}
},
hceHandler = { isoDep ->
try {
val client = VitalWearHceReaderClient(isoDep)
val character = client.receiveCharacterFromWatch()
val importer = VitalWearCharacterImporter(database)
val result = importer.importCharacter(character)
Log.i("NFC_READ", "VitalWear HCE import: ${result.message}")
componentActivity.runOnUiThread {
Toast.makeText(componentActivity, result.message, Toast.LENGTH_SHORT).show()
}
} catch (e: Exception) {
Log.e("NFC_READ", "Error reading character from VitalWear HCE", e)
componentActivity.runOnUiThread {
Toast.makeText(componentActivity, componentActivity.getString(R.string.scan_error_generic) + ": " + (e.message ?: e.javaClass.simpleName), Toast.LENGTH_LONG).show()
}
} finally {
onComplete()
}
}
)
} }
override fun cancelRead() { override fun cancelRead() {
@@ -75,29 +112,45 @@ class ScanScreenControllerImpl(
unregisterActivityLifecycleListener.invoke(key) unregisterActivityLifecycleListener.invoke(key)
} }
// EXTRACTED DIRECTLY FROM EXAMPLE APP private fun handleTag(
private fun handleTag(secrets: Secrets, handlerFunc: (TagCommunicator)->String) { secrets: Secrets,
handlerFunc: (TagCommunicator) -> String,
hceHandler: ((IsoDep) -> Unit)? = null,
) {
if (!nfcAdapter.isEnabled) { if (!nfcAdapter.isEnabled) {
showWirelessSettings() showWirelessSettings()
} else { } else {
val options = Bundle() val options = Bundle()
// Work around for some broken Nfc firmware implementations that poll the card too fast // Work around for some broken Nfc firmware implementations that poll the card too fast
options.putInt(NfcAdapter.EXTRA_READER_PRESENCE_CHECK_DELAY, 250) options.putInt(NfcAdapter.EXTRA_READER_PRESENCE_CHECK_DELAY, 250)
nfcAdapter.enableReaderMode(componentActivity, buildOnReadTag(secrets, handlerFunc), NfcAdapter.FLAG_READER_NFC_A or NfcAdapter.FLAG_READER_SKIP_NDEF_CHECK, nfcAdapter.enableReaderMode(
componentActivity,
buildOnReadTag(secrets, handlerFunc, hceHandler),
NfcAdapter.FLAG_READER_NFC_A or NfcAdapter.FLAG_READER_SKIP_NDEF_CHECK,
options options
) )
} }
} }
// EXTRACTED DIRECTLY FROM EXAMPLE APP private fun buildOnReadTag(
private fun buildOnReadTag(secrets: Secrets, handlerFunc: (TagCommunicator)->String): (Tag)->Unit { secrets: Secrets,
return { tag-> handlerFunc: (TagCommunicator) -> String,
hceHandler: ((IsoDep) -> Unit)? = null,
): (Tag) -> Unit {
return { tag ->
val isoDep = IsoDep.get(tag)
if (isoDep != null && hceHandler != null) {
// VitalWear watch via HCE / ISO-DEP
isoDep.connect()
isoDep.use { hceHandler(isoDep) }
} else {
// Physical Vital Bracelet via raw NFC-A
val nfcData = NfcA.get(tag) val nfcData = NfcA.get(tag)
if (nfcData == null) { if (nfcData == null) {
componentActivity.runOnUiThread { componentActivity.runOnUiThread {
Toast.makeText(componentActivity, componentActivity.getString(R.string.scan_tag_not_vb), Toast.LENGTH_SHORT).show() Toast.makeText(componentActivity, componentActivity.getString(R.string.scan_tag_not_vb), Toast.LENGTH_SHORT).show()
} }
} } else {
nfcData.connect() nfcData.connect()
nfcData.use { nfcData.use {
val tagCommunicator = TagCommunicator.getInstance(nfcData, secrets.getCryptographicTransformerMap()) val tagCommunicator = TagCommunicator.getInstance(nfcData, secrets.getCryptographicTransformerMap())
@@ -108,6 +161,8 @@ class ScanScreenControllerImpl(
} }
} }
} }
}
}
private fun checkSecrets() { private fun checkSecrets() {
componentActivity.lifecycleScope.launch(Dispatchers.IO) { componentActivity.lifecycleScope.launch(Dispatchers.IO) {
@@ -124,16 +179,16 @@ class ScanScreenControllerImpl(
nfcCharacter: NfcCharacter, nfcCharacter: NfcCharacter,
onComplete: () -> Unit onComplete: () -> Unit
) { ) {
handleTag(secrets) { tagCommunicator -> handleTag(
secrets,
handlerFunc = { tagCommunicator ->
try { try {
if (nfcCharacter is VBNfcCharacter) { if (nfcCharacter is VBNfcCharacter) {
Log.d("SendCharacter", "VBNfcCharacter") Log.d("SendCharacter", "VBNfcCharacter")
val castNfcCharacter: VBNfcCharacter = nfcCharacter tagCommunicator.sendCharacter(nfcCharacter)
tagCommunicator.sendCharacter(castNfcCharacter)
} else if (nfcCharacter is BENfcCharacter) { } else if (nfcCharacter is BENfcCharacter) {
Log.d("SendCharacter", "BENfcCharacter") Log.d("SendCharacter", "BENfcCharacter")
val castNfcCharacter: BENfcCharacter = nfcCharacter tagCommunicator.sendCharacter(nfcCharacter)
tagCommunicator.sendCharacter(castNfcCharacter)
} }
onComplete.invoke() onComplete.invoke()
componentActivity.getString(R.string.scan_sent_character_success) componentActivity.getString(R.string.scan_sent_character_success)
@@ -141,7 +196,12 @@ class ScanScreenControllerImpl(
Log.e("TAG", e.stackTraceToString()) Log.e("TAG", e.stackTraceToString())
componentActivity.getString(R.string.scan_error_generic) componentActivity.getString(R.string.scan_error_generic)
} }
},
hceHandler = { _ ->
// Character was already sent in onClickCheckCard's HCE handler.
onComplete()
} }
)
} }
override fun onClickCheckCard( override fun onClickCheckCard(
@@ -149,11 +209,44 @@ class ScanScreenControllerImpl(
nfcCharacter: NfcCharacter, nfcCharacter: NfcCharacter,
onComplete: () -> Unit onComplete: () -> Unit
) { ) {
handleTag(secrets) { tagCommunicator -> handleTag(
secrets,
handlerFunc = { tagCommunicator ->
tagCommunicator.prepareDIMForCharacter(nfcCharacter.dimId) tagCommunicator.prepareDIMForCharacter(nfcCharacter.dimId)
onComplete.invoke() onComplete.invoke()
componentActivity.getString(R.string.scan_sent_dim_success) componentActivity.getString(R.string.scan_sent_dim_success)
},
hceHandler = { isoDep ->
// VitalWear watch: send the character proto directly via HCE.
val characterId = pendingExportCharacterId
if (characterId == null) {
Log.e("NFC_WRITE", "No pending character to send to VitalWear")
componentActivity.runOnUiThread {
Toast.makeText(componentActivity, componentActivity.getString(R.string.scan_error_generic), Toast.LENGTH_SHORT).show()
} }
onComplete()
return@handleTag
}
try {
val proto = VitalWearCharacterExporter(componentActivity, database).buildCharacterProto(characterId)
val client = VitalWearHceReaderClient(isoDep)
client.sendCharacterToWatch(proto)
database.userCharacterDao().deleteCharacterById(characterId)
pendingExportCharacterId = null
Log.i("NFC_WRITE", "Character sent to VitalWear and deleted from app (id=$characterId)")
componentActivity.runOnUiThread {
Toast.makeText(componentActivity, componentActivity.getString(R.string.scan_sent_character_success), Toast.LENGTH_SHORT).show()
}
} catch (e: Exception) {
Log.e("NFC_WRITE", "Error sending character to VitalWear HCE", e)
componentActivity.runOnUiThread {
Toast.makeText(componentActivity, componentActivity.getString(R.string.scan_error_generic) + ": " + (e.message ?: e.javaClass.simpleName), Toast.LENGTH_LONG).show()
}
} finally {
onComplete()
}
}
)
} }
// EXTRACTED DIRECTLY FROM EXAMPLE APP // EXTRACTED DIRECTLY FROM EXAMPLE APP
@@ -173,10 +266,8 @@ class ScanScreenControllerImpl(
} }
override suspend fun characterToNfc(characterId: Long): NfcCharacter { override suspend fun characterToNfc(characterId: Long): NfcCharacter {
val nfcGenerator = ToNfcConverter( pendingExportCharacterId = characterId
componentActivity = componentActivity val nfcGenerator = ToNfcConverter(componentActivity = componentActivity)
)
val character = nfcGenerator.characterToNfc(characterId) val character = nfcGenerator.characterToNfc(characterId)
Log.d("CharacterType", character.toString()) Log.d("CharacterType", character.toString())
return character return character
@@ -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,55 @@
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
import timber.log.Timber
/**
* 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
Timber.e(e, "Failed to convert character $characterId to VB type")
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,118 @@
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 buildCharacterProto(characterId: Long): Character = 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
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()
}
fun buildShareIntent(characterId: Long): Intent {
return runBlocking {
val proto = buildCharacterProto(characterId)
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
@@ -13,7 +13,27 @@ data class BitmapData (
val bitmap: ByteArray, val bitmap: ByteArray,
val width: Int, val width: Int,
val height: Int val height: Int
) ) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as BitmapData
if (!bitmap.contentEquals(other.bitmap)) return false
if (width != other.width) return false
if (height != other.height) return false
return true
}
override fun hashCode(): Int {
var result = bitmap.contentHashCode()
result = 31 * result + width
result = 31 * result + height
return result
}
}
data class ImageBitmapData( data class ImageBitmapData(
val imageBitmap: ImageBitmap, val imageBitmap: ImageBitmap,
@@ -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,58 @@
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;
enum TransferDeviceType {
TRANSFER_DEVICE_TYPE_UNSPECIFIED = 0;
TRANSFER_DEVICE_TYPE_VB = 1;
TRANSFER_DEVICE_TYPE_BE = 2;
}
TransferDeviceType transfer_device_type = 16;
}
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;
}
+161
View File
@@ -0,0 +1,161 @@
<?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">No Data</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_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_send_to_vitalwear">VitalWearへ転送</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">このアプリは現在開発中です。\n今後のアップデートにより、保存されているデータ全ての削除が必要になる場合があります。\nその為、大切なデータの保存には使用しないようお願い致します。\nご不便をおかけして申し訳ございません。</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="nav_dex">図鑑</string>
<string name="credits_RedEyez_description">献身的なデバッグ作業を行って頂きました。</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_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">\n 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">No Data</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>
<string name="settings_companion_import_card_image_title">カードのインポート</string>
<string name="companion_validation_connect_vb">デバイスに接続</string>
<string name="companion_card_import_success">カードのインポートに成功しました。</string>
<string name="companion_firmware_no_watch">デバイスが見つかりません。</string>
<string name="companion_validation_insert_card">デバイスへカードを差し込んで下さい。</string>
<string name="companion_validation_success">データチェックが完了しました。</string>
<string name="companion_loading_select_firmware">ファームウェアファイルを選択</string>
<string name="companion_firmware_sent_success">ファームウェアの送信が完了しました。</string>
<string name="companion_logs_receive_failed">ログの受信に失敗しました。</string>
<string name="companion_logs_failed_request">ログの要求に失敗しました。</string>
<string name="companion_logs_warning">注意:ログの転送には時間がかかる場合があります。</string>
<string name="settings_companion_send_watch_logs_title">ログの送信</string>
<string name="companion_transfer_disabled_message">現在、転送は無効になっています。</string>
</resources>
+3 -9
View File
@@ -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>
+27 -21
View File
@@ -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>
@@ -226,4 +219,17 @@
<string name="home_special_mission_delete_dismiss">Dismiss</string> <string name="home_special_mission_delete_dismiss">Dismiss</string>
<string name="home_special_mission_delete_remove">Remove</string> <string name="home_special_mission_delete_remove">Remove</string>
<string name="settings_companion_import_card_image_title">Import Card Image</string>
<string name="companion_validation_connect_vb">Connect Vital Bracelet</string>
<string name="companion_card_import_success">Card imported successfully!</string>
<string name="companion_firmware_no_watch">No watch found!</string>
<string name="companion_validation_insert_card">Insert card into Vital Bracelet</string>
<string name="companion_validation_success">Validation successful!</string>
<string name="companion_loading_select_firmware">Select firmware file</string>
<string name="companion_firmware_sent_success">Firmware sent successfully!</string>
<string name="companion_logs_receive_failed">Failed to receive logs</string>
<string name="companion_logs_failed_request">Failed to request logs</string>
<string name="companion_logs_warning">Warning: Log transfer might take a while</string>
<string name="settings_companion_send_watch_logs_title">Send Watch Logs</string>
<string name="companion_transfer_disabled_message">Transfer is currently disabled</string>
</resources> </resources>
+6
View 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,
)
}
}
+2 -6
View File
@@ -3,10 +3,6 @@ plugins {
alias(libs.plugins.android.application) apply false alias(libs.plugins.android.application) apply false
alias(libs.plugins.kotlin.android) apply false alias(libs.plugins.kotlin.android) apply false
alias(libs.plugins.kotlin.compose) apply false alias(libs.plugins.kotlin.compose) apply false
} alias(libs.plugins.vksp) apply false
alias(libs.plugins.vprotobuf) apply false
buildscript {
dependencies {
classpath(libs.protobuf.gradle.plugin)
}
} }
+3
View File
@@ -21,3 +21,6 @@ kotlin.code.style=official
# resources declared in the library itself and none from the library's dependencies, # resources declared in the library itself and none from the library's dependencies,
# thereby reducing the size of the R class for that library # thereby reducing the size of the R class for that library
android.nonTransitiveRClass=true android.nonTransitiveRClass=true
android.uniquePackageNames=false
android.dependency.useConstraints=true
android.r8.strictFullModeForKeepRules=false
+31 -13
View File
@@ -1,22 +1,30 @@
[versions] [versions]
agp = "8.13.2" agp = "9.2.1"
datastore = "1.2.0" datastore = "1.2.1"
datastorePreferences = "1.2.0" datastorePreferences = "1.2.1"
kotlin = "2.3.0" kotlin = "2.4.0"
coreKtx = "1.17.0" coreKtx = "1.19.0"
junit = "4.13.2" junit = "4.13.2"
junitVersion = "1.3.0" junitVersion = "1.3.0"
espressoCore = "3.7.0" espressoCore = "3.7.0"
lifecycleRuntimeKtx = "2.10.0" lifecycleRuntimeKtx = "2.11.0"
activityCompose = "1.12.2" activityCompose = "1.13.0"
composeBom = "2026.01.00" composeBom = "2026.06.00"
material = "1.13.0" material = "1.14.0"
navigationCompose = "2.9.6" navigationCompose = "2.9.8"
protobufGradlePlugin = "0.9.6" protobufGradlePlugin = "0.10.0"
protobufJavalite = "4.33.4" protobufJavalite = "4.35.1"
roomRuntime = "2.8.4" roomRuntime = "2.8.4"
vbNfcReader = "0.2.0-SNAPSHOT" vbNfcReader = "0.2.0-SNAPSHOT"
dimReader = "2.1.0" dimReader = "2.1.0"
playServicesWearable = "20.0.1"
kotlinxCoroutinesPlayServices = "1.11.0"
timber = "5.0.1"
tinylog = "2.7.0"
retrofit = "3.0.0"
gson = "2.14.0"
okhttp = "5.4.0"
ksp = "2.3.9"
[libraries] [libraries]
androidx-compose-material = { module = "androidx.compose.material:material" } androidx-compose-material = { module = "androidx.compose.material:material" }
@@ -42,13 +50,23 @@ androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-man
androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
androidx-material3 = { group = "androidx.compose.material3", name = "material3" } androidx-material3 = { group = "androidx.compose.material3", name = "material3" }
material = { module = "com.google.android.material:material", version.ref = "material" } material = { module = "com.google.android.material:material", version.ref = "material" }
protobuf-gradle-plugin = { module = "com.google.protobuf:protobuf-gradle-plugin", version.ref = "protobufGradlePlugin" }
protobuf-javalite = { module = "com.google.protobuf:protobuf-javalite", version.ref = "protobufJavalite" } protobuf-javalite = { module = "com.google.protobuf:protobuf-javalite", version.ref = "protobufJavalite" }
vb-nfc-reader = { module = "com.github.cfogrady:vb-nfc-reader", version.ref = "vbNfcReader" } vb-nfc-reader = { module = "com.github.cfogrady:vb-nfc-reader", version.ref = "vbNfcReader" }
dim-reader = { module = "com.github.cfogrady:vb-dim-reader", version.ref = "dimReader" } dim-reader = { module = "com.github.cfogrady:vb-dim-reader", version.ref = "dimReader" }
play-services-wearable = { group = "com.google.android.gms", name = "play-services-wearable", version.ref = "playServicesWearable" }
kotlinx-coroutines-play-services = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-play-services", version.ref = "kotlinxCoroutinesPlayServices" }
timber = { module = "com.jakewharton.timber:timber", version.ref = "timber" }
tinylog-api = { module = "org.tinylog:tinylog-api", version.ref = "tinylog" }
tinylog-impl = { module = "org.tinylog:tinylog-impl", version.ref = "tinylog" }
retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" }
retrofit-converter-gson = { module = "com.squareup.retrofit2:converter-gson", version.ref = "retrofit" }
gson = { module = "com.google.code.gson:gson", version.ref = "gson" }
okhttp-logging-interceptor = { module = "com.squareup.okhttp3:logging-interceptor", version.ref = "okhttp" }
[plugins] [plugins]
android-application = { id = "com.android.application", version.ref = "agp" } android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
vksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
vprotobuf = { id = "com.google.protobuf", version.ref = "protobufGradlePlugin" }
+1 -1
View File
@@ -1,6 +1,6 @@
#Tue Dec 24 10:55:57 UTC 2024 #Tue Dec 24 10:55:57 UTC 2024
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.1-bin.zip
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists zipStorePath=wrapper/dists
+1
View File
@@ -17,6 +17,7 @@ dependencyResolutionManagement {
mavenLocal() mavenLocal()
google() google()
mavenCentral() mavenCentral()
maven { url = uri("https://jitpack.io") }
} }
} }