Compare commits

..
Author SHA1 Message Date
nacabaro c8e035aee1 Fixes 2026-06-24 21:28:22 +00:00
nacabaro 5787069761 Revert 457R0 work, merged wrong branch
Revert "Merge pull request #53 from 457R0/main"

This reverts commit 8aa31e819b, reversing
changes made to af27fc4933.
2026-06-22 14:55:12 +00:00
100 changed files with 412 additions and 4780 deletions
+2
View File
@@ -17,3 +17,5 @@ app/src/main/assets/battle_sprites
app/src/main/assets/extracted_audio
API-ACR122USAM-2.01.pdf
build/
+21 -14
View File
@@ -2,21 +2,22 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.vksp)
alias(libs.plugins.vprotobuf)
id("com.google.devtools.ksp") version "2.3.2"
id("com.google.protobuf")
}
android {
namespace = "com.github.nacabaro.vbhelper"
compileSdk = 37
compileSdk = 36
defaultConfig {
applicationId = "com.github.nacabaro.vbhelper"
minSdk = 28
targetSdk = 37
targetSdk = 36
versionCode = 1
versionName = "Alpha 0.6.5"
versionName = "Alpha 0.6.3"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
@@ -55,6 +56,9 @@ protobuf {
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 {
all().forEach { task ->
task.builtins {
@@ -86,11 +90,6 @@ dependencies {
implementation(libs.androidx.ui.tooling.preview)
implementation(libs.androidx.material3)
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.test.manifest)
@@ -105,10 +104,18 @@ dependencies {
androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.ui.test.junit4)
implementation(libs.retrofit)
implementation(libs.retrofit.converter.gson)
implementation(libs.gson)
debugImplementation(libs.androidx.ui.tooling)
debugImplementation(libs.androidx.ui.test.manifest)
implementation("androidx.navigation:navigation-compose:2.7.0")
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
implementation(libs.okhttp.logging.interceptor)
implementation("com.squareup.okhttp3:logging-interceptor:4.11.0")
}
+107 -63
View File
@@ -35,14 +35,14 @@
</issue>
<issue
id="PropertyEscape"
message="Windows file separators (`\`) and drive letter separators (&apos;:&apos;) must be escaped (`\\`) in property files; use D\\:/Android/Sdk\\r"
errorLine1="sdk.dir=D:/Android/Sdk"
errorLine2=" ~">
id="OldTargetApi"
message="Not targeting the latest versions of Android; compatibility modes apply. Consider testing and updating this version. Consult the `android.os.Build.VERSION_CODES` javadoc for details."
errorLine1=" targetSdk = 36"
errorLine2=" ~~~~~~~~~~~~~~">
<location
file="$HOME/Documents/Codex/2026-04-18-review-bandai-s-vital-hero-bracelet/vbhelper/local.properties"
line="1"
column="10"/>
file="build.gradle.kts"
line="18"
column="9"/>
</issue>
<issue
@@ -52,7 +52,7 @@
errorLine2=" ~~~~~~~~~~~~~">
<location
file="src/main/AndroidManifest.xml"
line="50"
line="41"
column="14"/>
</issue>
@@ -63,7 +63,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/AndroidManifest.xml"
line="54"
line="45"
column="17"/>
</issue>
@@ -74,7 +74,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/AndroidManifest.xml"
line="55"
line="46"
column="17"/>
</issue>
@@ -85,24 +85,46 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/AndroidManifest.xml"
line="37"
line="28"
column="13"/>
</issue>
<issue
id="AndroidGradlePluginVersion"
message="A newer version of com.android.application than 8.13.2 is available: 9.1.1"
message="A newer version of Gradle than 8.14.3 is available: 8.14.5"
errorLine1="distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="$HOME/VBHelper/vbhelper/gradle/wrapper/gradle-wrapper.properties"
line="4"
column="17"/>
</issue>
<issue
id="AndroidGradlePluginVersion"
message="A newer version of com.android.application than 8.13.2 is available: 9.2.1"
errorLine1="agp = &quot;8.13.2&quot;"
errorLine2=" ~~~~~~~~">
<location
file="$HOME/Documents/Codex/2026-04-18-review-bandai-s-vital-hero-bracelet/vbhelper/gradle/libs.versions.toml"
file="$HOME/VBHelper/vbhelper/gradle/libs.versions.toml"
line="2"
column="7"/>
</issue>
<issue
id="GradleDependency"
message="A newer version of androidx.navigation:navigation-compose than 2.7.0 is available: 2.9.7"
message="A newer version of com.google.devtools.ksp than 2.3.0 is available: 2.3.2"
errorLine1=" id(&quot;com.google.devtools.ksp&quot;) version &quot;2.3.0&quot;"
errorLine2=" ~~~~~~~">
<location
file="build.gradle.kts"
line="7"
column="43"/>
</issue>
<issue
id="GradleDependency"
message="A newer version of androidx.navigation:navigation-compose than 2.7.0 is available: 2.9.8"
errorLine1=" implementation(&quot;androidx.navigation:navigation-compose:2.7.0&quot;)"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
@@ -113,7 +135,7 @@
<issue
id="GradleDependency"
message="A newer version of com.google.android.material:material than 1.2.0 is available: 1.13.0"
message="A newer version of com.google.android.material:material than 1.2.0 is available: 1.14.0"
errorLine1=" implementation(&quot;com.google.android.material:material:1.2.0&quot;)"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
@@ -139,7 +161,7 @@
errorLine1="datastore = &quot;1.2.0&quot;"
errorLine2=" ~~~~~~~">
<location
file="$HOME/Documents/Codex/2026-04-18-review-bandai-s-vital-hero-bracelet/vbhelper/gradle/libs.versions.toml"
file="$HOME/VBHelper/vbhelper/gradle/libs.versions.toml"
line="3"
column="13"/>
</issue>
@@ -150,51 +172,73 @@
errorLine1="datastorePreferences = &quot;1.2.0&quot;"
errorLine2=" ~~~~~~~">
<location
file="$HOME/Documents/Codex/2026-04-18-review-bandai-s-vital-hero-bracelet/vbhelper/gradle/libs.versions.toml"
file="$HOME/VBHelper/vbhelper/gradle/libs.versions.toml"
line="4"
column="24"/>
</issue>
<issue
id="GradleDependency"
message="A newer version of androidx.core:core-ktx than 1.17.0 is available: 1.18.0"
message="A newer version of androidx.core:core-ktx than 1.17.0 is available: 1.19.0"
errorLine1="coreKtx = &quot;1.17.0&quot;"
errorLine2=" ~~~~~~~~">
<location
file="$HOME/Documents/Codex/2026-04-18-review-bandai-s-vital-hero-bracelet/vbhelper/gradle/libs.versions.toml"
file="$HOME/VBHelper/vbhelper/gradle/libs.versions.toml"
line="6"
column="11"/>
</issue>
<issue
id="GradleDependency"
message="A newer version of androidx.lifecycle:lifecycle-runtime-ktx than 2.10.0 is available: 2.11.0"
errorLine1="lifecycleRuntimeKtx = &quot;2.10.0&quot;"
errorLine2=" ~~~~~~~~">
<location
file="$HOME/VBHelper/vbhelper/gradle/libs.versions.toml"
line="10"
column="23"/>
</issue>
<issue
id="GradleDependency"
message="A newer version of androidx.activity:activity-compose than 1.12.2 is available: 1.13.0"
errorLine1="activityCompose = &quot;1.12.2&quot;"
errorLine2=" ~~~~~~~~">
<location
file="$HOME/Documents/Codex/2026-04-18-review-bandai-s-vital-hero-bracelet/vbhelper/gradle/libs.versions.toml"
file="$HOME/VBHelper/vbhelper/gradle/libs.versions.toml"
line="11"
column="19"/>
</issue>
<issue
id="GradleDependency"
message="A newer version of androidx.compose:compose-bom than 2026.01.00 is available: 2026.03.01"
message="A newer version of androidx.compose:compose-bom than 2026.01.00 is available: 2026.06.00"
errorLine1="composeBom = &quot;2026.01.00&quot;"
errorLine2=" ~~~~~~~~~~~~">
<location
file="$HOME/Documents/Codex/2026-04-18-review-bandai-s-vital-hero-bracelet/vbhelper/gradle/libs.versions.toml"
file="$HOME/VBHelper/vbhelper/gradle/libs.versions.toml"
line="12"
column="14"/>
</issue>
<issue
id="GradleDependency"
message="A newer version of androidx.navigation:navigation-compose than 2.9.6 is available: 2.9.7"
message="A newer version of com.google.android.material:material than 1.13.0 is available: 1.14.0"
errorLine1="material = &quot;1.13.0&quot;"
errorLine2=" ~~~~~~~~">
<location
file="$HOME/VBHelper/vbhelper/gradle/libs.versions.toml"
line="13"
column="12"/>
</issue>
<issue
id="GradleDependency"
message="A newer version of androidx.navigation:navigation-compose than 2.9.6 is available: 2.9.8"
errorLine1="navigationCompose = &quot;2.9.6&quot;"
errorLine2=" ~~~~~~~">
<location
file="$HOME/Documents/Codex/2026-04-18-review-bandai-s-vital-hero-bracelet/vbhelper/gradle/libs.versions.toml"
file="$HOME/VBHelper/vbhelper/gradle/libs.versions.toml"
line="14"
column="21"/>
</issue>
@@ -223,7 +267,7 @@
<issue
id="NewerVersionAvailable"
message="A newer version of com.google.code.gson:gson than 2.10.1 is available: 2.13.2"
message="A newer version of com.google.code.gson:gson than 2.10.1 is available: 2.14.0"
errorLine1=" implementation(&quot;com.google.code.gson:gson:2.10.1&quot;)"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
@@ -234,7 +278,7 @@
<issue
id="NewerVersionAvailable"
message="A newer version of com.squareup.okhttp3:logging-interceptor than 4.11.0 is available: 5.3.2"
message="A newer version of com.squareup.okhttp3:logging-interceptor than 4.11.0 is available: 5.4.0"
errorLine1=" implementation(&quot;com.squareup.okhttp3:logging-interceptor:4.11.0&quot;)"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
@@ -245,33 +289,44 @@
<issue
id="NewerVersionAvailable"
message="A newer version of org.jetbrains.kotlin.android than 2.3.0 is available: 2.3.20"
message="A newer version of org.jetbrains.kotlin.android than 2.3.0 is available: 2.4.0"
errorLine1="kotlin = &quot;2.3.0&quot;"
errorLine2=" ~~~~~~~">
<location
file="$HOME/Documents/Codex/2026-04-18-review-bandai-s-vital-hero-bracelet/vbhelper/gradle/libs.versions.toml"
file="$HOME/VBHelper/vbhelper/gradle/libs.versions.toml"
line="5"
column="10"/>
</issue>
<issue
id="NewerVersionAvailable"
message="A newer version of org.jetbrains.kotlin.plugin.compose than 2.3.0 is available: 2.3.20"
message="A newer version of org.jetbrains.kotlin.plugin.compose than 2.3.0 is available: 2.4.0"
errorLine1="kotlin = &quot;2.3.0&quot;"
errorLine2=" ~~~~~~~">
<location
file="$HOME/Documents/Codex/2026-04-18-review-bandai-s-vital-hero-bracelet/vbhelper/gradle/libs.versions.toml"
file="$HOME/VBHelper/vbhelper/gradle/libs.versions.toml"
line="5"
column="10"/>
</issue>
<issue
id="NewerVersionAvailable"
message="A newer version of com.google.protobuf:protobuf-javalite than 4.33.4 is available: 4.34.1"
message="A newer version of com.google.protobuf:protobuf-gradle-plugin than 0.9.6 is available: 0.10.0"
errorLine1="protobufGradlePlugin = &quot;0.9.6&quot;"
errorLine2=" ~~~~~~~">
<location
file="$HOME/VBHelper/vbhelper/gradle/libs.versions.toml"
line="15"
column="24"/>
</issue>
<issue
id="NewerVersionAvailable"
message="A newer version of com.google.protobuf:protobuf-javalite than 4.33.4 is available: 4.35.1"
errorLine1="protobufJavalite = &quot;4.33.4&quot;"
errorLine2=" ~~~~~~~~">
<location
file="$HOME/Documents/Codex/2026-04-18-review-bandai-s-vital-hero-bracelet/vbhelper/gradle/libs.versions.toml"
file="$HOME/VBHelper/vbhelper/gradle/libs.versions.toml"
line="16"
column="20"/>
</issue>
@@ -558,7 +613,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/java/com/github/nacabaro/vbhelper/screens/storageScreen/StorageDialog.kt"
line="97"
line="96"
column="46"/>
</issue>
@@ -628,17 +683,6 @@
column="37"/>
</issue>
<issue
id="MissingTranslation"
message="&quot;storage_send_to_vitalwear&quot; is not translated in &quot;pt&quot; (Portuguese)"
errorLine1=" &lt;string name=&quot;storage_send_to_vitalwear&quot;>Send to VitalWear&lt;/string>"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/strings.xml"
line="202"
column="13"/>
</issue>
<issue
id="MissingTranslation"
message="&quot;home_special_mission_delete_main&quot; is not translated in &quot;pt&quot; (Portuguese)"
@@ -646,7 +690,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/strings.xml"
line="226"
line="215"
column="13"/>
</issue>
@@ -657,7 +701,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/strings.xml"
line="227"
line="216"
column="13"/>
</issue>
@@ -668,7 +712,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/strings.xml"
line="228"
line="217"
column="13"/>
</issue>
@@ -679,7 +723,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/strings.xml"
line="151"
line="141"
column="5"/>
</issue>
@@ -690,7 +734,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/strings.xml"
line="156"
line="146"
column="5"/>
</issue>
@@ -701,7 +745,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/strings.xml"
line="162"
line="152"
column="5"/>
</issue>
@@ -712,7 +756,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/strings.xml"
line="192"
line="182"
column="5"/>
</issue>
@@ -723,7 +767,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/strings.xml"
line="217"
line="206"
column="5"/>
</issue>
@@ -734,7 +778,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/strings.xml"
line="218"
line="207"
column="5"/>
</issue>
@@ -745,7 +789,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/strings.xml"
line="219"
line="208"
column="5"/>
</issue>
@@ -756,7 +800,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/strings.xml"
line="220"
line="209"
column="5"/>
</issue>
@@ -767,7 +811,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/strings.xml"
line="221"
line="210"
column="5"/>
</issue>
@@ -778,7 +822,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/strings.xml"
line="222"
line="211"
column="5"/>
</issue>
@@ -789,7 +833,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/strings.xml"
line="223"
line="212"
column="5"/>
</issue>
@@ -800,7 +844,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/strings.xml"
line="224"
line="213"
column="5"/>
</issue>
@@ -1335,7 +1379,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/strings.xml"
line="168"
line="158"
column="13"/>
</issue>
@@ -1346,7 +1390,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/strings.xml"
line="169"
line="159"
column="13"/>
</issue>
@@ -1423,7 +1467,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values-pt-rBR/strings.xml"
line="113"
line="109"
column="45"/>
</issue>
@@ -1434,7 +1478,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/strings.xml"
line="118"
line="110"
column="45"/>
</issue>
+3 -19
View File
@@ -7,7 +7,6 @@
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.WRITE_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"
tools:ignore="ScopedStorage" />
@@ -23,18 +22,10 @@
android:theme="@style/Theme.VBHelper"
android:usesCleartextTraffic="true"
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
android:name=".MainActivity"
android:exported="true"
android:label="@string/app_name"
android:theme="@style/Theme.VBHelper">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
@@ -51,15 +42,8 @@
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="http" />
<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" />
<data android:scheme="http" android:host="localhost" android:port="8080" android:pathPrefix="/authenticate" />
<data android:scheme="http" android:host="127.0.0.1" android:port="8080" android:pathPrefix="/authenticate" />
</intent-filter>
</activity>
</application>
@@ -1,22 +0,0 @@
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 }
}
}
}
@@ -1,16 +0,0 @@
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
}
@@ -1,47 +0,0 @@
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)
}
}
}
@@ -1,24 +0,0 @@
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,16 +1,11 @@
package com.github.nacabaro.vbhelper
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.util.Log
import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.runtime.Composable
import androidx.lifecycle.lifecycleScope
import com.github.cfogrady.vitalwear.protos.Character
import com.github.nacabaro.vbhelper.navigation.AppNavigation
import com.github.nacabaro.vbhelper.di.VBHelper
import com.github.nacabaro.vbhelper.navigation.AppNavigationHandlers
@@ -22,15 +17,11 @@ import com.github.nacabaro.vbhelper.screens.adventureScreen.AdventureScreenContr
import com.github.nacabaro.vbhelper.screens.cardScreen.CardScreenControllerImpl
import com.github.nacabaro.vbhelper.screens.spriteViewer.SpriteViewerControllerImpl
import com.github.nacabaro.vbhelper.screens.storageScreen.StorageScreenControllerImpl
import com.github.nacabaro.vbhelper.source.VitalWearCharacterImporter
import com.github.nacabaro.vbhelper.ui.theme.VBHelperTheme
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
class MainActivity : ComponentActivity() {
private val onActivityLifecycleListeners = HashMap<String, ActivityLifecycleListener>()
private var initialRoute: String? = null
private fun registerActivityLifecycleListener(key: String, activityLifecycleListener: ActivityLifecycleListener) {
if( onActivityLifecycleListeners[key] != null) {
@@ -49,8 +40,7 @@ class MainActivity : ComponentActivity() {
application.container.dataStoreSecretsRepository.secretsFlow,
this,
this::registerActivityLifecycleListener,
this::unregisterActivityLifecycleListener,
application.container.db,
this::unregisterActivityLifecycleListener
)
val settingsScreenController = SettingsScreenControllerImpl(this)
val itemsScreenController = ItemsScreenControllerImpl(this)
@@ -64,8 +54,6 @@ class MainActivity : ComponentActivity() {
enableEdgeToEdge()
initialRoute = getInitialRouteFromIntent(intent)
setContent {
VBHelperTheme {
MainApplication(
@@ -76,14 +64,12 @@ class MainActivity : ComponentActivity() {
homeScreenController = homeScreenController,
storageScreenController = storageScreenController,
spriteViewerController = spriteViewerController,
cardScreenController = cardScreenController,
initialRoute = initialRoute
cardScreenController = cardScreenController
)
}
}
Log.i("MainActivity", "Activity onCreated")
handleImportIntent(intent)
}
override fun onPause() {
@@ -102,71 +88,6 @@ class MainActivity : ComponentActivity() {
}
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
initialRoute = getInitialRouteFromIntent(intent)
// Optionally, you may want to trigger navigation here if needed
handleImportIntent(intent)
}
private fun handleImportIntent(intent: Intent?) {
val importUri = extractVitalWearImportUri(intent) ?: return
val application = applicationContext as VBHelper
lifecycleScope.launch(Dispatchers.IO) {
val result = runCatching {
contentResolver.openInputStream(importUri)?.use { inputStream ->
val character = Character.parseFrom(inputStream)
VitalWearCharacterImporter(application.container.db).importCharacter(character)
} ?: VitalWearCharacterImporter.ImportResult(
success = false,
message = "VitalWear import file could not be opened."
)
}.getOrElse {
VitalWearCharacterImporter.ImportResult(
success = false,
message = "VitalWear import failed: ${it.message ?: "Unknown error"}"
)
}
runOnUiThread {
Toast.makeText(this@MainActivity, result.message, Toast.LENGTH_LONG).show()
}
}
}
private fun extractVitalWearImportUri(intent: Intent?): Uri? {
if (intent == null) {
return null
}
val isVitalWearImport = intent.type == VITALWEAR_CHARACTER_MIME
if (!isVitalWearImport) {
return null
}
return when (intent.action) {
Intent.ACTION_SEND -> {
@Suppress("DEPRECATION")
intent.getParcelableExtra(Intent.EXTRA_STREAM)
}
Intent.ACTION_VIEW -> intent.data
else -> null
}
}
private fun getInitialRouteFromIntent(intent: Intent?): String? {
if (intent == null) return null
val data = intent.data
if (intent.action == Intent.ACTION_VIEW && data != null) {
if (data.scheme == "vbhelper" && data.host == "auth") {
return "Battle"
}
}
return null
}
@Composable
private fun MainApplication(
scanScreenController: ScanScreenControllerImpl,
@@ -176,8 +97,7 @@ class MainActivity : ComponentActivity() {
storageScreenController: StorageScreenControllerImpl,
homeScreenController: HomeScreenControllerImpl,
spriteViewerController: SpriteViewerControllerImpl,
cardScreenController: CardScreenControllerImpl,
initialRoute: String? = null
cardScreenController: CardScreenControllerImpl
) {
AppNavigation(
applicationNavigationHandlers = AppNavigationHandlers(
@@ -189,12 +109,7 @@ class MainActivity : ComponentActivity() {
homeScreenController,
spriteViewerController,
cardScreenController
),
initialRoute = initialRoute
)
)
}
companion object {
private const val VITALWEAR_CHARACTER_MIME = "application/x-vitalwear-character"
}
}
@@ -1,46 +0,0 @@
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
}
@@ -1,400 +0,0 @@
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
}
}
@@ -1,162 +0,0 @@
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")
}
}
}
@@ -1,48 +0,0 @@
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)
}
}
@@ -1,10 +0,0 @@
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"
}
@@ -1,140 +0,0 @@
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
}
}
}
}
@@ -1,77 +0,0 @@
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" }
}
}
}
@@ -1,122 +0,0 @@
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"
}
}
}
@@ -1,41 +0,0 @@
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)
}
}
}
}
}
@@ -1,21 +0,0 @@
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}")
}
}
}
@@ -1,23 +0,0 @@
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()
}
}
@@ -1,42 +0,0 @@
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")
}
}
}
}
@@ -1,32 +0,0 @@
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),
)
}
}
@@ -1,14 +0,0 @@
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
}
@@ -1,16 +0,0 @@
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)
}
@@ -1,13 +0,0 @@
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"
}
}
@@ -1,35 +0,0 @@
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,13 +2,11 @@ package com.github.nacabaro.vbhelper.daos
import androidx.room.Dao
import androidx.room.Query
import androidx.room.RewriteQueriesToDropUnusedColumns
import com.github.nacabaro.vbhelper.dtos.CharacterDtos
import kotlinx.coroutines.flow.Flow
@Dao
@RewriteQueriesToDropUnusedColumns
interface AdventureDao {
@Query("""
INSERT INTO Adventure (characterId, originalDuration, finishesAdventure)
@@ -13,18 +13,12 @@ interface CardDao {
@Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun insertNewCard(card: Card): Long
@Query("SELECT * FROM Card")
fun getAllCards(): List<Card>
@Query("SELECT * FROM Card WHERE cardId = :id")
fun getCardByCardId(id: Int): List<Card>
@Query("SELECT * FROM Card WHERE id = :id")
fun getCardById(id: Long): Card?
@Query("SELECT * FROM Card WHERE LOWER(name) = LOWER(:name) LIMIT 1")
fun getCardByName(name: String): Card?
@Query(
"""
SELECT ca.*
@@ -36,18 +30,6 @@ interface CardDao {
)
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")
suspend fun renameCard(id: Int, newName: String)
@@ -2,13 +2,11 @@ package com.github.nacabaro.vbhelper.daos
import androidx.room.Dao
import androidx.room.Query
import androidx.room.RewriteQueriesToDropUnusedColumns
import com.github.cfogrady.vbnfc.data.NfcCharacter
import com.github.nacabaro.vbhelper.dtos.CharacterDtos
import kotlinx.coroutines.flow.Flow
@Dao
@RewriteQueriesToDropUnusedColumns
interface CardFusionsDao {
@Query("""
INSERT INTO
@@ -24,9 +24,6 @@ interface CardProgressDao {
)
fun getCardProgress(cardId: Long): Flow<Int>
@Query("SELECT currentStage FROM CardProgress WHERE cardId = :cardId LIMIT 1")
fun getCardProgressSync(cardId: Long): Int?
@Insert
fun insertCardProgress(cardProgress: CardProgress)
}
@@ -1,17 +0,0 @@
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)
}
@@ -1,7 +0,0 @@
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,7 +5,6 @@ import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Upsert
import androidx.room.RewriteQueriesToDropUnusedColumns
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.BECharacterData
@@ -17,7 +16,6 @@ import com.github.nacabaro.vbhelper.dtos.CharacterDtos
import kotlinx.coroutines.flow.Flow
@Dao
@RewriteQueriesToDropUnusedColumns
interface UserCharacterDao {
@Insert
fun insertCharacterData(characterData: UserCharacter): Long
@@ -57,22 +55,6 @@ interface UserCharacterDao {
)
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(
"""
SELECT
@@ -133,9 +115,6 @@ interface UserCharacterDao {
@Query("SELECT * FROM VBCharacterData WHERE id = :id")
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")
fun getSpecialMissions(id: Long): Flow<List<SpecialMissions>>
@@ -1,17 +0,0 @@
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,8 +2,6 @@ package com.github.nacabaro.vbhelper.database
import androidx.room.Database
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.CardAdventureDao
import com.github.nacabaro.vbhelper.daos.CharacterDao
@@ -15,10 +13,6 @@ import com.github.nacabaro.vbhelper.daos.ItemDao
import com.github.nacabaro.vbhelper.daos.SpecialMissionDao
import com.github.nacabaro.vbhelper.daos.SpriteDao
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.CardCharacter
import com.github.nacabaro.vbhelper.domain.card.Card
@@ -35,13 +29,10 @@ 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.VBCharacterData
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
@Database(
version = 3,
exportSchema = false,
version = 1,
entities = [
Card::class,
CardProgress::class,
@@ -59,10 +50,7 @@ import com.github.nacabaro.vbhelper.domain.items.Items
Items::class,
Adventure::class,
Background::class,
PossibleTransformations::class,
ValidatedCardEntity::class,
VitalWearCharacterSettings::class,
CharacterTransferPolicy::class
PossibleTransformations::class
]
)
abstract class AppDatabase : RoomDatabase() {
@@ -77,35 +65,4 @@ abstract class AppDatabase : RoomDatabase() {
abstract fun specialMissionDao(): SpecialMissionDao
abstract fun cardAdventureDao(): CardAdventureDao
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,13 +3,9 @@ package com.github.nacabaro.vbhelper.di
import com.github.nacabaro.vbhelper.database.AppDatabase
import com.github.nacabaro.vbhelper.source.CurrencyRepository
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 {
val db: AppDatabase
val dataStoreSecretsRepository: DataStoreSecretsRepository
val currencyRepository: CurrencyRepository
val validatedCardManager: ValidatedCardManager
val companionLogService: CompanionLogService
}
@@ -8,8 +8,6 @@ import com.github.nacabaro.vbhelper.database.AppDatabase
import com.github.nacabaro.vbhelper.di.AppContainer
import com.github.nacabaro.vbhelper.source.CurrencyRepository
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.proto.Secrets
@@ -32,7 +30,6 @@ class DefaultAppContainer(private val context: Context) : AppContainer {
klass = AppDatabase::class.java,
"internalDb"
)
.addMigrations(AppDatabase.MIGRATION_1_2)
.createFromAsset("items.db")
.build()
}
@@ -40,11 +37,5 @@ class DefaultAppContainer(private val context: Context) : AppContainer {
override val dataStoreSecretsRepository = DataStoreSecretsRepository(context.secretsStore)
override val currencyRepository = CurrencyRepository(context.currencyStore)
override val validatedCardManager by lazy {
ValidatedCardManager(db.validatedCardDao())
}
override val companionLogService = CompanionLogService()
}
@@ -2,18 +2,10 @@ package com.github.nacabaro.vbhelper.di
import DefaultAppContainer
import android.app.Application
import com.github.nacabaro.vbhelper.companion.validation.ValidatedCardManager
import com.github.nacabaro.vbhelper.companion.logs.CompanionLogService
class VBHelper : Application() {
lateinit var container: DefaultAppContainer
val validatedCardManager: ValidatedCardManager
get() = container.validatedCardManager
val companionLogService: CompanionLogService
get() = container.companionLogService
override fun onCreate() {
super.onCreate()
container = DefaultAppContainer(applicationContext)
@@ -1,7 +1,6 @@
package com.github.nacabaro.vbhelper.domain.card
import androidx.room.Entity
import androidx.room.Index
import androidx.room.ForeignKey
import androidx.room.PrimaryKey
@@ -13,8 +12,7 @@ import androidx.room.PrimaryKey
childColumns = ["cardId"],
onDelete = ForeignKey.CASCADE
)
],
indices = [Index(value = ["cardId"])]
]
)
data class Background (
@PrimaryKey(autoGenerate = true) val id: Long,
@@ -22,28 +20,4 @@ data class Background (
val background: ByteArray,
val backgroundWidth: 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,34 +14,4 @@ data class Card(
val name: String,
val stageCount: Int,
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,7 +1,6 @@
package com.github.nacabaro.vbhelper.domain.card
import androidx.room.Entity
import androidx.room.Index
import androidx.room.ForeignKey
import androidx.room.PrimaryKey
@@ -19,10 +18,6 @@ import androidx.room.PrimaryKey
childColumns = ["cardId"],
onDelete = ForeignKey.CASCADE
)
],
indices = [
Index(value = ["characterId"]),
Index(value = ["cardId"])
]
)
data class CardAdventure(
@@ -1,7 +1,6 @@
package com.github.nacabaro.vbhelper.domain.card
import androidx.room.Entity
import androidx.room.Index
import androidx.room.ForeignKey
import androidx.room.PrimaryKey
import com.github.cfogrady.vbnfc.data.NfcCharacter
@@ -21,10 +20,6 @@ import com.github.nacabaro.vbhelper.domain.characters.Sprite
childColumns = ["spriteId"],
onDelete = ForeignKey.CASCADE
)
],
indices = [
Index(value = ["cardId"]),
Index(value = ["spriteId"])
]
)
@@ -46,42 +41,4 @@ data class CardCharacter (
val nameSprite: ByteArray,
val nameWidth: 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,7 +1,6 @@
package com.github.nacabaro.vbhelper.domain.card
import androidx.room.Entity
import androidx.room.Index
import androidx.room.ForeignKey
import androidx.room.PrimaryKey
import com.github.cfogrady.vbnfc.data.NfcCharacter
@@ -20,10 +19,6 @@ import com.github.cfogrady.vbnfc.data.NfcCharacter
childColumns = ["toCharaId"],
onDelete = ForeignKey.CASCADE
)
],
indices = [
Index(value = ["fromCharaId"]),
Index(value = ["toCharaId"])
]
)
data class CardFusions(
@@ -1,7 +1,6 @@
package com.github.nacabaro.vbhelper.domain.card
import androidx.room.Entity
import androidx.room.Index
import androidx.room.ForeignKey
import androidx.room.PrimaryKey
@@ -19,10 +18,6 @@ import androidx.room.PrimaryKey
childColumns = ["toCharaId"],
onDelete = ForeignKey.CASCADE
)
],
indices = [
Index(value = ["charaId"]),
Index(value = ["toCharaId"])
]
)
data class PossibleTransformations (
@@ -20,48 +20,4 @@ data class Sprite(
val spriteDodge: ByteArray,
val width: 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
}
}
)
@@ -1,32 +0,0 @@
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,7 +1,6 @@
package com.github.nacabaro.vbhelper.domain.device_data
import androidx.room.Entity
import androidx.room.Index
import androidx.room.ForeignKey
import androidx.room.PrimaryKey
import com.github.cfogrady.vbnfc.vb.SpecialMission
@@ -14,8 +13,7 @@ import com.github.cfogrady.vbnfc.vb.SpecialMission
childColumns = ["characterId"],
onDelete = ForeignKey.CASCADE
)
],
indices = [Index(value = ["characterId"])]
]
)
data class SpecialMissions (
@PrimaryKey(autoGenerate = true) var id: Long = 0,
@@ -1,7 +1,6 @@
package com.github.nacabaro.vbhelper.domain.device_data
import androidx.room.Entity
import androidx.room.Index
import androidx.room.ForeignKey
import androidx.room.PrimaryKey
import com.github.nacabaro.vbhelper.domain.card.CardCharacter
@@ -20,10 +19,6 @@ import com.github.nacabaro.vbhelper.domain.card.CardCharacter
childColumns = ["stageId"],
onDelete = ForeignKey.CASCADE
)
],
indices = [
Index(value = ["monId"]),
Index(value = ["stageId"])
]
)
data class TransformationHistory (
@@ -1,31 +0,0 @@
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,7 +1,6 @@
package com.github.nacabaro.vbhelper.domain.device_data
import androidx.room.Entity
import androidx.room.Index
import androidx.room.ForeignKey
import androidx.room.PrimaryKey
import com.github.cfogrady.vbnfc.data.NfcCharacter
@@ -16,8 +15,7 @@ import com.github.nacabaro.vbhelper.domain.card.CardCharacter
childColumns = ["charId"],
onDelete = ForeignKey.CASCADE
)
],
indices = [Index(value = ["charId"])]
]
)
/**
* UserCharacter represents and instance of a character. The charId should map to a Character
@@ -1,13 +0,0 @@
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,7 +1,6 @@
package com.github.nacabaro.vbhelper.domain.device_data
import androidx.room.Entity
import androidx.room.Index
import androidx.room.ForeignKey
import androidx.room.PrimaryKey
@@ -13,8 +12,7 @@ import androidx.room.PrimaryKey
childColumns = ["charId"],
onDelete = ForeignKey.CASCADE
)
],
indices = [Index(value = ["charId"])]
]
)
data class VitalsHistory (
@PrimaryKey(autoGenerate = true) val id: Long = 0,
@@ -1,7 +0,0 @@
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,35 +9,7 @@ object CardDtos {
val logoHeight: Int,
val totalCharacters: 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 (
val characterName: ByteArray,
@@ -51,67 +23,11 @@ object CardDtos {
val characterDp: Int,
val characterHp: 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 (
val cardIcon: ByteArray,
val cardIconWidth: 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,75 +33,7 @@ object CharacterDtos {
val isBemCard: Boolean,
val isInAdventure: 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(
val cardId: Long,
@@ -118,39 +50,6 @@ object CharacterDtos {
val spriteHeight: Int,
val monIndex: Int,
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(
@@ -167,76 +66,14 @@ object CharacterDtos {
val baseAp: Int,
val stage: Int,
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(
val id: Long,
val spriteIdle: ByteArray,
val spriteWidth: 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(
var id: Long = 0,
@@ -262,67 +99,7 @@ object CharacterDtos {
val isBemCard: Boolean,
val finishesAdventure: 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(
val charaId: Long,
@@ -337,45 +114,7 @@ object CharacterDtos {
val requiredWinRate: Int,
val changeTimerHours: 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(
val charaId: Long,
@@ -385,33 +124,5 @@ object CharacterDtos {
val spriteHeight: Int,
val discoveredOn: Long?,
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,7 +54,6 @@ data class AppNavigationHandlers(
@Composable
fun AppNavigation(
applicationNavigationHandlers: AppNavigationHandlers,
initialRoute: String? = null
) {
val navController = rememberNavController()
@@ -65,7 +64,7 @@ fun AppNavigation(
) { contentPadding ->
NavHost(
navController = navController,
startDestination = initialRoute ?: NavigationItems.Home.route,
startDestination = NavigationItems.Home.route,
enterTransition = {
fadeIn(
animationSpec = tween(200)
@@ -30,19 +30,12 @@ fun BottomNavigationBar(navController: NavController) {
label = { Text(text = stringResource(item.label)) },
selected = currentRoute == item.route,
onClick = {
if (item == NavigationItems.Home) {
navController.navigate(NavigationItems.Home.route) {
popUpTo(0) { inclusive = false }
launchSingleTop = true
}
} else {
navController.navigate(item.route) {
popUpTo(navController.graph.startDestinationId) { saveState = true }
launchSingleTop = true
restoreState = true
}
}
}
)
}
}
@@ -1,15 +1,10 @@
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.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
//import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size
@@ -33,6 +28,10 @@ import androidx.compose.ui.unit.sp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.text.font.FontWeight
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.ui.platform.LocalContext
import androidx.compose.runtime.LaunchedEffect
@@ -52,6 +51,8 @@ import android.content.Intent
import android.net.Uri
import android.media.MediaPlayer
import android.os.Environment
//import androidx.compose.animation.core.animateFloatAsState
//import androidx.compose.animation.core.tween
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import androidx.compose.runtime.rememberCoroutineScope
@@ -71,8 +72,13 @@ import com.github.nacabaro.vbhelper.battle.AnimatedSpriteImage
import com.github.nacabaro.vbhelper.battle.HitEffectOverlay
import com.github.nacabaro.vbhelper.battle.BattleAuthContainer
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.collect
import androidx.compose.foundation.lazy.LazyColumn
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.draw.alpha
import androidx.compose.ui.graphics.Shadow
@@ -80,20 +86,24 @@ import androidx.compose.ui.text.TextStyle
import androidx.compose.foundation.Image
import androidx.compose.ui.graphics.asImageBitmap
import android.graphics.BitmapFactory
//import android.os.Environment
import java.io.File
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.foundation.layout.width
import com.github.nacabaro.vbhelper.di.VBHelper
import kotlinx.coroutines.Dispatchers
//import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.compose.material3.AlertDialog
/*@Composable
@Composable
fun isLandscapeMode(): Boolean {
val configuration = LocalConfiguration.current
return configuration.screenWidthDp > configuration.screenHeightDp
}*/
}
@Composable
fun getLandscapeModifier(): Modifier {
@@ -1725,7 +1735,7 @@ fun BattlesScreen() {
val currentCharacter = activeUserCharacter
if (currentCharacter != null && canBattle && playerBattleType != null) {
try {
RetrofitHelper().getOpponents(context, playerBattleType) { opponents ->
RetrofitHelper().getOpponents(context, playerBattleType!!) { opponents ->
try {
// Create a new list to trigger UI recomposition
opponentsList = ArrayList(opponents.opponentsList)
@@ -2124,8 +2134,8 @@ fun BattlesScreen() {
// Create APIBattleCharacter from database character
val playerCharacter = APIBattleCharacter(
name = "Player Character", // We could get this from the database if needed
namekey = "player_digimon", // Name key for the character (functional lookup key, not shown)
name = "Player Digimon", // We could get this from the database if needed
namekey = "player_digimon", // Name key for the character
charaId = formattedCardId, // Use the formatted card ID for sprite loading
stage = characterData.stage,
attribute = characterData.attribute.ordinal, // Convert enum to int
@@ -2232,10 +2242,10 @@ fun BattlesScreen() {
) {
// Show active character info
activeUserCharacter?.let { character ->
Text("Active character:")
Text("Active Digimon:")
Text("Stage: ${character.stage}")
activeCardId?.let { cardId ->
Text("Character ID: $cardId", fontSize = 14.sp, color = Color.Blue, fontWeight = FontWeight.Bold)
Text("Digimon ID: $cardId", fontSize = 14.sp, color = Color.Blue, fontWeight = FontWeight.Bold)
}
Spacer(modifier = Modifier.height(16.dp))
@@ -2336,7 +2346,7 @@ fun BattlesScreen() {
textAlign = TextAlign.Center)
}
} else {
Text("Your character must be at least Stage 2 to battle",
Text("Your Digimon must be at least Stage 2 to battle",
fontSize = 16.sp,
color = Color.Red,
textAlign = TextAlign.Center)
@@ -2405,7 +2415,7 @@ fun BattlesScreen() {
// Also check winner field if it's not empty
val playerWonFromWinner = activeCardId?.let { cardId ->
val winner = apiResult.winner
val winner = apiResult.winner ?: ""
if (winner.isNotEmpty()) {
if (winner.contains("|")) {
// Pipe-separated format: first part is the winner
@@ -2432,7 +2442,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")
// Store winner name for display (will be updated in cleanup call if available)
winnerName = apiResult.winner
winnerName = apiResult.winner ?: if (playerWon) "You" else "Opponent"
isWinnerLoaded = true
// Then send the cleanup call - this will have the actual winner name
@@ -2455,7 +2465,7 @@ fun BattlesScreen() {
// 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)
val opponentName = selectedOpponent?.name ?: ""
val winner = cleanupResult.winner
val winner = cleanupResult.winner ?: ""
// Primary: HP-based determination (most reliable)
// If opponentHP <= 0, opponent is dead = player won
@@ -2,7 +2,6 @@ package com.github.nacabaro.vbhelper.screens.homeScreens
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
@@ -41,8 +40,6 @@ import com.github.nacabaro.vbhelper.source.StorageRepository
import com.github.nacabaro.vbhelper.R
import com.github.nacabaro.vbhelper.dtos.CardDtos
import com.github.nacabaro.vbhelper.source.CardRepository
import com.github.nacabaro.vbhelper.source.VitalWearCharacterExporter
import android.widget.Toast
import com.github.nacabaro.vbhelper.utils.BitmapData
import kotlinx.coroutines.flow.flowOf
import kotlin.collections.emptyList
@@ -139,7 +136,6 @@ fun HomeScreen(
Text(text = stringResource(R.string.adventure_empty_state))
}
} else {
Column(modifier = Modifier.padding(top = contentPadding.calculateTopPadding())) {
val cardIcon = BitmapData(
bitmap = cardIconData!!.cardIcon,
width = cardIconData!!.cardIconWidth,
@@ -151,7 +147,7 @@ fun HomeScreen(
activeMon = activeMon!!,
beData = beData!!,
transformationHistory = transformationHistory,
contentPadding = PaddingValues(0.dp),
contentPadding = contentPadding,
cardIcon = cardIcon
)
} else if (!activeMon!!.isBemCard && activeMon!!.characterType == DeviceType.BEDevice && beData != null) {
@@ -159,7 +155,7 @@ fun HomeScreen(
activeMon = activeMon!!,
beData = beData!!,
transformationHistory = transformationHistory,
contentPadding = PaddingValues(0.dp),
contentPadding = contentPadding,
cardIcon = cardIcon
)
} else if (vbData != null) {
@@ -167,7 +163,7 @@ fun HomeScreen(
activeMon = activeMon!!,
vbData = vbData!!,
transformationHistory = transformationHistory,
contentPadding = PaddingValues(0.dp),
contentPadding = contentPadding,
specialMissions = vbSpecialMissions,
homeScreenController = homeScreenController,
onClickCollect = { item, currency ->
@@ -177,29 +173,6 @@ fun HomeScreen(
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")
}
}
}
}
@@ -248,3 +221,5 @@ fun HomeScreen(
}
}
}
@@ -45,12 +45,11 @@ class HomeScreenControllerImpl(
.clearSpecialMission(missionId)
if (missionStatus.status == SpecialMission.Status.COMPLETED) {
val allItems = database.itemDao().getAllItems().first()
if (allItems.isEmpty()) {
onCleared(null, null)
return@launch
}
val randomItem = allItems.random()
val randomItem = database
.itemDao()
.getAllItems()
.first()
.random()
val randomItemAmount = (Random.nextFloat() * 5).roundToInt()
@@ -1,241 +0,0 @@
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(
icon = R.drawable.baseline_swords_24,
textValue = when {
activeMon.currentPhaseBattlesWon + activeMon.currentPhaseBattlesLost == 0 -> "0.00 %"
activeMon.totalBattlesLost == 0 -> "0.00 %"
else -> {
val battleWinPercentage =
activeMon.currentPhaseBattlesWon.toFloat() / (activeMon.currentPhaseBattlesWon + activeMon.currentPhaseBattlesLost).toFloat()
@@ -150,7 +150,7 @@ fun VBDiMHomeScreen(
Locale.getDefault(),
"%.2f",
battleWinPercentage * 100
) + " %"
) + " %" // Specify locale
}
},
definition = stringResource(R.string.home_vbdim_current_phase_win),
@@ -193,9 +193,9 @@ fun VBDiMHomeScreen(
onClickMission = { missionId ->
selectedSpecialMissionId = missionId
},
onClickCollect = { missionId ->
onClickCollect = {
homeScreenController
.clearSpecialMission(missionId, onClickCollect)
.clearSpecialMission(selectedSpecialMissionId, onClickCollect)
}
)
}
@@ -4,9 +4,9 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.PrimaryTabRow
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Tab
import androidx.compose.material3.TabRow
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
@@ -34,7 +34,7 @@ fun ItemsScreen(
topBar = {
Column {
TopBanner(text = stringResource(R.string.items_title))
PrimaryTabRow(
TabRow(
selectedTabIndex = selectedTabItem,
modifier = Modifier
) {
@@ -42,11 +42,6 @@ fun ChooseConnectOption(
.fillMaxSize()
.padding(contentPadding)
) {
Text(
text = "VB, VH, VBBE",
fontSize = 14.sp,
modifier = Modifier.padding(bottom = 24.dp)
)
ScanButton(
text = stringResource(R.string.scan_vb_to_app),
disabled = onClickRead == null,
@@ -1,8 +0,0 @@
package com.github.nacabaro.vbhelper.screens.scanScreen
enum class DetectedTransport {
UNKNOWN,
NFC_A,
ISO_DEP,
}
@@ -9,6 +9,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.navigation.NavController
import androidx.navigation.compose.rememberNavController
@@ -44,8 +45,14 @@ fun ScanScreen(
val context = LocalContext.current
LaunchedEffect(characterId) {
LaunchedEffect(storageRepository) {
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) {
nfcCharacter = scanScreenController.characterToNfc(characterId)
}
@@ -82,15 +89,18 @@ fun ScanScreen(
}
)
} else {
val secretsNotInitialized = stringResource(id = R.string.scan_secrets_not_initialized)
val secretsNotImported = stringResource(id = R.string.scan_secrets_not_imported)
ChooseConnectOption(
onClickRead = when {
!launchedFromHomeScreen -> null
else -> {
{
if(secrets == null) {
Toast.makeText(context, context.getString(R.string.scan_secrets_not_initialized), Toast.LENGTH_SHORT).show()
} else if(secrets?.isMissingSecrets() == true) {
Toast.makeText(context, context.getString(R.string.scan_secrets_not_imported), Toast.LENGTH_SHORT).show()
if (secrets == null) {
Toast.makeText(context, secretsNotInitialized, Toast.LENGTH_SHORT).show()
} else if (secrets?.isMissingSecrets() == true) {
Toast.makeText(context, secretsNotImported, Toast.LENGTH_SHORT).show()
} else {
readingScreen = true // kicks off nfc adapter in DisposableEffect
}
@@ -101,10 +111,10 @@ fun ScanScreen(
nfcCharacter == null -> null
else -> {
{
if(secrets == null) {
Toast.makeText(context, context.getString(R.string.scan_secrets_not_initialized), Toast.LENGTH_SHORT).show()
} else if(secrets?.isMissingSecrets() == true) {
Toast.makeText(context, context.getString(R.string.scan_secrets_not_imported), Toast.LENGTH_SHORT).show()
if (secrets == null) {
Toast.makeText(context, secretsNotInitialized, Toast.LENGTH_SHORT).show()
} else if (secrets?.isMissingSecrets() == true) {
Toast.makeText(context, secretsNotImported, Toast.LENGTH_SHORT).show()
} else {
writingScreen = true // kicks off nfc adapter in DisposableEffect
}
@@ -3,7 +3,6 @@ package com.github.nacabaro.vbhelper.screens.scanScreen
import android.content.Intent
import android.nfc.NfcAdapter
import android.nfc.Tag
import android.nfc.tech.IsoDep
import android.nfc.tech.NfcA
import android.os.Bundle
import android.provider.Settings
@@ -16,16 +15,12 @@ import com.github.cfogrady.vbnfc.be.BENfcCharacter
import com.github.cfogrady.vbnfc.data.NfcCharacter
import com.github.cfogrady.vbnfc.vb.VBNfcCharacter
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.screens.scanScreen.converters.FromNfcConverter
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.isMissingSecrets
import com.github.nacabaro.vbhelper.source.proto.Secrets
import com.github.nacabaro.vbhelper.transfer.hce.VitalWearHceReaderClient
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.stateIn
@@ -37,10 +32,8 @@ class ScanScreenControllerImpl(
private val componentActivity: ComponentActivity,
private val registerActivityLifecycleListener: (String, ActivityLifecycleListener)->Unit,
private val unregisterActivityLifecycleListener: (String)->Unit,
private val database: AppDatabase,
): ScanScreenController {
private var lastScannedCharacter: NfcCharacter? = null
private var pendingExportCharacterId: Long? = null
private val nfcAdapter: NfcAdapter
init {
@@ -53,46 +46,16 @@ class ScanScreenControllerImpl(
}
override fun onClickRead(secrets: Secrets, onComplete: ()->Unit, onMultipleCards: (List<Card>) -> Unit) {
handleTag(
secrets,
handlerFunc = { tagCommunicator ->
try {
handleTag(secrets) { tagCommunicator ->
val character = tagCommunicator.receiveCharacter()
val resultMessage = characterFromNfc(character) { cards, nfcCharacter ->
lastScannedCharacter = nfcCharacter
onMultipleCards(cards)
}
onComplete.invoke()
resultMessage
} catch (e: Exception) {
Log.e("NFC_READ", "Error reading character from NFC", e)
componentActivity.runOnUiThread {
Toast.makeText(componentActivity, componentActivity.getString(R.string.scan_error_generic) + ": " + (e.message ?: e.javaClass.simpleName), Toast.LENGTH_LONG).show()
}
onComplete.invoke()
componentActivity.getString(R.string.scan_error_generic)
}
},
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() {
@@ -112,45 +75,29 @@ class ScanScreenControllerImpl(
unregisterActivityLifecycleListener.invoke(key)
}
private fun handleTag(
secrets: Secrets,
handlerFunc: (TagCommunicator) -> String,
hceHandler: ((IsoDep) -> Unit)? = null,
) {
// EXTRACTED DIRECTLY FROM EXAMPLE APP
private fun handleTag(secrets: Secrets, handlerFunc: (TagCommunicator)->String) {
if (!nfcAdapter.isEnabled) {
showWirelessSettings()
} else {
val options = Bundle()
// Work around for some broken Nfc firmware implementations that poll the card too fast
options.putInt(NfcAdapter.EXTRA_READER_PRESENCE_CHECK_DELAY, 250)
nfcAdapter.enableReaderMode(
componentActivity,
buildOnReadTag(secrets, handlerFunc, hceHandler),
NfcAdapter.FLAG_READER_NFC_A or NfcAdapter.FLAG_READER_SKIP_NDEF_CHECK,
nfcAdapter.enableReaderMode(componentActivity, buildOnReadTag(secrets, handlerFunc), NfcAdapter.FLAG_READER_NFC_A or NfcAdapter.FLAG_READER_SKIP_NDEF_CHECK,
options
)
}
}
private fun buildOnReadTag(
secrets: Secrets,
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
// EXTRACTED DIRECTLY FROM EXAMPLE APP
private fun buildOnReadTag(secrets: Secrets, handlerFunc: (TagCommunicator)->String): (Tag)->Unit {
return { tag->
val nfcData = NfcA.get(tag)
if (nfcData == null) {
componentActivity.runOnUiThread {
Toast.makeText(componentActivity, componentActivity.getString(R.string.scan_tag_not_vb), Toast.LENGTH_SHORT).show()
}
} else {
}
nfcData.connect()
nfcData.use {
val tagCommunicator = TagCommunicator.getInstance(nfcData, secrets.getCryptographicTransformerMap())
@@ -161,8 +108,6 @@ class ScanScreenControllerImpl(
}
}
}
}
}
private fun checkSecrets() {
componentActivity.lifecycleScope.launch(Dispatchers.IO) {
@@ -179,16 +124,16 @@ class ScanScreenControllerImpl(
nfcCharacter: NfcCharacter,
onComplete: () -> Unit
) {
handleTag(
secrets,
handlerFunc = { tagCommunicator ->
handleTag(secrets) { tagCommunicator ->
try {
if (nfcCharacter is VBNfcCharacter) {
Log.d("SendCharacter", "VBNfcCharacter")
tagCommunicator.sendCharacter(nfcCharacter)
val castNfcCharacter: VBNfcCharacter = nfcCharacter
tagCommunicator.sendCharacter(castNfcCharacter)
} else if (nfcCharacter is BENfcCharacter) {
Log.d("SendCharacter", "BENfcCharacter")
tagCommunicator.sendCharacter(nfcCharacter)
val castNfcCharacter: BENfcCharacter = nfcCharacter
tagCommunicator.sendCharacter(castNfcCharacter)
}
onComplete.invoke()
componentActivity.getString(R.string.scan_sent_character_success)
@@ -196,12 +141,7 @@ class ScanScreenControllerImpl(
Log.e("TAG", e.stackTraceToString())
componentActivity.getString(R.string.scan_error_generic)
}
},
hceHandler = { _ ->
// Character was already sent in onClickCheckCard's HCE handler.
onComplete()
}
)
}
override fun onClickCheckCard(
@@ -209,42 +149,11 @@ class ScanScreenControllerImpl(
nfcCharacter: NfcCharacter,
onComplete: () -> Unit
) {
handleTag(
secrets,
handlerFunc = { tagCommunicator ->
handleTag(secrets) { tagCommunicator ->
tagCommunicator.prepareDIMForCharacter(nfcCharacter.dimId)
onComplete.invoke()
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)
Log.i("NFC_WRITE", "Character sent to VitalWear (id=$characterId), deletion deferred to WritingScreen")
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
@@ -264,8 +173,10 @@ class ScanScreenControllerImpl(
}
override suspend fun characterToNfc(characterId: Long): NfcCharacter {
pendingExportCharacterId = characterId
val nfcGenerator = ToNfcConverter(componentActivity = componentActivity)
val nfcGenerator = ToNfcConverter(
componentActivity = componentActivity
)
val character = nfcGenerator.characterToNfc(characterId)
Log.d("CharacterType", character.toString())
return character
@@ -1,51 +0,0 @@
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")
}
}
}
@@ -1,267 +0,0 @@
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 character 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,8 +38,6 @@ fun CreditsScreen(
SettingsEntry(title = "nacabaro", description = stringResource(R.string.credits_nacabaro_description)) { }
SettingsEntry(title = "lightheel", description = stringResource(R.string.credits_lightheel_description)) { }
SettingsEntry(title = "shvstrz", description = stringResource(R.string.credits_shvstrz_description)) { }
SettingsEntry(title = "redeyez", description = stringResource(R.string.credits_RedEyez_description)) { }
}
}
}
@@ -136,7 +136,7 @@ class SettingsScreenControllerImpl(
secrets = secretsImporter.importSecrets(it)
} catch (e: Exception) {
context.runOnUiThread {
Toast.makeText(context, "Secrets import failed. Please only select the official app APK.", Toast.LENGTH_SHORT).show()
Toast.makeText(context, "Secrets import failed. Please only select the official Vital Arena App 2.1.0 APK.", Toast.LENGTH_SHORT).show()
}
return@launch
}
@@ -33,7 +33,6 @@ import com.github.nacabaro.vbhelper.di.VBHelper
import com.github.nacabaro.vbhelper.navigation.NavigationItems
import com.github.nacabaro.vbhelper.screens.adventureScreen.AdventureScreenControllerImpl
import com.github.nacabaro.vbhelper.source.StorageRepository
import com.github.nacabaro.vbhelper.source.VitalWearCharacterExporter
import com.github.nacabaro.vbhelper.utils.BitmapData
import androidx.compose.ui.res.stringResource
import com.github.nacabaro.vbhelper.R
@@ -1,28 +0,0 @@
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
}
}
}
@@ -1,55 +0,0 @@
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)
}
}
}
@@ -1,118 +0,0 @@
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"
}
}
@@ -1,163 +0,0 @@
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()
}
}
@@ -1,38 +0,0 @@
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
}
}
@@ -1,106 +0,0 @@
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
}
@@ -1,112 +0,0 @@
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
}
}
}
@@ -1,67 +0,0 @@
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
}
@@ -1,31 +0,0 @@
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
}
}
@@ -1,437 +0,0 @@
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) }
}
@@ -1,40 +0,0 @@
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,27 +13,7 @@ data class BitmapData (
val bitmap: ByteArray,
val width: 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(
val imageBitmap: ImageBitmap,
@@ -1,14 +1,6 @@
package com.github.nacabaro.vbhelper.utils
enum class DeviceType {
VBDevice, // Bandai Vital Bracelet
BEDevice, // Bandai BE Bracelet
VitalWear // WearOS VitalWear app
}
fun detectDeviceType(deviceName: String): DeviceType = when {
deviceName.startsWith("VitalWear", ignoreCase = true) || deviceName.startsWith("VW-", ignoreCase = true) -> DeviceType.VitalWear
deviceName.startsWith("BE-", ignoreCase = true) -> DeviceType.BEDevice
deviceName.startsWith("VB-", ignoreCase = true) -> DeviceType.VBDevice
else -> DeviceType.VBDevice // Default/fallback, can be refined
VBDevice,
BEDevice
}
@@ -1,58 +0,0 @@
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;
}
+32 -47
View File
@@ -17,21 +17,21 @@
<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="adventure_empty_state">何もありません。</string>
<string name="home_title">VB Helper</string>
<string name="scan_secrets_not_imported">"APKの読み込みがされていません。設定から読み込をして下さい。 "</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">公式アプリのAPKを読み込みして下さい。</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="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>
@@ -42,7 +42,7 @@
<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_prepare_device">バイタルブレスを準備してください。</string>
<string name="read_character_go_to_connect">準備が出来たら転送を押してください。</string>
<string name="read_character_confirm">転送</string>
<string name="card_adventure_missions_title">アドベンチャーミッション</string>
@@ -54,32 +54,31 @@
<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_send_to_watch">転送</string>
<string name="storage_set_active">選択</string>
<string name="storage_send_on_adventure">アドベンチャーへ出発</string>
<string name="scan_no_nfc_on_device">NFC機能が見つかりません。</string>
<string name="scan_tag_not_vb">検出されたタグはバイタルブレスでは使用出来ません。</string>
<string name="settings_section_nfc">NFC通信</string>
<string name="settings_section_dim_bem">DiM / BEm管理</string>
<string name="settings_section_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">公式アプリのAPKファイルを読み込みします。</string>
<string name="settings_import_card_title">カードのインポート</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_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="beta_warning_message_main">" このアプリは現在アルファ版です。今後のアップデートにより保存されているデータの全てが削除される可能性がある為、大切なデータの保存はしないようにお願い致します。ご不便をおかけして申し訳ございません。 "</string>
<string name="beta_warning_message_compatibility">" このアプリはオリジナルのVBおよびVHで動作するようになりました。 "</string>
<string name="beta_warning_message_thanks">" ご理解とご協力に感謝いたします。\n 開発チーム一同"</string>
<string name="home_vbdim_vitals">バイタル</string>
<string name="home_adventure_mission_finished">"1人のキャラクターがアドベンチャーミッションを完了しました。 "</string>
<string name="scan_secrets_not_initialized">"初期化が出来ませんでした。もう一度試して下さい。 "</string>
<string name="home_special_mission_delete_main">現在の進捗状況が失われます。本当にこのミッションを削除しますか?</string>
<string name="home_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>
@@ -91,26 +90,25 @@
<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_place_near_reader">バイタルブレス本体を近くに置いて下さい。</string>
<string name="action_cancel">キャンセル</string>
<string name="card_view_discovered_characters">解放済みキャラクター</string>
<string name="dex_chara_icon_description">キャラクターアイコン</string>
<string name="dex_chara_name_icon_description">名前</string>
<string name="credits_section_reverse_engineering">プログラム解析</string>
<string name="credits_section_reverse_engineering">リバースエンジニアリング</string>
<string name="credits_section_app_development">アプリケーション開発</string>
<string name="credits_cyanic_description">ファームウェアの解析を行い、開発をサポートしてくれました。</string>
<string name="credits_cfogrady_description">vb-lib-nfc開発および本アプリの開発に協力して頂きました。</string>
<string name="credits_nacabaro_description">本アプリの開発をしました。</string>
<string name="credits_lightheel_description">本アプリのサーバー含む対戦機能を開発して頂きました。現在も開発を継続して頂いています。</string>
<string name="credits_shvstrz_description">本アプリのアイコンデザインをして頂きました。</string>
<string name="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_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>
@@ -121,10 +119,10 @@
<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_requirements">" Tr: %1$d; Bt: %2$d; Vr: %3$d; Wr: %4$d%%; Ct: %5$dh "</string>
<string name="dex_chara_adventure_level">AdvLvl %1$d</string>
<string name="storage_my_characters_title">キャラクターリスト</string>
<string name="storage_nothing_to_see_here">No Data</string>
<string name="storage_nothing_to_see_here">表示するデータはありません。</string>
<string name="storage_in_adventure_toast">このキャラクターは冒険中です。</string>
<string name="home_vbdim_next_timer">次のタイマー</string>
<string name="home_vbdim_total_battle_win">総勝率 %</string>
@@ -145,17 +143,4 @@
<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>
+7 -7
View File
@@ -43,13 +43,13 @@
Os segredos ainda não foram importados. Vá em Configurações e importe o APK.
</string>
<string name="scan_title">Escanear um VB</string>
<string name="scan_vb_to_app">VB para o app</string>
<string name="scan_app_to_vb">App para o VB</string>
<string name="scan_title">Escanear um Vital Bracelet</string>
<string name="scan_vb_to_app">Vital Bracelet para o app</string>
<string name="scan_app_to_vb">App para o Vital Bracelet</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 VB.</string>
<string name="scan_missing_secrets">" Segredos ausentes. Vá em Configurações e importe o APK do app oficial. "</string>
<string name="scan_tag_not_vb">A tag detectada não é do Vital Bracelet.</string>
<string name="scan_missing_secrets">" 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_error_generic">Ops!</string>
<string name="scan_sent_dim_success">DIM enviado com sucesso!</string>
@@ -64,7 +64,7 @@
<string name="settings_import_apk_title">Importar APK</string>
<string name="settings_import_apk_desc">
Importar segredos do APK do app oficial
Importar segredos do APK do Vital Arena 2.1.0
</string>
<string name="settings_import_card_title">Importar card</string>
@@ -106,7 +106,7 @@
Responsável pelo design do ícone do aplicativo em SVG.
</string>
<string name="action_place_near_reader">Aproxime o seu VB do leitor...</string>
<string name="action_place_near_reader">Aproxime o seu Vital Bracelet do leitor...</string>
<string name="action_cancel">Cancelar</string>
<string name="read_character_title">Ler personagem</string>
+9 -25
View File
@@ -43,13 +43,13 @@
Secrets not yet imported. Go to Settings and Import APK.
</string>
<string name="scan_title">Scan a VB</string>
<string name="scan_vb_to_app">Watch to VBH</string>
<string name="scan_app_to_vb">VBH to Watch</string>
<string name="scan_title">Scan a Vital Bracelet</string>
<string name="scan_vb_to_app">Vital Bracelet to App</string>
<string name="scan_app_to_vb">App to Vital Bracelet</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_missing_secrets">" Missing Secrets. Go to settings and import the official app APK. "</string>
<string name="scan_missing_secrets">" Missing Secrets. Go to settings and import Vital Arena APK. "</string>
<string name="scan_sent_character_success">Sent character successfully!</string>
<string name="scan_error_generic">Whoops</string>
<string name="scan_sent_dim_success">Sent DIM successfully!</string>
@@ -65,12 +65,12 @@
<string name="settings_import_apk_title">Import APK</string>
<string name="settings_import_apk_desc">
Import Secrets From the official app APK
Import Secrets From Vital Arena 2.1.0 APK
</string>
<string name="settings_import_card_title">Import card</string>
<string name="settings_import_card_desc">
Import DiM/BEm
Import DiM/BEm card file
</string>
<string name="settings_credits_title">Credits</string>
@@ -106,10 +106,8 @@
<string name="credits_shvstrz_description">
Designing the app icon in SVG.
</string>
<string name="credits_RedEyez_description">
Devout Debugger.
</string>
<string name="action_place_near_reader">Place your VB 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="read_character_title">Read character</string>
@@ -186,12 +184,11 @@
<string name="card_entry_delete">Delete card</string>
<string name="storage_my_characters_title">My characters</string>
<string name="storage_nothing_to_see_here">Storage is Empty</string>
<string name="storage_nothing_to_see_here">Nothing to see here</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_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_send_on_adventure">Send on adventure</string>
<string name="storage_delete_character">Delete character</string>
@@ -219,17 +216,4 @@
<string name="home_special_mission_delete_dismiss">Dismiss</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 VB</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 VB</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>
-6
View File
@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<cache-path
name="exports"
path="exports" />
</paths>
@@ -1,25 +0,0 @@
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))
}
}
@@ -1,58 +0,0 @@
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,
)
}
}
@@ -1,106 +0,0 @@
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,
)
}
}
+6 -2
View File
@@ -3,6 +3,10 @@ plugins {
alias(libs.plugins.android.application) apply false
alias(libs.plugins.kotlin.android) 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)
}
}
+7
View File
@@ -21,6 +21,13 @@ kotlin.code.style=official
# resources declared in the library itself and none from the library's dependencies,
# thereby reducing the size of the R class for that library
android.nonTransitiveRClass=true
android.defaults.buildfeatures.resvalues=true
android.sdk.defaultTargetSdkToCompileSdkIfUnset=false
android.enableAppCompileTimeRClass=false
android.usesSdkInManifest.disallowed=false
android.uniquePackageNames=false
android.dependency.useConstraints=true
android.r8.strictFullModeForKeepRules=false
android.r8.optimizedResourceShrinking=false
android.builtInKotlin=false
android.newDsl=false
+1 -19
View File
@@ -17,14 +17,6 @@ protobufJavalite = "4.35.1"
roomRuntime = "2.8.4"
vbNfcReader = "0.2.0-SNAPSHOT"
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]
androidx-compose-material = { module = "androidx.compose.material:material" }
@@ -50,23 +42,13 @@ 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-material3 = { group = "androidx.compose.material3", name = "material3" }
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" }
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" }
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]
android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", 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
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.1-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
-1
View File
@@ -17,7 +17,6 @@ dependencyResolutionManagement {
mavenLocal()
google()
mavenCentral()
maven { url = uri("https://jitpack.io") }
}
}