mirror of
https://github.com/nacabaro/vbhelper.git
synced 2026-07-30 00:31:54 +00:00
HCE Savepoint
This is HCE before companion merge
This commit is contained in:
parent
04ea584c4f
commit
6c257b2080
@ -73,12 +73,8 @@ protobuf {
|
||||
dependencies {
|
||||
implementation(libs.androidx.room.runtime)
|
||||
implementation(libs.vb.nfc.reader)
|
||||
// Temporarily commented out due to Lombok compilation issues in VB-DIM-Reader
|
||||
// implementation(libs.dim.reader)
|
||||
implementation(files("../../VB-DIM-Reader-2.0.0/build/libs/VB-DIM-Reader.jar"))
|
||||
//VB-DIM-Reader runtime dependency required by the local JAR
|
||||
implementation("at.favre.lib:bytes:1.5.0")
|
||||
|
||||
implementation(libs.dim.reader)
|
||||
implementation(libs.androidx.core.ktx)
|
||||
implementation(libs.androidx.room.ktx)
|
||||
implementation(libs.androidx.datastore.preferences)
|
||||
implementation(libs.androidx.navigation.compose)
|
||||
@ -94,13 +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.tiny.log)
|
||||
implementation(libs.tiny.log.impl)
|
||||
implementation(libs.slf4j.api)
|
||||
runtimeOnly(libs.slf4j.timber)
|
||||
|
||||
debugImplementation(libs.androidx.ui.tooling)
|
||||
debugImplementation(libs.androidx.ui.test.manifest)
|
||||
@ -129,7 +118,4 @@ dependencies {
|
||||
|
||||
// HTTP request logging
|
||||
implementation("com.squareup.okhttp3:logging-interceptor:4.11.0")
|
||||
|
||||
// In-app browser for OAuth login
|
||||
implementation("androidx.browser:browser:1.8.0")
|
||||
}
|
||||
@ -2,16 +2,8 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<permission
|
||||
android:name="com.github.nacabaro.vbhelper.permission.ACCESS_TRANSFER_SEEN"
|
||||
android:protectionLevel="signature" />
|
||||
<permission
|
||||
android:name="com.github.nacabaro.vbhelper.permission.ACCESS_CARD_DATA"
|
||||
android:protectionLevel="signature" />
|
||||
|
||||
<uses-permission android:name="android.permission.NFC" />
|
||||
<uses-feature android:name="android.hardware.nfc" android:required="true" />
|
||||
<uses-permission android:name="android.permission.VIBRATE" />
|
||||
<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" />
|
||||
@ -39,25 +31,6 @@
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/provider_paths" />
|
||||
</provider>
|
||||
<provider
|
||||
android:name=".transfer.CardImportProvider"
|
||||
android:authorities="com.github.nacabaro.vbhelper.cardimport"
|
||||
android:exported="true"
|
||||
android:readPermission="com.github.nacabaro.vbhelper.permission.ACCESS_CARD_DATA"
|
||||
android:writePermission="com.github.nacabaro.vbhelper.permission.ACCESS_CARD_DATA" />
|
||||
<provider
|
||||
android:name=".transfer.SharedTransferSeenProvider"
|
||||
android:authorities="com.github.nacabaro.vbhelper.transferseen"
|
||||
android:exported="true"
|
||||
android:readPermission="com.github.nacabaro.vbhelper.permission.ACCESS_TRANSFER_SEEN"
|
||||
android:writePermission="com.github.nacabaro.vbhelper.permission.ACCESS_TRANSFER_SEEN" />
|
||||
<activity android:name=".companion.card.CompanionImportCardActivity" />
|
||||
<activity android:name=".companion.card.CompanionValidateCardActivity" />
|
||||
<activity android:name=".companion.firmware.CompanionFirmwareImportActivity" />
|
||||
<activity android:name=".companion.logs.CompanionWatchLogsActivity" />
|
||||
<activity
|
||||
android:name=".companion.transfer.CompanionCharacterImportActivity"
|
||||
android:exported="false" />
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
@ -81,17 +54,12 @@
|
||||
<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>
|
||||
<service
|
||||
android:name=".companion.logs.WatchCommunicationService"
|
||||
android:exported="true"
|
||||
android:enabled="true">
|
||||
<intent-filter>
|
||||
<action android:name="com.google.android.gms.wearable.CHANNEL_EVENT" />
|
||||
<action android:name="com.google.android.gms.wearable.MESSAGE_RECEIVED" />
|
||||
<data android:scheme="wear" android:host="*" />
|
||||
<action android:name="android.intent.action.SEND" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:mimeType="application/x-vitalwear-character" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
</activity>
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
|
||||
@ -1,19 +1,19 @@
|
||||
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.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
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
|
||||
import com.github.nacabaro.vbhelper.navigation.NavigationItems
|
||||
import com.github.nacabaro.vbhelper.screens.homeScreens.HomeScreenControllerImpl
|
||||
import com.github.nacabaro.vbhelper.screens.itemsScreen.ItemsScreenControllerImpl
|
||||
import com.github.nacabaro.vbhelper.screens.scanScreen.ScanScreenControllerImpl
|
||||
@ -22,12 +22,15 @@ 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? by mutableStateOf(null)
|
||||
private var initialRoute: String? = null
|
||||
|
||||
private fun registerActivityLifecycleListener(key: String, activityLifecycleListener: ActivityLifecycleListener) {
|
||||
if( onActivityLifecycleListeners[key] != null) {
|
||||
@ -79,6 +82,7 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
|
||||
Log.i("MainActivity", "Activity onCreated")
|
||||
handleImportIntent(intent)
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
@ -101,25 +105,62 @@ class MainActivity : ComponentActivity() {
|
||||
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) {
|
||||
val isAppAuthCallback = data.scheme == "vbhelper" && data.host == "auth"
|
||||
val isLocalhostAuthCallback =
|
||||
(data.scheme == "http" || data.scheme == "https") &&
|
||||
(data.host == "localhost" || data.host == "127.0.0.1") &&
|
||||
data.path?.startsWith("/authenticate") == true
|
||||
val token = data.getQueryParameter("c") ?: data.getQueryParameter("token")
|
||||
val hasAuthToken = !token.isNullOrEmpty()
|
||||
|
||||
if (isAppAuthCallback || isLocalhostAuthCallback || hasAuthToken) {
|
||||
// BattlesScreen consumes the callback intent and exchanges the single-use token
|
||||
// for a durable session token. Writing partial auth state here can clear an
|
||||
// already-valid session token and cause the browser login loop to restart.
|
||||
return NavigationItems.Battles.route
|
||||
if (data.scheme == "vbhelper" && data.host == "auth") {
|
||||
return "Battle"
|
||||
}
|
||||
}
|
||||
return null
|
||||
@ -151,4 +192,8 @@ class MainActivity : ComponentActivity() {
|
||||
initialRoute = initialRoute
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val VITALWEAR_CHARACTER_MIME = "application/x-vitalwear-character"
|
||||
}
|
||||
}
|
||||
|
||||
@ -5,7 +5,9 @@ import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
@Composable
|
||||
fun AnimatedSpriteImage(
|
||||
@ -13,9 +15,11 @@ fun AnimatedSpriteImage(
|
||||
animationType: DigimonAnimationType = DigimonAnimationType.IDLE,
|
||||
modifier: Modifier = Modifier,
|
||||
contentScale: ContentScale = ContentScale.Fit,
|
||||
reloadMappings: Boolean = false,
|
||||
animationOffset: Long = 0L // New parameter for offsetting animation timing
|
||||
) {
|
||||
val spriteManager = remember { IndividualSpriteManager() }
|
||||
val context = LocalContext.current
|
||||
val spriteManager = remember { IndividualSpriteManager(context) }
|
||||
|
||||
// Calculate frame offset based on animation offset
|
||||
// 750ms is the idle animation duration, so we calculate how many frames to offset
|
||||
@ -26,11 +30,18 @@ fun AnimatedSpriteImage(
|
||||
0
|
||||
}
|
||||
|
||||
val animationStateMachine = remember { DigimonAnimationStateMachine(characterId, frameOffset, animationOffset) }
|
||||
val animationStateMachine = remember { DigimonAnimationStateMachine(characterId, context, frameOffset, animationOffset) }
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
var bitmap by remember { mutableStateOf<android.graphics.Bitmap?>(null) }
|
||||
|
||||
// Reload mappings when reloadMappings parameter changes
|
||||
LaunchedEffect(reloadMappings) {
|
||||
if (reloadMappings) {
|
||||
animationStateMachine.reloadMappings()
|
||||
}
|
||||
}
|
||||
|
||||
// Start the animation when the component is first created
|
||||
LaunchedEffect(characterId) {
|
||||
coroutineScope.launch {
|
||||
|
||||
@ -1,10 +1,17 @@
|
||||
package com.github.nacabaro.vbhelper.battle
|
||||
|
||||
import android.util.Log
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.State
|
||||
|
||||
class ArenaBattleSystem {
|
||||
companion object {
|
||||
private const val TAG = "ArenaBattleSystem"
|
||||
}
|
||||
|
||||
// Attack phases: 0=Idle, 1=Player attack on player screen, 2=Player attack on opponent screen,
|
||||
// 3=Opponent attack on opponent screen, 4=Opponent attack on player screen
|
||||
private var _attackPhase by mutableStateOf(0)
|
||||
@ -37,6 +44,19 @@ class ArenaBattleSystem {
|
||||
private var _critBarProgress by mutableStateOf(0)
|
||||
val critBarProgress: Int get() = _critBarProgress
|
||||
|
||||
// Dodge animation states
|
||||
private var _isDodging by mutableStateOf(false)
|
||||
val isDodging: Boolean get() = _isDodging
|
||||
|
||||
private var _dodgeProgress by mutableStateOf(0f)
|
||||
val dodgeProgress: Float get() = _dodgeProgress
|
||||
|
||||
private var _dodgeDirection by mutableStateOf(1f) // 1f = up, -1f = down
|
||||
val dodgeDirection: Float get() = _dodgeDirection
|
||||
|
||||
private var _isHit by mutableStateOf(false)
|
||||
val isHit: Boolean get() = _isHit
|
||||
|
||||
private var _hitProgress by mutableStateOf(0f)
|
||||
val hitProgress: Float get() = _hitProgress
|
||||
|
||||
@ -160,6 +180,10 @@ class ArenaBattleSystem {
|
||||
_isPlayerAttacking = false
|
||||
_attackIsHit = false
|
||||
_currentView = 0
|
||||
_isDodging = false
|
||||
_dodgeProgress = 0f
|
||||
_dodgeDirection = 1f
|
||||
_isHit = false
|
||||
_hitProgress = 0f
|
||||
_isPlayerDodging = false
|
||||
_isOpponentDodging = false
|
||||
@ -191,10 +215,41 @@ class ArenaBattleSystem {
|
||||
//Log.d(TAG, "Updated crit bar progress: $progress")
|
||||
}
|
||||
|
||||
// Dodge animation methods
|
||||
fun startDodge() {
|
||||
_isDodging = true
|
||||
_dodgeProgress = 0f
|
||||
_dodgeDirection = 1f // Start moving up
|
||||
}
|
||||
|
||||
fun setDodgeProgress(progress: Float) {
|
||||
_dodgeProgress = progress
|
||||
}
|
||||
|
||||
fun setDodgeDirection(direction: Float) {
|
||||
_dodgeDirection = direction
|
||||
}
|
||||
|
||||
fun endDodge() {
|
||||
_isDodging = false
|
||||
_dodgeProgress = 0f
|
||||
}
|
||||
|
||||
// Hit animation methods
|
||||
fun startHit() {
|
||||
_isHit = true
|
||||
_hitProgress = 0f
|
||||
}
|
||||
|
||||
fun setHitProgress(progress: Float) {
|
||||
_hitProgress = progress
|
||||
}
|
||||
|
||||
fun endHit() {
|
||||
_isHit = false
|
||||
_hitProgress = 0f
|
||||
}
|
||||
|
||||
// Player-specific dodge methods
|
||||
fun startPlayerDodge() {
|
||||
_isPlayerDodging = true
|
||||
@ -290,6 +345,17 @@ class ArenaBattleSystem {
|
||||
_isOpponentShakeDelayed = false
|
||||
}
|
||||
|
||||
// Combined method to handle attack result
|
||||
fun handleAttackResult(isHit: Boolean) {
|
||||
_attackIsHit = isHit
|
||||
if (isHit) {
|
||||
// Player attack hit - opponent gets hit
|
||||
startOpponentHit()
|
||||
} else {
|
||||
// Player attack missed - opponent dodges
|
||||
startOpponentDodge()
|
||||
}
|
||||
}
|
||||
|
||||
// Method to handle opponent attack result
|
||||
fun handleOpponentAttackResult(isHit: Boolean) {
|
||||
|
||||
@ -20,7 +20,6 @@ class AuthInterceptor(private val token: String) : Interceptor {
|
||||
// Use X-Session-Token header (preferred) or Authorization: Bearer
|
||||
val authenticatedRequest = originalRequest.newBuilder()
|
||||
.header("X-Session-Token", token)
|
||||
.header("Authorization", "Bearer $token")
|
||||
.build()
|
||||
|
||||
// Debug: Log which header is being used (first few chars of token for security)
|
||||
|
||||
@ -1,46 +1,21 @@
|
||||
package com.github.nacabaro.vbhelper.battle
|
||||
|
||||
import com.google.gson.annotations.SerializedName
|
||||
|
||||
data class AdditionalInfo(
|
||||
@SerializedName(value = "avatar", alternate = ["avatar_url"])
|
||||
val avatar: String? = null,
|
||||
@SerializedName(value = "id", alternate = ["user_id"])
|
||||
val id: Long? = null,
|
||||
@SerializedName(value = "name", alternate = ["username", "display_name"])
|
||||
val name: String? = null,
|
||||
val status: String? = null
|
||||
)
|
||||
|
||||
data class UserInfo(
|
||||
@SerializedName(value = "userId", alternate = ["user_id", "id"])
|
||||
val userId: String? = null,
|
||||
@SerializedName(value = "additionalInfo", alternate = ["additional_info"])
|
||||
val additionalInfo: AdditionalInfo? = null
|
||||
)
|
||||
|
||||
data class AuthenticateResponse(
|
||||
@SerializedName(value = "success", alternate = ["ok"])
|
||||
val success: Boolean,
|
||||
val message: String? = null,
|
||||
@SerializedName(value = "userInfo", alternate = ["user_info", "user"])
|
||||
val userInfo: UserInfo? = null,
|
||||
@SerializedName(value = "sessionToken", alternate = ["session_token", "token", "session"])
|
||||
val sessionToken: String? = null,
|
||||
@SerializedName(value = "userId", alternate = ["user_id"])
|
||||
val topLevelUserId: String? = null,
|
||||
@SerializedName(value = "id", alternate = ["uid"])
|
||||
val topLevelId: Long? = null,
|
||||
val sessionToken: String? = null
|
||||
)
|
||||
|
||||
fun AuthenticateResponse.extractSessionToken(): String? {
|
||||
return sessionToken?.takeIf { it.isNotBlank() }
|
||||
}
|
||||
|
||||
fun AuthenticateResponse.extractUserId(): Long? {
|
||||
return userInfo?.userId?.toLongOrNull()
|
||||
?: topLevelUserId?.toLongOrNull()
|
||||
?: userInfo?.additionalInfo?.id
|
||||
?: topLevelId
|
||||
}
|
||||
|
||||
|
||||
@ -4,13 +4,22 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import kotlinx.coroutines.delay
|
||||
import android.content.Context
|
||||
import java.io.File
|
||||
|
||||
enum class DigimonAnimationType {
|
||||
IDLE,
|
||||
IDLE2,
|
||||
WALK,
|
||||
WALK2,
|
||||
RUN,
|
||||
RUN2,
|
||||
WORKOUT,
|
||||
WORKOUT2,
|
||||
HAPPY,
|
||||
SLEEP,
|
||||
ATTACK
|
||||
ATTACK,
|
||||
FLEE
|
||||
}
|
||||
|
||||
data class AnimationState(
|
||||
@ -22,6 +31,7 @@ data class AnimationState(
|
||||
|
||||
class DigimonAnimationStateMachine(
|
||||
private val characterId: String,
|
||||
private val context: Context,
|
||||
private val initialFrameOffset: Int = 0, // New parameter for offsetting the starting frame
|
||||
private val timingOffset: Long = 0L // New parameter for offsetting the timing
|
||||
) {
|
||||
@ -34,13 +44,21 @@ class DigimonAnimationStateMachine(
|
||||
var isPlaying by mutableStateOf(false)
|
||||
private set
|
||||
|
||||
// Direct mapping of frame numbers used by current battle animations.
|
||||
// Direct mapping of frame numbers (1-12) to animation types
|
||||
// This is based on the standard Digimon sprite frame order
|
||||
private val frameToAnimationType = mapOf(
|
||||
1 to DigimonAnimationType.IDLE,
|
||||
2 to DigimonAnimationType.IDLE2,
|
||||
3 to DigimonAnimationType.WALK,
|
||||
4 to DigimonAnimationType.WALK2,
|
||||
5 to DigimonAnimationType.RUN,
|
||||
6 to DigimonAnimationType.RUN2,
|
||||
7 to DigimonAnimationType.WORKOUT,
|
||||
8 to DigimonAnimationType.WORKOUT2,
|
||||
9 to DigimonAnimationType.HAPPY,
|
||||
10 to DigimonAnimationType.SLEEP,
|
||||
11 to DigimonAnimationType.ATTACK
|
||||
11 to DigimonAnimationType.ATTACK,
|
||||
12 to DigimonAnimationType.FLEE
|
||||
)
|
||||
|
||||
// Reverse mapping for getting frame numbers for each animation type
|
||||
@ -51,8 +69,15 @@ class DigimonAnimationStateMachine(
|
||||
DigimonAnimationType.IDLE to 750L,
|
||||
DigimonAnimationType.IDLE2 to 750L,
|
||||
DigimonAnimationType.WALK to 200L,
|
||||
DigimonAnimationType.WALK2 to 200L,
|
||||
DigimonAnimationType.RUN to 150L,
|
||||
DigimonAnimationType.RUN2 to 150L,
|
||||
DigimonAnimationType.WORKOUT to 300L,
|
||||
DigimonAnimationType.WORKOUT2 to 300L,
|
||||
DigimonAnimationType.HAPPY to 400L,
|
||||
DigimonAnimationType.SLEEP to 1500L,
|
||||
DigimonAnimationType.ATTACK to 650L
|
||||
DigimonAnimationType.ATTACK to 650L,
|
||||
DigimonAnimationType.FLEE to 150L
|
||||
)
|
||||
|
||||
/*
|
||||
@ -130,4 +155,13 @@ class DigimonAnimationStateMachine(
|
||||
return currentFrameNumber
|
||||
}
|
||||
|
||||
fun getCurrentCharacterId(): String {
|
||||
return characterId
|
||||
}
|
||||
|
||||
// Method to reload mappings (useful for testing)
|
||||
fun reloadMappings() {
|
||||
println("Reloading mappings for character: $characterId")
|
||||
// No need to reload since we use direct frame mapping
|
||||
}
|
||||
}
|
||||
@ -10,6 +10,7 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.delay
|
||||
@ -24,9 +25,10 @@ fun HitEffectOverlay(
|
||||
) {
|
||||
if (!isVisible) return
|
||||
|
||||
val context = LocalContext.current
|
||||
val configuration = LocalConfiguration.current
|
||||
val isLandscapeMode = configuration.orientation == android.content.res.Configuration.ORIENTATION_LANDSCAPE
|
||||
val hitEffectManager = remember { HitEffectSpriteManager() }
|
||||
val hitEffectManager = remember { HitEffectSpriteManager(context) }
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
var currentFrame by remember { mutableStateOf(0) }
|
||||
|
||||
@ -1,10 +1,13 @@
|
||||
package com.github.nacabaro.vbhelper.battle
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.graphics.Rect
|
||||
import android.os.Environment
|
||||
import java.io.File
|
||||
|
||||
class HitEffectSpriteManager {
|
||||
class HitEffectSpriteManager(private val context: Context) {
|
||||
private val spriteCache = mutableMapOf<String, Bitmap>()
|
||||
|
||||
// Get the external storage directory for hit effect sprites
|
||||
@ -53,4 +56,117 @@ class HitEffectSpriteManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a damage effect sprite from spritesheet
|
||||
* @param spritesheetName The spritesheet name (e.g., "dmg_ef1", "dmg_ef2")
|
||||
* @param frameIndex The frame index (0-3 for dmg_ef1 and dmg_ef2, 0 for dmg_ef3)
|
||||
* @return Bitmap of the damage effect frame, or null if not found
|
||||
*/
|
||||
fun loadDamageEffectSprite(spritesheetName: String, frameIndex: Int = 0): Bitmap? {
|
||||
val cacheKey = "dmg_${spritesheetName}_frame_${frameIndex}"
|
||||
|
||||
// Check cache first
|
||||
if (spriteCache.containsKey(cacheKey)) {
|
||||
return spriteCache[cacheKey]
|
||||
}
|
||||
|
||||
try {
|
||||
val spritesheetFile = File(getHitSpritesDir(), "$spritesheetName.png")
|
||||
|
||||
if (!spritesheetFile.exists()) {
|
||||
println("Damage effect spritesheet not found: ${spritesheetFile.absolutePath}")
|
||||
return null
|
||||
}
|
||||
|
||||
val spritesheet = BitmapFactory.decodeFile(spritesheetFile.absolutePath)
|
||||
if (spritesheet == null) {
|
||||
println("Failed to decode damage effect spritesheet: ${spritesheetFile.absolutePath}")
|
||||
return null
|
||||
}
|
||||
|
||||
// Extract frame from spritesheet
|
||||
val frameBitmap = when (spritesheetName) {
|
||||
"dmg_ef1", "dmg_ef2" -> {
|
||||
// These are 2x2 spritesheets (4 frames)
|
||||
val frameWidth = spritesheet.width / 2
|
||||
val frameHeight = spritesheet.height / 2
|
||||
val row = frameIndex / 2
|
||||
val col = frameIndex % 2
|
||||
val x = col * frameWidth
|
||||
val y = row * frameHeight
|
||||
Bitmap.createBitmap(spritesheet, x, y, frameWidth, frameHeight)
|
||||
}
|
||||
"dmg_ef3" -> {
|
||||
// This is a single sprite
|
||||
spritesheet
|
||||
}
|
||||
else -> {
|
||||
println("Unknown spritesheet name: $spritesheetName")
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
println("Successfully loaded damage effect frame: $spritesheetName frame $frameIndex (${frameBitmap.width}x${frameBitmap.height})")
|
||||
|
||||
// Cache the result
|
||||
spriteCache[cacheKey] = frameBitmap
|
||||
|
||||
return frameBitmap
|
||||
|
||||
} catch (e: Exception) {
|
||||
println("Error loading damage effect sprite: ${e.message}")
|
||||
e.printStackTrace()
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all available hit sprites
|
||||
* @return List of hit sprite names (without .png extension)
|
||||
*/
|
||||
fun getAvailableHitSprites(): List<String> {
|
||||
val hitSpritesDir = getHitSpritesDir()
|
||||
|
||||
if (!hitSpritesDir.exists()) {
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
return hitSpritesDir.listFiles { file ->
|
||||
file.name.startsWith("hit_") && file.name.endsWith(".png")
|
||||
}?.map { file ->
|
||||
file.name.substringBefore(".png")
|
||||
}?.sorted() ?: emptyList()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available damage effect spritesheet names
|
||||
* @return List of available damage effect spritesheet names
|
||||
*/
|
||||
fun getAvailableDamageEffectSpritesheets(): List<String> {
|
||||
try {
|
||||
if (!getHitSpritesDir().exists()) {
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
val dmgFiles = getHitSpritesDir().listFiles { file ->
|
||||
file.name.startsWith("dmg_ef") && file.name.endsWith(".png")
|
||||
} ?: emptyArray()
|
||||
|
||||
return dmgFiles.map { file ->
|
||||
file.name.substringBefore(".png")
|
||||
}.sorted()
|
||||
|
||||
} catch (e: Exception) {
|
||||
println("Error getting available damage effect spritesheets: ${e.message}")
|
||||
e.printStackTrace()
|
||||
return emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the sprite cache
|
||||
*/
|
||||
fun clearCache() {
|
||||
spriteCache.clear()
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,10 +1,12 @@
|
||||
package com.github.nacabaro.vbhelper.battle
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.os.Environment
|
||||
import java.io.File
|
||||
|
||||
class IndividualSpriteManager {
|
||||
class IndividualSpriteManager(private val context: Context) {
|
||||
private val spriteCache = mutableMapOf<String, Bitmap>()
|
||||
|
||||
// Get the external storage directory for sprite files
|
||||
@ -63,4 +65,68 @@ class IndividualSpriteManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all available sprite frames for a character
|
||||
* @param characterId The character ID
|
||||
* @return List of frame numbers (1-12) that exist for this character
|
||||
*/
|
||||
fun getAvailableFrames(characterId: String): List<Int> {
|
||||
val spriteBaseDir = getSpriteBaseDir()
|
||||
val characterDir = File(spriteBaseDir, characterId)
|
||||
|
||||
if (!characterDir.exists()) {
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
val spriteFiles = characterDir.listFiles { file ->
|
||||
file.name.startsWith("${characterId}_") && file.name.endsWith(".png")
|
||||
} ?: emptyArray()
|
||||
|
||||
return spriteFiles.mapNotNull { file ->
|
||||
val fileName = file.name
|
||||
val frameMatch = Regex("${characterId}_(\\d{2})\\.png").find(fileName)
|
||||
frameMatch?.groupValues?.get(1)?.toIntOrNull()
|
||||
}.sorted()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all available character IDs
|
||||
* @return List of character IDs that have sprite directories
|
||||
*/
|
||||
fun getAvailableCharacters(): List<String> {
|
||||
val spriteBaseDir = getSpriteBaseDir()
|
||||
|
||||
if (!spriteBaseDir.exists()) {
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
return spriteBaseDir.listFiles { file ->
|
||||
file.isDirectory && file.listFiles()?.any { it.name.endsWith(".png") } == true
|
||||
}?.map { it.name }?.sorted() ?: emptyList()
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the sprite cache
|
||||
*/
|
||||
fun clearCache() {
|
||||
spriteCache.clear()
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a character has sprite files
|
||||
* @param characterId The character ID to check
|
||||
* @return true if the character has sprite files, false otherwise
|
||||
*/
|
||||
fun hasCharacterSprites(characterId: String): Boolean {
|
||||
val characterDir = File(getSpriteBaseDir(), characterId)
|
||||
if (!characterDir.exists()) {
|
||||
return false
|
||||
}
|
||||
|
||||
val spriteFiles = characterDir.listFiles { file ->
|
||||
file.name.startsWith("${characterId}_") && file.name.endsWith(".png")
|
||||
} ?: emptyArray()
|
||||
|
||||
return spriteFiles.isNotEmpty()
|
||||
}
|
||||
}
|
||||
@ -12,11 +12,7 @@ import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import com.github.nacabaro.vbhelper.battle.BattleAuthContainer
|
||||
import java.net.SocketTimeoutException
|
||||
import java.io.InterruptedIOException
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class RetrofitHelper {
|
||||
|
||||
@ -32,22 +28,9 @@ class RetrofitHelper {
|
||||
return OkHttpClient.Builder()
|
||||
.addInterceptor(AuthInterceptor(token))
|
||||
.addInterceptor(loggingInterceptor)
|
||||
.connectTimeout(30, TimeUnit.SECONDS)
|
||||
.readTimeout(30, TimeUnit.SECONDS)
|
||||
.writeTimeout(30, TimeUnit.SECONDS)
|
||||
.callTimeout(45, TimeUnit.SECONDS)
|
||||
.retryOnConnectionFailure(true)
|
||||
.build()
|
||||
}
|
||||
|
||||
private fun classifyNetworkError(t: Throwable): String {
|
||||
return when (t) {
|
||||
is SocketTimeoutException -> "Authentication server timed out"
|
||||
is InterruptedIOException -> "Request timed out"
|
||||
else -> t.message ?: "Network error"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the session token from AuthRepository for API calls.
|
||||
* Falls back to nacatech token if session token is not available (backward compatibility).
|
||||
@ -55,8 +38,7 @@ class RetrofitHelper {
|
||||
private fun getAuthToken(context: Context): String? {
|
||||
return try {
|
||||
val authContainer = BattleAuthContainer(context)
|
||||
runBlocking(Dispatchers.IO) {
|
||||
withTimeoutOrNull(5000L) {
|
||||
runBlocking {
|
||||
// Prefer session token, fall back to nacatech token for backward compatibility
|
||||
val sessionToken = authContainer.authRepository.sessionToken.first()
|
||||
if (!sessionToken.isNullOrEmpty()) {
|
||||
@ -70,10 +52,6 @@ class RetrofitHelper {
|
||||
}
|
||||
nacatechToken
|
||||
}
|
||||
} ?: run {
|
||||
println("RetrofitHelper: Timed out while reading auth token from DataStore")
|
||||
null
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
println("RetrofitHelper: Error getting auth token: ${e.message}")
|
||||
@ -98,29 +76,6 @@ class RetrofitHelper {
|
||||
.addConverterFactory(GsonConverterFactory.create())
|
||||
.build()
|
||||
}
|
||||
/**
|
||||
* Creates a Retrofit instance without authentication for public endpoints.
|
||||
*/
|
||||
private fun createPublicRetrofit(): Retrofit {
|
||||
val loggingInterceptor = HttpLoggingInterceptor().apply {
|
||||
level = HttpLoggingInterceptor.Level.BODY
|
||||
}
|
||||
|
||||
val client = OkHttpClient.Builder()
|
||||
.addInterceptor(loggingInterceptor)
|
||||
.connectTimeout(30, TimeUnit.SECONDS)
|
||||
.readTimeout(30, TimeUnit.SECONDS)
|
||||
.writeTimeout(30, TimeUnit.SECONDS)
|
||||
.callTimeout(45, TimeUnit.SECONDS)
|
||||
.retryOnConnectionFailure(true)
|
||||
.build()
|
||||
|
||||
return Retrofit.Builder()
|
||||
.baseUrl("http://battle.io-void.com:8080/")
|
||||
.client(client)
|
||||
.addConverterFactory(GsonConverterFactory.create())
|
||||
.build()
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles HTTP error responses (401, 403, 429).
|
||||
@ -166,15 +121,16 @@ class RetrofitHelper {
|
||||
}
|
||||
}
|
||||
|
||||
fun getOpponents(context: Context, stage: String, onComplete: (() -> Unit)? = null, callback: (OpponentsDataModel) -> Unit) {
|
||||
fun getOpponents(context: Context, stage: String, callback: (OpponentsDataModel) -> Unit) {
|
||||
//println("RetrofitHelper: Starting API call for stage: $stage")
|
||||
|
||||
try {
|
||||
// Opponents endpoint requires auth on the current backend.
|
||||
// Prefer authenticated Retrofit; fall back to public client only if no token is available.
|
||||
val retrofit = createAuthenticatedRetrofit(context) ?: run {
|
||||
println("RetrofitHelper: No auth token for opponents request, falling back to public Retrofit")
|
||||
createPublicRetrofit()
|
||||
// Create an authenticated Retrofit instance
|
||||
val retrofit = createAuthenticatedRetrofit(context)
|
||||
if (retrofit == null) {
|
||||
println("RetrofitHelper: Cannot create authenticated Retrofit - no token available")
|
||||
Toast.makeText(context, "Authentication required. Please log in.", Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
}
|
||||
|
||||
// Create an ApiService instance from the Retrofit instance.
|
||||
@ -190,11 +146,9 @@ class RetrofitHelper {
|
||||
// make an asynchronous API request.
|
||||
call.enqueue(object : Callback<OpponentsDataModel> {
|
||||
override fun onFailure(call: Call<OpponentsDataModel>, t: Throwable) {
|
||||
val classified = classifyNetworkError(t)
|
||||
println("RetrofitHelper: API call failed (${t::class.java.simpleName}): $classified")
|
||||
println("RetrofitHelper: API call failed: ${t.message}")
|
||||
t.printStackTrace()
|
||||
Toast.makeText(context, classified, Toast.LENGTH_SHORT).show()
|
||||
onComplete?.invoke()
|
||||
Toast.makeText(context, "Request Fail", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
override fun onResponse(call: Call<OpponentsDataModel>, response: Response<OpponentsDataModel>) {
|
||||
@ -202,19 +156,13 @@ class RetrofitHelper {
|
||||
println("RetrofitHelper: Response body: ${response.body()}")
|
||||
|
||||
if(response.isSuccessful){
|
||||
val opponentsList = response.body()
|
||||
if (opponentsList != null) {
|
||||
callback(opponentsList)
|
||||
} else {
|
||||
println("RetrofitHelper: Successful opponents response had empty body")
|
||||
Toast.makeText(context, "Empty response from server", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
onComplete?.invoke()
|
||||
//println("RetrofitHelper: Response successful, calling callback")
|
||||
val opponentsList: OpponentsDataModel = response.body() as OpponentsDataModel
|
||||
callback(opponentsList)
|
||||
} else {
|
||||
val errorBody = response.errorBody()?.string()
|
||||
println("RetrofitHelper: Opponents response not successful - Code: ${response.code()}, Error: $errorBody")
|
||||
handleErrorResponse(context, response, errorBody ?: "Unable to load opponents right now.")
|
||||
onComplete?.invoke()
|
||||
println("RetrofitHelper: Response not successful - Error: $errorBody")
|
||||
handleErrorResponse(context, response, errorBody ?: "Unknown error")
|
||||
}
|
||||
}
|
||||
})
|
||||
@ -222,7 +170,6 @@ class RetrofitHelper {
|
||||
println("RetrofitHelper: Exception in getOpponents: ${e.message}")
|
||||
e.printStackTrace()
|
||||
Toast.makeText(context, "Request failed: ${e.message}", Toast.LENGTH_SHORT).show()
|
||||
onComplete?.invoke()
|
||||
}
|
||||
}
|
||||
|
||||
@ -337,10 +284,9 @@ class RetrofitHelper {
|
||||
|
||||
override fun onFailure(call: Call<PVPDataModel>, t: Throwable) {
|
||||
// This method is called when the API request fails.
|
||||
val classified = classifyNetworkError(t)
|
||||
println("RetrofitHelper: PVP API call failed (${t::class.java.simpleName}): $classified")
|
||||
println("RetrofitHelper: PVP API call failed: ${t.message}")
|
||||
t.printStackTrace()
|
||||
Toast.makeText(context, classified, Toast.LENGTH_SHORT).show()
|
||||
Toast.makeText(context, "Request Fail", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
override fun onResponse(call: Call<PVPDataModel>, response: Response<PVPDataModel>) {
|
||||
@ -348,13 +294,13 @@ class RetrofitHelper {
|
||||
println("RetrofitHelper: PVP API response received - Code: ${response.code()}")
|
||||
|
||||
if(response.isSuccessful){
|
||||
val apiResults = response.body()
|
||||
if (apiResults != null) {
|
||||
callback(apiResults)
|
||||
} else {
|
||||
println("RetrofitHelper: Successful PVP response had empty body")
|
||||
Toast.makeText(context, "Empty response from server", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
// If the response is successful, parse the
|
||||
// response body to a DataModel object.
|
||||
val apiResults: PVPDataModel = response.body() as PVPDataModel
|
||||
|
||||
// Call the callback function with the DataModel
|
||||
// object as a parameter.
|
||||
callback(apiResults)
|
||||
} else {
|
||||
val errorBody = response.errorBody()?.string()
|
||||
println("RetrofitHelper: PVP API response not successful - Code: ${response.code()}, Error: $errorBody")
|
||||
@ -369,20 +315,12 @@ class RetrofitHelper {
|
||||
}
|
||||
}
|
||||
|
||||
fun authenticate(
|
||||
context: Context,
|
||||
token: String,
|
||||
showUserFacingErrors: Boolean = true,
|
||||
callback: (AuthenticateResponse) -> Unit,
|
||||
) {
|
||||
fun authenticate(context: Context, token: String, callback: (AuthenticateResponse) -> Unit) {
|
||||
//println("RetrofitHelper: Starting validate API call with token: $token")
|
||||
|
||||
if (token.isEmpty()) {
|
||||
println("RetrofitHelper: ERROR - Token is empty!")
|
||||
if (showUserFacingErrors) {
|
||||
Toast.makeText(context, "Authentication failed: Token is empty", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
callback(AuthenticateResponse(success = false, message = "Token is empty"))
|
||||
Toast.makeText(context, "Authentication failed: Token is empty", Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
}
|
||||
|
||||
@ -393,10 +331,6 @@ class RetrofitHelper {
|
||||
}
|
||||
val okHttpClient = OkHttpClient.Builder()
|
||||
.addInterceptor(loggingInterceptor)
|
||||
.connectTimeout(30, TimeUnit.SECONDS)
|
||||
.readTimeout(30, TimeUnit.SECONDS)
|
||||
.writeTimeout(30, TimeUnit.SECONDS)
|
||||
.callTimeout(45, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
val retrofit: Retrofit = Retrofit.Builder()
|
||||
@ -414,11 +348,7 @@ class RetrofitHelper {
|
||||
override fun onFailure(call: Call<AuthenticateResponse>, t: Throwable) {
|
||||
println("RetrofitHelper: Validate API call failed: ${t.message}")
|
||||
t.printStackTrace()
|
||||
val message = classifyNetworkError(t)
|
||||
if (showUserFacingErrors) {
|
||||
Toast.makeText(context, "Authentication failed: $message", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
callback(AuthenticateResponse(success = false, message = message))
|
||||
Toast.makeText(context, "Authentication failed: ${t.message}", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
override fun onResponse(call: Call<AuthenticateResponse>, response: Response<AuthenticateResponse>) {
|
||||
@ -430,25 +360,11 @@ class RetrofitHelper {
|
||||
} else {
|
||||
println("RetrofitHelper: Validation failed: Invalid response body")
|
||||
Toast.makeText(context, "Authentication failed: Invalid response", Toast.LENGTH_SHORT).show()
|
||||
callback(AuthenticateResponse(success = false, message = "Invalid response body"))
|
||||
}
|
||||
} else {
|
||||
val errorBody = response.errorBody()?.string()
|
||||
println("RetrofitHelper: Validate response not successful - Code: ${response.code()}, Error: $errorBody")
|
||||
val userMessage = when {
|
||||
response.code() == 522 -> "Authentication server timed out. Please try again in a moment."
|
||||
response.code() >= 500 -> "Authentication server is unavailable. Please try again later."
|
||||
else -> "Authentication failed: ${response.code()}"
|
||||
}
|
||||
if (showUserFacingErrors) {
|
||||
Toast.makeText(context, userMessage, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
callback(
|
||||
AuthenticateResponse(
|
||||
success = false,
|
||||
message = "HTTP ${response.code()}: ${errorBody ?: "Authentication server error"}"
|
||||
)
|
||||
)
|
||||
Toast.makeText(context, "Authentication failed: ${response.code()}", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
})
|
||||
@ -456,7 +372,6 @@ class RetrofitHelper {
|
||||
println("RetrofitHelper: Exception in validate: ${e.message}")
|
||||
e.printStackTrace()
|
||||
Toast.makeText(context, "Authentication failed: ${e.message}", Toast.LENGTH_SHORT).show()
|
||||
callback(AuthenticateResponse(success = false, message = e.message ?: "Authentication error"))
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,13 +1,275 @@
|
||||
package com.github.nacabaro.vbhelper.battle
|
||||
|
||||
import android.content.Context
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.io.IOException
|
||||
import java.io.FileInputStream
|
||||
|
||||
class SpriteFileManager {
|
||||
private fun getExternalSpriteBaseDir(): File {
|
||||
class SpriteFileManager(private val context: Context) {
|
||||
|
||||
// Get the external storage directory where files are already located
|
||||
fun getExternalSpriteBaseDir(): File {
|
||||
val externalDir = android.os.Environment.getExternalStorageDirectory()
|
||||
return File(externalDir, "VBHelper/battle_sprites")
|
||||
}
|
||||
|
||||
// Get the internal storage directory for sprite files
|
||||
private fun getInternalSpriteBaseDir(): File {
|
||||
return File(context.filesDir, "battle_sprites")
|
||||
}
|
||||
|
||||
fun copySpriteFilesToInternalStorage() {
|
||||
try {
|
||||
println("Starting sprite file copy process from external storage to internal storage...")
|
||||
|
||||
val externalDir = getExternalSpriteBaseDir()
|
||||
val internalDir = getInternalSpriteBaseDir()
|
||||
|
||||
// Check if external directory exists
|
||||
if (!externalDir.exists()) {
|
||||
println("External sprite directory does not exist: ${externalDir.absolutePath}")
|
||||
return
|
||||
}
|
||||
|
||||
println("External sprite directory exists: ${externalDir.absolutePath}")
|
||||
println("Copying to internal storage: ${internalDir.absolutePath}")
|
||||
|
||||
// Create internal directory if it doesn't exist
|
||||
if (!internalDir.exists()) {
|
||||
val created = internalDir.mkdirs()
|
||||
println("Created internal sprite directory: $created")
|
||||
}
|
||||
|
||||
// Copy all subdirectories from external to internal storage
|
||||
val externalFiles = externalDir.listFiles()
|
||||
if (externalFiles != null) {
|
||||
println("Found ${externalFiles.size} items in external directory")
|
||||
externalFiles.forEach { item ->
|
||||
val targetItem = File(internalDir, item.name)
|
||||
if (item.isDirectory) {
|
||||
println("Copying directory: ${item.name}")
|
||||
copyDirectory(item, targetItem)
|
||||
} else {
|
||||
println("Copying file: ${item.name}")
|
||||
copyFile(item, targetItem)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
println("Sprite files copied successfully to internal storage: ${internalDir.absolutePath}")
|
||||
|
||||
} catch (e: Exception) {
|
||||
println("Error copying sprite files to internal storage: ${e.message}")
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
|
||||
fun copySpriteFilesToExternalStorage() {
|
||||
try {
|
||||
println("Starting sprite file copy process to external storage...")
|
||||
|
||||
// Debug: List what's in the assets directory
|
||||
val assetManager = context.assets
|
||||
val battleSpritesFiles = assetManager.list("battle_sprites")
|
||||
println("battle_sprites directory in assets contains: ${battleSpritesFiles?.joinToString(", ")}")
|
||||
|
||||
val extractedAssetsFiles = assetManager.list("battle_sprites/extracted_assets")
|
||||
println("battle_sprites/extracted_assets directory in assets contains: ${extractedAssetsFiles?.joinToString(", ")}")
|
||||
|
||||
// Check specifically for extracted_atksprites in assets (now directly under battle_sprites)
|
||||
val atkspritesInAssets = assetManager.list("battle_sprites/extracted_atksprites")
|
||||
println("extracted_atksprites in assets contains: ${atkspritesInAssets?.size ?: 0} files")
|
||||
if (atkspritesInAssets != null && atkspritesInAssets.isNotEmpty()) {
|
||||
println("First few attack files in assets: ${atkspritesInAssets.take(5).joinToString(", ")}")
|
||||
}
|
||||
|
||||
// Check for extracted_battlebgs in assets (now directly under battle_sprites)
|
||||
val battlebgsInAssets = assetManager.list("battle_sprites/extracted_battlebgs")
|
||||
println("extracted_battlebgs in assets contains: ${battlebgsInAssets?.size ?: 0} files")
|
||||
if (battlebgsInAssets != null && battlebgsInAssets.isNotEmpty()) {
|
||||
println("First few battle background files in assets: ${battlebgsInAssets.take(5).joinToString(", ")}")
|
||||
}
|
||||
|
||||
// Try to list all possible subdirectories in battle_sprites
|
||||
println("Checking all possible subdirectories in battle_sprites...")
|
||||
battleSpritesFiles?.forEach { subdir ->
|
||||
try {
|
||||
val subdirFiles = assetManager.list("battle_sprites/$subdir")
|
||||
println(" $subdir contains: ${subdirFiles?.size ?: 0} files")
|
||||
if (subdirFiles != null && subdirFiles.isNotEmpty()) {
|
||||
println(" First few files: ${subdirFiles.take(3).joinToString(", ")}")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
println(" Error listing $subdir: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
// Create the base directory for battle_sprites in external storage
|
||||
val battleSpritesDir = getExternalSpriteBaseDir()
|
||||
if (!battleSpritesDir.exists()) {
|
||||
battleSpritesDir.mkdirs()
|
||||
println("Created battle_sprites directory in external storage: ${battleSpritesDir.absolutePath}")
|
||||
} else {
|
||||
println("battle_sprites directory already exists in external storage: ${battleSpritesDir.absolutePath}")
|
||||
}
|
||||
|
||||
// Copy all subdirectories from battle_sprites assets to external storage
|
||||
println("Copying all battle_sprites subdirectories to external storage...")
|
||||
battleSpritesFiles?.forEach { subdir ->
|
||||
val sourcePath = "battle_sprites/$subdir"
|
||||
val targetDir = File(battleSpritesDir, subdir)
|
||||
println("Copying $sourcePath to ${targetDir.absolutePath}")
|
||||
copyAssetDirectory(sourcePath, targetDir)
|
||||
}
|
||||
|
||||
println("Sprite files copied successfully to external storage: ${battleSpritesDir.absolutePath}")
|
||||
|
||||
// Verify that attack sprites were copied
|
||||
val atkspritesDir = File(battleSpritesDir, "extracted_atksprites")
|
||||
if (atkspritesDir.exists()) {
|
||||
val attackFiles = atkspritesDir.listFiles()
|
||||
println("Attack sprites directory exists with ${attackFiles?.size ?: 0} files")
|
||||
if (attackFiles != null && attackFiles.isNotEmpty()) {
|
||||
println("First few attack files: ${attackFiles.take(5).map { it.name }}")
|
||||
}
|
||||
} else {
|
||||
println("WARNING: extracted_atksprites directory does not exist!")
|
||||
// List what's actually in the battle_sprites directory
|
||||
val battleSpritesContents = battleSpritesDir.listFiles()
|
||||
println("battle_sprites directory contains: ${battleSpritesContents?.map { it.name }?.joinToString(", ")}")
|
||||
}
|
||||
|
||||
// Verify that battle backgrounds were copied
|
||||
val battlebgsDir = File(battleSpritesDir, "extracted_battlebgs")
|
||||
if (battlebgsDir.exists()) {
|
||||
val bgFiles = battlebgsDir.listFiles()
|
||||
println("Battle backgrounds directory exists with ${bgFiles?.size ?: 0} files")
|
||||
if (bgFiles != null && bgFiles.isNotEmpty()) {
|
||||
println("First few battle background files: ${bgFiles.take(5).map { it.name }}")
|
||||
}
|
||||
} else {
|
||||
println("WARNING: extracted_battlebgs directory does not exist!")
|
||||
}
|
||||
|
||||
} catch (e: Exception) {
|
||||
println("Error copying sprite files to external storage: ${e.message}")
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
|
||||
private fun copyAssetDirectory(assetPath: String, targetDir: File) {
|
||||
try {
|
||||
val assetManager = context.assets
|
||||
val files = assetManager.list(assetPath) ?: return
|
||||
|
||||
println("Copying asset directory: $assetPath (${files.size} items)")
|
||||
println("Files found: ${files.joinToString(", ")}")
|
||||
|
||||
for (file in files) {
|
||||
val assetFilePath = if (assetPath.isEmpty()) file else "$assetPath/$file"
|
||||
val targetFile = File(targetDir, file)
|
||||
|
||||
// Create subdirectories if needed
|
||||
if (targetFile.parentFile != null && !targetFile.parentFile!!.exists()) {
|
||||
targetFile.parentFile!!.mkdirs()
|
||||
}
|
||||
|
||||
// Check if it's a directory by trying to list its contents
|
||||
try {
|
||||
val subFiles = assetManager.list(assetFilePath)
|
||||
if (subFiles != null && subFiles.isNotEmpty()) {
|
||||
// It's a directory, create it and copy contents
|
||||
println("Copying subdirectory: $assetFilePath (${subFiles.size} files)")
|
||||
if (!targetFile.exists()) {
|
||||
targetFile.mkdirs()
|
||||
}
|
||||
copyAssetDirectory(assetFilePath, targetFile)
|
||||
} else {
|
||||
// It's a file, copy it
|
||||
copyAssetFile(assetFilePath, targetFile)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// If we can't list contents, it's probably a file
|
||||
println("Treating $assetFilePath as file (could not list contents)")
|
||||
copyAssetFile(assetFilePath, targetFile)
|
||||
}
|
||||
}
|
||||
|
||||
// Special handling for extracted_atksprites - try to copy it directly if it wasn't found
|
||||
if (assetPath == "battle_sprites/extracted_assets") {
|
||||
println("Special handling: Checking for extracted_atksprites directory...")
|
||||
try {
|
||||
val atkspritesFiles = assetManager.list("battle_sprites/extracted_assets/extracted_atksprites")
|
||||
if (atkspritesFiles != null && atkspritesFiles.isNotEmpty()) {
|
||||
println("Found extracted_atksprites with ${atkspritesFiles.size} files")
|
||||
val atkspritesDir = File(targetDir, "extracted_atksprites")
|
||||
if (!atkspritesDir.exists()) {
|
||||
atkspritesDir.mkdirs()
|
||||
}
|
||||
copyAssetDirectory("battle_sprites/extracted_assets/extracted_atksprites", atkspritesDir)
|
||||
} else {
|
||||
println("extracted_atksprites directory not found in assets")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
println("Error checking extracted_atksprites: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
} catch (e: Exception) {
|
||||
println("Error copying asset directory $assetPath: ${e.message}")
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
|
||||
private fun copyDirectory(sourceDir: File, targetDir: File) {
|
||||
if (!targetDir.exists()) {
|
||||
targetDir.mkdirs()
|
||||
}
|
||||
|
||||
val files = sourceDir.listFiles()
|
||||
if (files != null) {
|
||||
files.forEach { file ->
|
||||
val targetFile = File(targetDir, file.name)
|
||||
if (file.isDirectory) {
|
||||
copyDirectory(file, targetFile)
|
||||
} else {
|
||||
copyFile(file, targetFile)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun copyFile(sourceFile: File, targetFile: File) {
|
||||
try {
|
||||
val inputStream = FileInputStream(sourceFile)
|
||||
val outputStream = FileOutputStream(targetFile)
|
||||
|
||||
inputStream.copyTo(outputStream)
|
||||
inputStream.close()
|
||||
outputStream.close()
|
||||
|
||||
println("Copied: ${sourceFile.name} -> ${targetFile.absolutePath}")
|
||||
} catch (e: IOException) {
|
||||
println("Error copying file ${sourceFile.name}: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun copyAssetFile(assetPath: String, targetFile: File) {
|
||||
try {
|
||||
val inputStream = context.assets.open(assetPath)
|
||||
val outputStream = FileOutputStream(targetFile)
|
||||
|
||||
inputStream.copyTo(outputStream)
|
||||
inputStream.close()
|
||||
outputStream.close()
|
||||
|
||||
println("Copied: $assetPath -> ${targetFile.absolutePath}")
|
||||
} catch (e: IOException) {
|
||||
println("Error copying asset file $assetPath: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
fun checkSpriteFilesExist(): Boolean {
|
||||
val battleSpritesDir = getExternalSpriteBaseDir()
|
||||
val extractedAssetsDir = File(battleSpritesDir, "extracted_assets")
|
||||
@ -32,4 +294,35 @@ class SpriteFileManager {
|
||||
|
||||
return battleSpritesExist && assetsExist && statsExist && atkspritesExist && battlebgsExist
|
||||
}
|
||||
|
||||
fun clearSpriteFiles() {
|
||||
try {
|
||||
val battleSpritesDir = getInternalSpriteBaseDir()
|
||||
|
||||
if (battleSpritesDir.exists()) {
|
||||
deleteDirectory(battleSpritesDir)
|
||||
println("Cleared battle_sprites directory from internal storage")
|
||||
}
|
||||
|
||||
} catch (e: Exception) {
|
||||
println("Error clearing sprite files: ${e.message}")
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
|
||||
private fun deleteDirectory(directory: File) {
|
||||
if (directory.exists()) {
|
||||
val files = directory.listFiles()
|
||||
if (files != null) {
|
||||
for (file in files) {
|
||||
if (file.isDirectory) {
|
||||
deleteDirectory(file)
|
||||
} else {
|
||||
file.delete()
|
||||
}
|
||||
}
|
||||
}
|
||||
directory.delete()
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -5,6 +5,7 @@ import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
|
||||
@Composable
|
||||
fun SpriteImage(
|
||||
@ -13,7 +14,8 @@ fun SpriteImage(
|
||||
modifier: Modifier = Modifier,
|
||||
contentScale: ContentScale = ContentScale.Fit
|
||||
) {
|
||||
val spriteManager = remember { IndividualSpriteManager() }
|
||||
val context = LocalContext.current
|
||||
val spriteManager = remember { IndividualSpriteManager(context) }
|
||||
|
||||
var bitmap by remember { mutableStateOf<android.graphics.Bitmap?>(null) }
|
||||
|
||||
|
||||
@ -2,7 +2,6 @@ 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
|
||||
|
||||
@ -20,7 +19,6 @@ interface AdventureDao {
|
||||
""")
|
||||
fun getAdventureCount(): Int
|
||||
|
||||
@RewriteQueriesToDropUnusedColumns
|
||||
@Query(
|
||||
"""
|
||||
SELECT
|
||||
|
||||
@ -13,9 +13,6 @@ 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>
|
||||
|
||||
|
||||
@ -2,7 +2,6 @@ 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
|
||||
@ -28,12 +27,12 @@ interface CardFusionsDao {
|
||||
toCharaId: Int
|
||||
)
|
||||
|
||||
@RewriteQueriesToDropUnusedColumns
|
||||
@Query("""
|
||||
SELECT
|
||||
cf.toCharaId as charaId,
|
||||
cf.fromCharaId as fromCharaId,
|
||||
s.spriteIdle1 as spriteIdle,
|
||||
cc.attribute as attribute,
|
||||
s.width as spriteWidth,
|
||||
s.height as spriteHeight,
|
||||
d.discoveredOn as discoveredOn,
|
||||
|
||||
@ -13,15 +13,9 @@ interface CharacterDao {
|
||||
@Insert
|
||||
suspend fun insertCharacter(vararg characterData: CardCharacter)
|
||||
|
||||
@Query("SELECT * FROM CardCharacter WHERE id = :id LIMIT 1")
|
||||
fun getCharacterById(id: Long): CardCharacter?
|
||||
|
||||
@Query("SELECT * FROM CardCharacter WHERE charaIndex = :monIndex AND cardId = :dimId LIMIT 1")
|
||||
fun getCharacterByMonIndex(monIndex: Int, dimId: Long): CardCharacter
|
||||
|
||||
@Query("SELECT * FROM CardCharacter WHERE charaIndex = :monIndex AND cardId = :dimId LIMIT 1")
|
||||
fun getCharacterByMonIndexOrNull(monIndex: Int, dimId: Long): CardCharacter?
|
||||
|
||||
@Insert
|
||||
suspend fun insertSprite(vararg sprite: Sprite)
|
||||
|
||||
|
||||
@ -11,9 +11,6 @@ interface SpriteDao {
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
fun insertSprite(sprite: Sprite): Long
|
||||
|
||||
@Query("SELECT * FROM Sprite WHERE id = :id LIMIT 1")
|
||||
fun getSpriteById(id: Long): Sprite?
|
||||
|
||||
@Query("SELECT * FROM Sprite")
|
||||
suspend fun getAllSprites(): List<Sprite>
|
||||
}
|
||||
@ -4,7 +4,6 @@ import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import androidx.room.RewriteQueriesToDropUnusedColumns
|
||||
import androidx.room.Upsert
|
||||
import com.github.nacabaro.vbhelper.domain.card.CardCharacter
|
||||
import com.github.nacabaro.vbhelper.domain.device_data.UserCharacter
|
||||
@ -39,7 +38,6 @@ interface UserCharacterDao {
|
||||
@Upsert
|
||||
fun insertSpecialMissions(vararg specialMissions: SpecialMissions)
|
||||
|
||||
@RewriteQueriesToDropUnusedColumns
|
||||
@Query(
|
||||
"""
|
||||
SELECT
|
||||
@ -57,7 +55,6 @@ interface UserCharacterDao {
|
||||
)
|
||||
fun getTransformationHistory(monId: Long): Flow<List<CharacterDtos.TransformationHistory>>
|
||||
|
||||
@RewriteQueriesToDropUnusedColumns
|
||||
@Query(
|
||||
"""
|
||||
SELECT
|
||||
@ -74,7 +71,6 @@ interface UserCharacterDao {
|
||||
)
|
||||
suspend fun getTransformationHistoryForExport(monId: Long): List<CharacterDtos.TransformationHistoryExport>
|
||||
|
||||
@RewriteQueriesToDropUnusedColumns
|
||||
@Query(
|
||||
"""
|
||||
SELECT
|
||||
@ -100,7 +96,6 @@ interface UserCharacterDao {
|
||||
)
|
||||
fun getAllCharacters(): Flow<List<CharacterDtos.CharacterWithSprites>>
|
||||
|
||||
@RewriteQueriesToDropUnusedColumns
|
||||
@Query(
|
||||
"""
|
||||
SELECT
|
||||
@ -133,22 +128,12 @@ interface UserCharacterDao {
|
||||
@Query("SELECT * FROM BECharacterData WHERE id = :id")
|
||||
fun getBeData(id: Long): Flow<BECharacterData>
|
||||
|
||||
@Query("SELECT * FROM BECharacterData WHERE id = :id")
|
||||
suspend fun getBeDataOrNull(id: Long): BECharacterData?
|
||||
|
||||
@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("DELETE FROM BECharacterData WHERE id = :id")
|
||||
suspend fun deleteBECharacterData(id: Long)
|
||||
|
||||
@Query("SELECT * FROM SpecialMissions WHERE characterId = :id")
|
||||
fun getSpecialMissions(id: Long): Flow<List<SpecialMissions>>
|
||||
|
||||
@RewriteQueriesToDropUnusedColumns
|
||||
@Query(
|
||||
"""
|
||||
SELECT
|
||||
@ -214,7 +199,6 @@ interface UserCharacterDao {
|
||||
@Query("""SELECT * FROM VitalsHistory WHERE charId = :charId ORDER BY id ASC""")
|
||||
suspend fun getVitalsHistory(charId: Long): List<VitalsHistory>
|
||||
|
||||
@RewriteQueriesToDropUnusedColumns
|
||||
@Query(
|
||||
"""
|
||||
SELECT
|
||||
@ -241,7 +225,6 @@ interface UserCharacterDao {
|
||||
)
|
||||
suspend fun getBECharacters(): List<CharacterDtos.CharacterWithSprites>
|
||||
|
||||
@RewriteQueriesToDropUnusedColumns
|
||||
@Query(
|
||||
"""
|
||||
SELECT
|
||||
|
||||
@ -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,7 +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.domain.card.Background
|
||||
import com.github.nacabaro.vbhelper.domain.card.CardCharacter
|
||||
import com.github.nacabaro.vbhelper.domain.card.Card
|
||||
@ -32,12 +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.items.Items
|
||||
|
||||
@Database(
|
||||
version = 6,
|
||||
exportSchema = false,
|
||||
version = 1,
|
||||
entities = [
|
||||
Card::class,
|
||||
CardProgress::class,
|
||||
@ -55,8 +50,7 @@ import com.github.nacabaro.vbhelper.domain.items.Items
|
||||
Items::class,
|
||||
Adventure::class,
|
||||
Background::class,
|
||||
PossibleTransformations::class,
|
||||
VitalWearCharacterSettings::class,
|
||||
PossibleTransformations::class
|
||||
]
|
||||
)
|
||||
abstract class AppDatabase : RoomDatabase() {
|
||||
@ -71,92 +65,4 @@ abstract class AppDatabase : RoomDatabase() {
|
||||
abstract fun specialMissionDao(): SpecialMissionDao
|
||||
abstract fun cardAdventureDao(): CardAdventureDao
|
||||
abstract fun cardFusionsDao(): CardFusionsDao
|
||||
abstract fun vitalWearSettingsDao(): VitalWearSettingsDao
|
||||
|
||||
companion object {
|
||||
val MIGRATION_1_2 = object : Migration(1, 2) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS `VitalWearCharacterSettings` (
|
||||
`characterId` INTEGER NOT NULL,
|
||||
`trainingInBackground` INTEGER NOT NULL,
|
||||
`allowedBattles` INTEGER NOT NULL,
|
||||
`accumulatedDailyInjuries` INTEGER NOT NULL,
|
||||
PRIMARY KEY(`characterId`)
|
||||
)
|
||||
""".trimIndent()
|
||||
)
|
||||
|
||||
// Clean existing duplicates before adding the unique index.
|
||||
db.execSQL(
|
||||
"""
|
||||
DELETE FROM `CardCharacter`
|
||||
WHERE `id` NOT IN (
|
||||
SELECT MIN(`id`) FROM `CardCharacter` GROUP BY `cardId`, `charaIndex`
|
||||
)
|
||||
""".trimIndent()
|
||||
)
|
||||
db.execSQL(
|
||||
"""
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS `index_CardCharacter_cardId_charaIndex`
|
||||
ON `CardCharacter` (`cardId`, `charaIndex`)
|
||||
""".trimIndent()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val MIGRATION_2_3 = object : Migration(2, 3) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
val backfillTimestamp = System.currentTimeMillis()
|
||||
db.execSQL(
|
||||
"""
|
||||
INSERT INTO `TransformationHistory`(`monId`, `stageId`, `transformationDate`)
|
||||
SELECT uc.`id`, uc.`charId`, $backfillTimestamp
|
||||
FROM `UserCharacter` uc
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM `TransformationHistory` th
|
||||
WHERE th.`monId` = uc.`id`
|
||||
)
|
||||
""".trimIndent()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val MIGRATION_3_5 = object : Migration(3, 5) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
// TransferSeen moved to shared_transfer.db
|
||||
db.execSQL("DROP TABLE IF EXISTS `TransferSeen`")
|
||||
db.execSQL("DROP INDEX IF EXISTS `index_TransferSeen_cardLookupKey_slotId`")
|
||||
}
|
||||
}
|
||||
|
||||
val MIGRATION_4_5 = object : Migration(4, 5) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
// TransferSeen moved to shared_transfer.db
|
||||
db.execSQL("DROP TABLE IF EXISTS `TransferSeen`")
|
||||
db.execSQL("DROP INDEX IF EXISTS `index_TransferSeen_cardLookupKey_slotId`")
|
||||
}
|
||||
}
|
||||
|
||||
val MIGRATION_5_6 = object : Migration(5, 6) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL("CREATE INDEX IF NOT EXISTS `index_CardCharacter_spriteId` ON `CardCharacter` (`spriteId`)")
|
||||
db.execSQL("CREATE INDEX IF NOT EXISTS `index_CardAdventure_cardId` ON `CardAdventure` (`cardId`)")
|
||||
db.execSQL("CREATE INDEX IF NOT EXISTS `index_CardAdventure_characterId` ON `CardAdventure` (`characterId`)")
|
||||
db.execSQL("CREATE INDEX IF NOT EXISTS `index_CardFusions_fromCharaId` ON `CardFusions` (`fromCharaId`)")
|
||||
db.execSQL("CREATE INDEX IF NOT EXISTS `index_CardFusions_toCharaId` ON `CardFusions` (`toCharaId`)")
|
||||
db.execSQL("CREATE INDEX IF NOT EXISTS `index_Background_cardId` ON `Background` (`cardId`)")
|
||||
db.execSQL("CREATE INDEX IF NOT EXISTS `index_PossibleTransformations_charaId` ON `PossibleTransformations` (`charaId`)")
|
||||
db.execSQL("CREATE INDEX IF NOT EXISTS `index_PossibleTransformations_toCharaId` ON `PossibleTransformations` (`toCharaId`)")
|
||||
db.execSQL("CREATE INDEX IF NOT EXISTS `index_UserCharacter_charId` ON `UserCharacter` (`charId`)")
|
||||
db.execSQL("CREATE INDEX IF NOT EXISTS `index_SpecialMissions_characterId` ON `SpecialMissions` (`characterId`)")
|
||||
db.execSQL("CREATE INDEX IF NOT EXISTS `index_TransformationHistory_monId` ON `TransformationHistory` (`monId`)")
|
||||
db.execSQL("CREATE INDEX IF NOT EXISTS `index_TransformationHistory_stageId` ON `TransformationHistory` (`stageId`)")
|
||||
db.execSQL("CREATE INDEX IF NOT EXISTS `index_VitalsHistory_charId` ON `VitalsHistory` (`charId`)")
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@ -1,15 +1,11 @@
|
||||
package com.github.nacabaro.vbhelper.di
|
||||
|
||||
import com.github.cfogrady.vitalwear.common.data.SharedTransferSeenDao
|
||||
import com.github.nacabaro.vbhelper.database.AppDatabase
|
||||
import com.github.nacabaro.vbhelper.source.CardSettingsRepository
|
||||
import com.github.nacabaro.vbhelper.source.CurrencyRepository
|
||||
import com.github.nacabaro.vbhelper.source.DataStoreSecretsRepository
|
||||
|
||||
interface AppContainer {
|
||||
val db: AppDatabase
|
||||
val transferSeenDao: SharedTransferSeenDao
|
||||
val dataStoreSecretsRepository: DataStoreSecretsRepository
|
||||
val currencyRepository: CurrencyRepository
|
||||
val cardSettingsRepository: CardSettingsRepository
|
||||
}
|
||||
@ -4,11 +4,8 @@ import androidx.datastore.dataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import androidx.room.Room
|
||||
import com.github.cfogrady.vitalwear.common.data.SharedDatabaseFactory
|
||||
import com.github.cfogrady.vitalwear.common.data.SharedTransferSeenDao
|
||||
import com.github.nacabaro.vbhelper.di.AppContainer
|
||||
import com.github.nacabaro.vbhelper.database.AppDatabase
|
||||
import com.github.nacabaro.vbhelper.source.CardSettingsRepository
|
||||
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.source.SecretsSerializer
|
||||
@ -34,22 +31,11 @@ class DefaultAppContainer(private val context: Context) : AppContainer {
|
||||
"internalDb"
|
||||
)
|
||||
.createFromAsset("items.db")
|
||||
.addMigrations(AppDatabase.MIGRATION_1_2)
|
||||
.addMigrations(AppDatabase.MIGRATION_2_3)
|
||||
.addMigrations(AppDatabase.MIGRATION_3_5)
|
||||
.addMigrations(AppDatabase.MIGRATION_4_5)
|
||||
.addMigrations(AppDatabase.MIGRATION_5_6)
|
||||
.build()
|
||||
}
|
||||
|
||||
override val transferSeenDao: SharedTransferSeenDao by lazy {
|
||||
SharedDatabaseFactory.getDatabase(context).transferSeenDao()
|
||||
}
|
||||
|
||||
override val dataStoreSecretsRepository = DataStoreSecretsRepository(context.secretsStore)
|
||||
|
||||
override val currencyRepository = CurrencyRepository(context.currencyStore)
|
||||
|
||||
override val cardSettingsRepository = CardSettingsRepository(context.currencyStore)
|
||||
}
|
||||
|
||||
|
||||
@ -2,37 +2,12 @@ package com.github.nacabaro.vbhelper.di
|
||||
|
||||
import DefaultAppContainer
|
||||
import android.app.Application
|
||||
import androidx.room.Room
|
||||
import com.github.nacabaro.vbhelper.companion.logging.TinyLogTree
|
||||
import com.github.nacabaro.vbhelper.companion.logs.CompanionLogService
|
||||
import com.github.nacabaro.vbhelper.companion.validation.CompanionValidatedDatabase
|
||||
import com.github.nacabaro.vbhelper.companion.validation.ValidatedCardManager
|
||||
import timber.log.Timber
|
||||
|
||||
class VBHelper : Application() {
|
||||
lateinit var container: DefaultAppContainer
|
||||
lateinit var validatedCardDatabase: CompanionValidatedDatabase
|
||||
lateinit var validatedCardManager: ValidatedCardManager
|
||||
val companionLogService = CompanionLogService()
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
container = DefaultAppContainer(applicationContext)
|
||||
|
||||
Timber.plant(Timber.DebugTree())
|
||||
Timber.plant(TinyLogTree(this))
|
||||
val originalExceptionHandler = Thread.getDefaultUncaughtExceptionHandler()
|
||||
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
|
||||
Timber.e(throwable, "VBHelper crashed on thread ${thread.name}")
|
||||
TinyLogTree.shutdown()
|
||||
originalExceptionHandler?.uncaughtException(thread, throwable)
|
||||
}
|
||||
|
||||
validatedCardDatabase = Room.databaseBuilder(
|
||||
applicationContext,
|
||||
CompanionValidatedDatabase::class.java,
|
||||
"vbhelper_companion_tools"
|
||||
).build()
|
||||
validatedCardManager = ValidatedCardManager(validatedCardDatabase.validatedCardDao())
|
||||
}
|
||||
}
|
||||
@ -2,11 +2,9 @@ package com.github.nacabaro.vbhelper.domain.card
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.ForeignKey
|
||||
import androidx.room.Index
|
||||
import androidx.room.PrimaryKey
|
||||
|
||||
@Entity(
|
||||
indices = [Index(value = ["cardId"])],
|
||||
foreignKeys = [
|
||||
ForeignKey(
|
||||
entity = Card::class,
|
||||
|
||||
@ -2,14 +2,9 @@ package com.github.nacabaro.vbhelper.domain.card
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.ForeignKey
|
||||
import androidx.room.Index
|
||||
import androidx.room.PrimaryKey
|
||||
|
||||
@Entity(
|
||||
indices = [
|
||||
Index(value = ["cardId"]),
|
||||
Index(value = ["characterId"])
|
||||
],
|
||||
foreignKeys = [
|
||||
ForeignKey(
|
||||
entity = CardCharacter::class,
|
||||
|
||||
@ -2,16 +2,11 @@ package com.github.nacabaro.vbhelper.domain.card
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.ForeignKey
|
||||
import androidx.room.Index
|
||||
import androidx.room.PrimaryKey
|
||||
import com.github.cfogrady.vbnfc.data.NfcCharacter
|
||||
import com.github.nacabaro.vbhelper.domain.characters.Sprite
|
||||
|
||||
@Entity(
|
||||
indices = [
|
||||
Index(value = ["cardId", "charaIndex"], unique = true),
|
||||
Index(value = ["spriteId"])
|
||||
],
|
||||
foreignKeys = [
|
||||
ForeignKey(
|
||||
entity = Card::class,
|
||||
@ -29,8 +24,9 @@ import com.github.nacabaro.vbhelper.domain.characters.Sprite
|
||||
)
|
||||
|
||||
/*
|
||||
* Character represents a character on a specific card slot.
|
||||
* Uniqueness is enforced per cardId + charaIndex.
|
||||
* Character represents a character on a card. There should only be one of these per dimId
|
||||
* and monIndex.
|
||||
* TODO: Customs will mean this should be unique per cardName and monIndex
|
||||
*/
|
||||
data class CardCharacter (
|
||||
@PrimaryKey(autoGenerate = true) val id: Long = 0,
|
||||
|
||||
@ -2,15 +2,10 @@ package com.github.nacabaro.vbhelper.domain.card
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.ForeignKey
|
||||
import androidx.room.Index
|
||||
import androidx.room.PrimaryKey
|
||||
import com.github.cfogrady.vbnfc.data.NfcCharacter
|
||||
|
||||
@Entity(
|
||||
indices = [
|
||||
Index(value = ["fromCharaId"]),
|
||||
Index(value = ["toCharaId"])
|
||||
],
|
||||
foreignKeys = [
|
||||
ForeignKey(
|
||||
entity = CardCharacter::class,
|
||||
|
||||
@ -2,14 +2,9 @@ package com.github.nacabaro.vbhelper.domain.card
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.ForeignKey
|
||||
import androidx.room.Index
|
||||
import androidx.room.PrimaryKey
|
||||
|
||||
@Entity(
|
||||
indices = [
|
||||
Index(value = ["charaId"]),
|
||||
Index(value = ["toCharaId"])
|
||||
],
|
||||
foreignKeys = [
|
||||
ForeignKey(
|
||||
entity = CardCharacter::class,
|
||||
|
||||
@ -2,12 +2,10 @@ package com.github.nacabaro.vbhelper.domain.device_data
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.ForeignKey
|
||||
import androidx.room.Index
|
||||
import androidx.room.PrimaryKey
|
||||
import com.github.cfogrady.vbnfc.vb.SpecialMission
|
||||
|
||||
@Entity(
|
||||
indices = [Index(value = ["characterId"])],
|
||||
foreignKeys = [
|
||||
ForeignKey(
|
||||
entity = UserCharacter::class,
|
||||
|
||||
@ -2,15 +2,10 @@ package com.github.nacabaro.vbhelper.domain.device_data
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.ForeignKey
|
||||
import androidx.room.Index
|
||||
import androidx.room.PrimaryKey
|
||||
import com.github.nacabaro.vbhelper.domain.card.CardCharacter
|
||||
|
||||
@Entity(
|
||||
indices = [
|
||||
Index(value = ["monId"]),
|
||||
Index(value = ["stageId"])
|
||||
],
|
||||
foreignKeys = [
|
||||
ForeignKey(
|
||||
entity = UserCharacter::class,
|
||||
|
||||
@ -2,14 +2,12 @@ package com.github.nacabaro.vbhelper.domain.device_data
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.ForeignKey
|
||||
import androidx.room.Index
|
||||
import androidx.room.PrimaryKey
|
||||
import com.github.cfogrady.vbnfc.data.NfcCharacter
|
||||
import com.github.nacabaro.vbhelper.utils.DeviceType
|
||||
import com.github.nacabaro.vbhelper.domain.card.CardCharacter
|
||||
|
||||
@Entity(
|
||||
indices = [Index(value = ["charId"])],
|
||||
foreignKeys = [
|
||||
ForeignKey(
|
||||
entity = CardCharacter::class,
|
||||
|
||||
@ -2,11 +2,9 @@ package com.github.nacabaro.vbhelper.domain.device_data
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.ForeignKey
|
||||
import androidx.room.Index
|
||||
import androidx.room.PrimaryKey
|
||||
|
||||
@Entity(
|
||||
indices = [Index(value = ["charId"])],
|
||||
foreignKeys = [
|
||||
ForeignKey(
|
||||
entity = UserCharacter::class,
|
||||
|
||||
@ -6,7 +6,6 @@ import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
@ -15,7 +14,6 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.NavGraph.Companion.findStartDestination
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import com.github.nacabaro.vbhelper.di.VBHelper
|
||||
@ -60,21 +58,6 @@ fun AppNavigation(
|
||||
) {
|
||||
val navController = rememberNavController()
|
||||
|
||||
LaunchedEffect(initialRoute) {
|
||||
val route = initialRoute ?: return@LaunchedEffect
|
||||
if (navController.currentBackStackEntry?.destination?.route == route) {
|
||||
return@LaunchedEffect
|
||||
}
|
||||
navController.navigate(route) {
|
||||
popUpTo(navController.graph.findStartDestination().id) {
|
||||
inclusive = false
|
||||
saveState = false
|
||||
}
|
||||
launchSingleTop = true
|
||||
restoreState = false
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
bottomBar = {
|
||||
BottomNavigationBar(navController = navController)
|
||||
|
||||
@ -7,7 +7,6 @@ import androidx.compose.material3.Icon
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.NavGraph.Companion.findStartDestination
|
||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||
import androidx.compose.ui.res.stringResource
|
||||
|
||||
@ -31,14 +30,17 @@ fun BottomNavigationBar(navController: NavController) {
|
||||
label = { Text(text = stringResource(item.label)) },
|
||||
selected = currentRoute == item.route,
|
||||
onClick = {
|
||||
navController.navigate(item.route) {
|
||||
// Always route tab clicks to each tab's root screen.
|
||||
popUpTo(navController.graph.findStartDestination().id) {
|
||||
inclusive = false
|
||||
saveState = false
|
||||
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
|
||||
}
|
||||
launchSingleTop = true
|
||||
restoreState = false
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@ -6,8 +6,8 @@ import androidx.annotation.StringRes
|
||||
|
||||
sealed class NavigationItems(
|
||||
val route: String,
|
||||
@param:DrawableRes val icon: Int,
|
||||
@param:StringRes val label: Int
|
||||
@DrawableRes val icon: Int,
|
||||
@StringRes val label: Int
|
||||
) {
|
||||
object Scan : NavigationItems(
|
||||
"Scan/{characterId}",
|
||||
|
||||
@ -51,7 +51,6 @@ import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.media.MediaPlayer
|
||||
import android.os.Environment
|
||||
import android.widget.Toast
|
||||
//import androidx.compose.animation.core.animateFloatAsState
|
||||
//import androidx.compose.animation.core.tween
|
||||
import kotlinx.coroutines.delay
|
||||
@ -65,8 +64,6 @@ import com.github.nacabaro.vbhelper.battle.APIBattleCharacter
|
||||
import android.util.Log
|
||||
import com.github.nacabaro.vbhelper.components.TopBanner
|
||||
import com.github.nacabaro.vbhelper.battle.RetrofitHelper
|
||||
import com.github.nacabaro.vbhelper.battle.extractSessionToken
|
||||
import com.github.nacabaro.vbhelper.battle.extractUserId
|
||||
import com.github.nacabaro.vbhelper.battle.AttackSpriteImage
|
||||
import com.github.nacabaro.vbhelper.battle.SpriteFileManager
|
||||
import com.github.nacabaro.vbhelper.battle.ArenaBattleSystem
|
||||
@ -74,7 +71,6 @@ import com.github.nacabaro.vbhelper.battle.DigimonAnimationType
|
||||
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.combine
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
@ -903,6 +899,7 @@ fun MiddleBattleView(
|
||||
y = enemyVerticalOffset + 40.dp
|
||||
),
|
||||
contentScale = ContentScale.Fit,
|
||||
reloadMappings = false,
|
||||
animationOffset = 375L // Offset enemy animation by half the idle duration
|
||||
)
|
||||
|
||||
@ -989,6 +986,7 @@ fun MiddleBattleView(
|
||||
y = playerVerticalOffset - 40.dp
|
||||
),
|
||||
contentScale = ContentScale.Fit,
|
||||
reloadMappings = false,
|
||||
animationOffset = 0L // Player animation starts immediately
|
||||
)
|
||||
|
||||
@ -1074,30 +1072,19 @@ fun MiddleBattleView(
|
||||
|
||||
// Capture userId for use in this lambda
|
||||
val playerUserId = userId
|
||||
if (playerUserId == null) {
|
||||
context?.let {
|
||||
Toast.makeText(it, "Session missing user id. Please re-authenticate.", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
battleSystem.resetAttackState()
|
||||
battleSystem.enableAttackButton()
|
||||
return@Button
|
||||
}
|
||||
|
||||
// Get crit bar progress as float (0.0f to 100.0f)
|
||||
val critBarProgressFloat = battleSystem.critBarProgress.toFloat()
|
||||
|
||||
// Determine player and opponent stages
|
||||
// activeCharacter.stage is DB stage (2=rookie, 3=champion, 4=ultimate, 5=mega)
|
||||
// API expects stage 0-3
|
||||
val playerStage = when (activeCharacter?.stage) {
|
||||
2 -> 0 // rookie
|
||||
3 -> 1 // champion
|
||||
4 -> 2 // ultimate
|
||||
5 -> 3 // mega
|
||||
0 -> 0 // rookie
|
||||
1 -> 1 // champion
|
||||
2 -> 2 // ultimate
|
||||
3 -> 3 // mega
|
||||
else -> 0
|
||||
}
|
||||
|
||||
// opponentCharacter.stage comes from the API and is already 0-3
|
||||
val opponentStage = when (opponentCharacter?.stage) {
|
||||
0 -> 0 // rookie
|
||||
1 -> 1 // champion
|
||||
@ -1114,11 +1101,11 @@ fun MiddleBattleView(
|
||||
RetrofitHelper().getPVPWinner(
|
||||
ctx,
|
||||
1,
|
||||
playerUserId,
|
||||
activeCharacter?.charaId ?: "dim011_mon01",
|
||||
playerUserId ?: 2L,
|
||||
activeCharacter?.name ?: "Player",
|
||||
playerStage,
|
||||
battleSystem.critBarProgress,
|
||||
opponentCharacter?.charaId ?: "dim011_mon01",
|
||||
opponentStage,
|
||||
opponentCharacter?.name ?: "Opponent",
|
||||
opponentStage
|
||||
) { apiResult ->
|
||||
// Handle API response here
|
||||
@ -1322,6 +1309,7 @@ fun PlayerBattleView(
|
||||
y = verticalOffset
|
||||
),
|
||||
contentScale = ContentScale.Fit,
|
||||
reloadMappings = false,
|
||||
animationOffset = 0L // Player animation starts immediately
|
||||
)
|
||||
|
||||
@ -1503,6 +1491,7 @@ fun EnemyBattleView(
|
||||
y = verticalOffset
|
||||
),
|
||||
contentScale = ContentScale.Fit,
|
||||
reloadMappings = false,
|
||||
animationOffset = 375L // Offset enemy animation by half the idle duration
|
||||
)
|
||||
|
||||
@ -1652,63 +1641,8 @@ fun BattlesScreen() {
|
||||
// Track processed tokens to prevent duplicate API calls
|
||||
// Use rememberSaveable to persist across configuration changes (screen rotation)
|
||||
var processedTokens by rememberSaveable { mutableStateOf<Set<String>>(emptySet()) }
|
||||
var isProcessingAuthCallback by rememberSaveable { mutableStateOf(false) }
|
||||
var authBrowserLaunchInFlight by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
var opponentsList by remember { mutableStateOf(ArrayList<APIBattleCharacter>()) }
|
||||
var isLoadingOpponents by remember { mutableStateOf(false) }
|
||||
var authReadyToken by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
battleAuthContainer.authRepository.sessionToken
|
||||
.combine(battleAuthContainer.authRepository.authToken) { sessionToken, nacatechToken ->
|
||||
sessionToken?.takeIf { it.isNotBlank() }
|
||||
?: nacatechToken?.takeIf { it.isNotBlank() }
|
||||
}
|
||||
.collect { token ->
|
||||
authReadyToken = token
|
||||
if (token.isNullOrBlank() && opponentsList.isNotEmpty()) {
|
||||
opponentsList = ArrayList()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val openBattleAuthPage: () -> Unit = openAuth@{
|
||||
if (isAuthenticated && !authReadyToken.isNullOrBlank()) {
|
||||
println("BATTLESCREEN: Skipping auth browser launch because session is already ready")
|
||||
return@openAuth
|
||||
}
|
||||
if (isProcessingAuthCallback) {
|
||||
println("BATTLESCREEN: Skipping auth browser launch while callback is being processed")
|
||||
return@openAuth
|
||||
}
|
||||
if (authBrowserLaunchInFlight) {
|
||||
println("BATTLESCREEN: Auth browser launch already in flight, skipping duplicate request")
|
||||
return@openAuth
|
||||
}
|
||||
|
||||
val authUrl = "http://auth.nacatech.es/begin?app=443654920&redirect_uri=vbhelper://auth?token="
|
||||
authBrowserLaunchInFlight = true
|
||||
try {
|
||||
val customTabsIntent = androidx.browser.customtabs.CustomTabsIntent.Builder()
|
||||
.setShowTitle(true)
|
||||
.build()
|
||||
customTabsIntent.launchUrl(context, Uri.parse(authUrl))
|
||||
println("BATTLESCREEN: Opened auth URL in Custom Tab: $authUrl")
|
||||
} catch (e: Exception) {
|
||||
authBrowserLaunchInFlight = false
|
||||
println("BATTLESCREEN: Failed to open auth URL in Custom Tab: ${e.message}")
|
||||
// Fall back to external browser if Custom Tabs is unavailable
|
||||
try {
|
||||
val fallbackIntent = Intent(Intent.ACTION_VIEW, Uri.parse(authUrl))
|
||||
context.startActivity(fallbackIntent)
|
||||
println("BATTLESCREEN: Fallback to external browser succeeded")
|
||||
} catch (e2: Exception) {
|
||||
println("BATTLESCREEN: Fallback browser also failed: ${e2.message}")
|
||||
e2.printStackTrace()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var activeCharacter by remember { mutableStateOf<APIBattleCharacter?>(null) }
|
||||
var selectedOpponent by remember { mutableStateOf<APIBattleCharacter?>(null) }
|
||||
@ -1790,85 +1724,39 @@ fun BattlesScreen() {
|
||||
// Determine if player can battle based on stage (derived from activeUserCharacter)
|
||||
val canBattle = activeUserCharacter?.stage?.let { it >= 2 } ?: false
|
||||
|
||||
val loadOpponentsIfReady: () -> Unit = loadOpponents@{
|
||||
val currentCharacter = activeUserCharacter
|
||||
val readyToken = authReadyToken
|
||||
|
||||
// Load opponents automatically based on player's stage
|
||||
// Only load if authenticated and character is ready
|
||||
LaunchedEffect(activeUserCharacter, isAuthenticated) {
|
||||
// Wait for authentication to complete before loading opponents
|
||||
if (!isAuthenticated) {
|
||||
if (opponentsList.isNotEmpty()) {
|
||||
opponentsList = ArrayList()
|
||||
}
|
||||
return@loadOpponents
|
||||
return@LaunchedEffect
|
||||
}
|
||||
|
||||
if (readyToken.isNullOrBlank()) {
|
||||
println("BATTLESCREEN: Skipping opponent load - auth token not persisted yet")
|
||||
return@loadOpponents
|
||||
}
|
||||
|
||||
if (isLoadingOpponents) {
|
||||
println("BATTLESCREEN: Opponents load already in flight, skipping duplicate request")
|
||||
return@loadOpponents
|
||||
}
|
||||
|
||||
if (currentCharacter != null && currentCharacter.stage >= 2) {
|
||||
val battleType = when (currentCharacter.stage) {
|
||||
2 -> "rookie"
|
||||
3 -> "champion"
|
||||
4 -> "ultimate"
|
||||
5 -> "mega"
|
||||
else -> null
|
||||
}
|
||||
if (battleType == null) {
|
||||
println("BATTLESCREEN: Stage ${currentCharacter.stage} has no battle type, skipping")
|
||||
return@loadOpponents
|
||||
}
|
||||
val currentCharacter = activeUserCharacter
|
||||
if (currentCharacter != null && canBattle && playerBattleType != null) {
|
||||
try {
|
||||
isLoadingOpponents = true
|
||||
RetrofitHelper().getOpponents(context, battleType, onComplete = {
|
||||
isLoadingOpponents = false
|
||||
}) { opponents ->
|
||||
RetrofitHelper().getOpponents(context, playerBattleType!!) { opponents ->
|
||||
try {
|
||||
// Create a new list to trigger UI recomposition
|
||||
opponentsList = ArrayList(opponents.opponentsList)
|
||||
println("BATTLESCREEN: Loaded ${opponents.opponentsList.size} opponents for stage $battleType")
|
||||
} catch (e: Exception) {
|
||||
Log.d(TAG, "Error processing opponents data: ${e.message}")
|
||||
e.printStackTrace()
|
||||
} catch (e: Exception) {
|
||||
Log.d(TAG, "Error processing opponents data: ${e.message}")
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.d(TAG,"Error calling getOpponents: ${e.message}")
|
||||
e.printStackTrace()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
isLoadingOpponents = false
|
||||
Log.d(TAG, "Error calling getOpponents: ${e.message}")
|
||||
e.printStackTrace()
|
||||
}
|
||||
} else {
|
||||
if (opponentsList.isNotEmpty()) {
|
||||
opponentsList = ArrayList()
|
||||
println("BATTLESCREEN: Cannot load opponents - activeUserCharacter: $currentCharacter")
|
||||
println("BATTLESCREEN: canBattle: $canBattle")
|
||||
println("BATTLESCREEN: playerBattleType: $playerBattleType")
|
||||
println("BATTLESCREEN: currentCharacter != null: ${currentCharacter != null}")
|
||||
if (currentCharacter != null) {
|
||||
println("BATTLESCREEN: currentCharacter.stage: ${currentCharacter.stage}")
|
||||
println("BATTLESCREEN: currentCharacter.stage >= 2: ${currentCharacter.stage >= 2}")
|
||||
}
|
||||
println("BATTLESCREEN: Cannot load opponents - stage ${currentCharacter?.stage}, must be >= 2")
|
||||
}
|
||||
}
|
||||
|
||||
// Load opponents automatically based on player's stage once auth is truly ready.
|
||||
LaunchedEffect(activeUserCharacter, isAuthenticated, authReadyToken, playerBattleType) {
|
||||
loadOpponentsIfReady()
|
||||
}
|
||||
|
||||
// Re-fetch opponents when returning from browser (resume)
|
||||
DisposableEffect(context, isAuthenticated, activeUserCharacter, canBattle, playerBattleType, authReadyToken) {
|
||||
val activity = context as? ComponentActivity
|
||||
val lifecycleOwner = activity as? LifecycleOwner
|
||||
|
||||
val observer = LifecycleEventObserver { _, event ->
|
||||
if (event == Lifecycle.Event.ON_RESUME) {
|
||||
if (isAuthenticated && !authReadyToken.isNullOrBlank()) {
|
||||
loadOpponentsIfReady()
|
||||
}
|
||||
}
|
||||
}
|
||||
lifecycleOwner?.lifecycle?.addObserver(observer)
|
||||
onDispose {
|
||||
lifecycleOwner?.lifecycle?.removeObserver(observer)
|
||||
}
|
||||
}
|
||||
|
||||
@ -1887,19 +1775,17 @@ fun BattlesScreen() {
|
||||
if (!processedTokens.contains(token)) {
|
||||
// Mark token as being processed IMMEDIATELY to prevent duplicate API calls
|
||||
processedTokens = processedTokens + token
|
||||
isProcessingAuthCallback = true
|
||||
authBrowserLaunchInFlight = false
|
||||
|
||||
// Exchange token with battle server
|
||||
RetrofitHelper().authenticate(context, token) { response ->
|
||||
if (response.success) {
|
||||
// Extract userId and sessionToken from response
|
||||
val extractedUserId = response.extractUserId()
|
||||
val sessionToken = response.extractSessionToken()
|
||||
val extractedUserId = response.userInfo?.userId?.toLongOrNull()
|
||||
val sessionToken = response.sessionToken
|
||||
|
||||
println("BATTLESCREEN: Authentication successful, userId: $extractedUserId, sessionToken: ${if (sessionToken != null) "present" else "missing"}")
|
||||
|
||||
// Persist auth first so follow-up API calls can immediately use the token.
|
||||
// Store both nacatech token (for re-auth) and sessionToken (for API calls)
|
||||
kotlinx.coroutines.CoroutineScope(Dispatchers.IO).launch {
|
||||
battleAuthContainer.authRepository.setAuthenticated(
|
||||
isAuthenticated = true,
|
||||
@ -1907,16 +1793,14 @@ fun BattlesScreen() {
|
||||
sessionToken = sessionToken,
|
||||
userId = extractedUserId
|
||||
)
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
isAuthenticated = true
|
||||
isCheckingAuth = false
|
||||
userId = extractedUserId
|
||||
isProcessingAuthCallback = false
|
||||
authBrowserLaunchInFlight = false
|
||||
println("BATTLESCREEN: Authentication successful, userId: $extractedUserId")
|
||||
android.widget.Toast.makeText(context, "Authentication successful!", android.widget.Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
// Update UI state on main thread
|
||||
kotlinx.coroutines.CoroutineScope(Dispatchers.Main).launch {
|
||||
isAuthenticated = true
|
||||
isCheckingAuth = false
|
||||
userId = extractedUserId
|
||||
println("BATTLESCREEN: Authentication successful, userId: $extractedUserId")
|
||||
android.widget.Toast.makeText(context, "Authentication successful!", android.widget.Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
} else {
|
||||
println("BATTLESCREEN: Authentication failed: ${response.message}")
|
||||
@ -1931,16 +1815,23 @@ fun BattlesScreen() {
|
||||
kotlinx.coroutines.CoroutineScope(Dispatchers.Main).launch {
|
||||
isAuthenticated = false
|
||||
isCheckingAuth = false
|
||||
isProcessingAuthCallback = false
|
||||
authBrowserLaunchInFlight = false
|
||||
println("BATTLESCREEN: Auth callback token was already used; avoiding automatic browser relaunch loop")
|
||||
}
|
||||
// Small delay to ensure state is updated
|
||||
kotlinx.coroutines.delay(100)
|
||||
// Open auth URL to get a fresh token
|
||||
val authUrl = "http://auth.nacatech.es/begin?app=443654920&redirect_uri=vbhelper://auth?token="
|
||||
val authIntent = Intent(Intent.ACTION_VIEW, Uri.parse(authUrl))
|
||||
try {
|
||||
context.startActivity(authIntent)
|
||||
println("BATTLESCREEN: Opened auth URL after token expiration: $authUrl")
|
||||
} catch (e: Exception) {
|
||||
println("BATTLESCREEN: Failed to open auth URL: ${e.message}")
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// For other errors, remove from processed set to allow retry with a new token
|
||||
println("BATTLESCREEN: Authentication failed, removing token from processed set to allow retry")
|
||||
processedTokens = processedTokens - token
|
||||
isProcessingAuthCallback = false
|
||||
authBrowserLaunchInFlight = false
|
||||
}
|
||||
// Show toast on main thread
|
||||
kotlinx.coroutines.CoroutineScope(Dispatchers.Main).launch {
|
||||
@ -1948,47 +1839,12 @@ fun BattlesScreen() {
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
println("BATTLESCREEN: Ignoring already processed auth token")
|
||||
}
|
||||
} else {
|
||||
println("BATTLESCREEN: No token found in URI: $uri (checked 'c' and 'token' parameters)")
|
||||
}
|
||||
}
|
||||
|
||||
val consumeAuthCallbackIntent: () -> Boolean = consume@{
|
||||
val currentActivity = context as? ComponentActivity ?: return@consume false
|
||||
val incomingIntent = currentActivity.intent ?: return@consume false
|
||||
if (incomingIntent.action != Intent.ACTION_VIEW) return@consume false
|
||||
|
||||
val uri = incomingIntent.data ?: return@consume false
|
||||
val isAppAuthCallback = uri.scheme == "vbhelper" && uri.host == "auth"
|
||||
val isLocalhostAuthCallback =
|
||||
(uri.scheme == "http" || uri.scheme == "https") &&
|
||||
(uri.host == "localhost" || uri.host == "127.0.0.1") &&
|
||||
uri.path?.startsWith("/authenticate") == true
|
||||
val hasAuthToken = !uri.getQueryParameter("c").isNullOrEmpty() ||
|
||||
!uri.getQueryParameter("token").isNullOrEmpty()
|
||||
|
||||
if (!hasAuthToken) {
|
||||
return@consume false
|
||||
}
|
||||
|
||||
if (!isAppAuthCallback && !isLocalhostAuthCallback) {
|
||||
return@consume false
|
||||
}
|
||||
|
||||
handleTokenFromUri(uri)
|
||||
|
||||
// Clear callback intent so reopening Battles does not replay stale deep links.
|
||||
currentActivity.setIntent(Intent(incomingIntent).apply {
|
||||
action = Intent.ACTION_MAIN
|
||||
data = null
|
||||
})
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
// Check authentication status on load
|
||||
LaunchedEffect(Unit) {
|
||||
try {
|
||||
@ -1996,7 +1852,6 @@ fun BattlesScreen() {
|
||||
val localAuthState = authRepository.isAuthenticated.first()
|
||||
val storedToken = authRepository.authToken.first()
|
||||
val storedUserId = authRepository.userId.first()
|
||||
val storedSessionToken = authRepository.sessionToken.first()
|
||||
|
||||
// Load stored userId if available
|
||||
if (storedUserId != null) {
|
||||
@ -2012,35 +1867,40 @@ fun BattlesScreen() {
|
||||
userId = storedUserId
|
||||
}
|
||||
|
||||
if (consumeAuthCallbackIntent()) {
|
||||
return@LaunchedEffect
|
||||
// Only check for token in intent if it's a fresh deep link (ACTION_VIEW intent)
|
||||
// This prevents processing stale tokens from previous sessions
|
||||
val activity = context as? ComponentActivity
|
||||
val intent = activity?.intent
|
||||
if (intent?.action == Intent.ACTION_VIEW) {
|
||||
intent.data?.let { uri ->
|
||||
if (uri.getQueryParameter("c") != null || uri.getQueryParameter("token") != null) {
|
||||
handleTokenFromUri(uri)
|
||||
return@LaunchedEffect // Don't open auth URL if we're processing a token
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If we have a stored token and userId, assume session is still active
|
||||
// The single-use token can't be re-validated, but the server maintains a session after initial validation
|
||||
// We'll only re-authenticate if API calls fail with authentication errors
|
||||
if (localAuthState && storedToken != null && storedToken.isNotEmpty() && (storedUserId != null || !storedSessionToken.isNullOrEmpty())) {
|
||||
if (localAuthState && storedToken != null && storedToken.isNotEmpty() && storedUserId != null) {
|
||||
// Session appears to be active - user is already authenticated
|
||||
// No need to re-validate the single-use token, just restore the session
|
||||
println("BATTLESCREEN: Restoring active session (userId: $storedUserId, sessionTokenPresent=${!storedSessionToken.isNullOrEmpty()})")
|
||||
println("BATTLESCREEN: Restoring active session (userId: $storedUserId)")
|
||||
isAuthenticated = true
|
||||
isCheckingAuth = false
|
||||
userId = storedUserId
|
||||
// Session is restored, no need to validate token again
|
||||
} else if (localAuthState && storedToken != null && storedToken.isNotEmpty() && storedSessionToken.isNullOrEmpty()) {
|
||||
} else if (localAuthState && storedToken != null && storedToken.isNotEmpty()) {
|
||||
// We have a token but no userId - try to validate once to get userId
|
||||
// This should only happen on first login or if userId was lost
|
||||
println("BATTLESCREEN: Have token but no userId, validating once to get userId...")
|
||||
RetrofitHelper().authenticate(
|
||||
context = context,
|
||||
token = storedToken,
|
||||
showUserFacingErrors = false,
|
||||
) { response ->
|
||||
RetrofitHelper().authenticate(context, storedToken) { response ->
|
||||
// Update UI on main thread
|
||||
kotlinx.coroutines.CoroutineScope(Dispatchers.Main).launch {
|
||||
if (response.success) {
|
||||
val extractedUserId = response.extractUserId()
|
||||
val sessionToken = response.extractSessionToken()
|
||||
val extractedUserId = response.userInfo?.userId?.toLongOrNull()
|
||||
val sessionToken = response.sessionToken
|
||||
|
||||
// Update stored userId and sessionToken
|
||||
if (extractedUserId != null) {
|
||||
@ -2064,22 +1924,8 @@ fun BattlesScreen() {
|
||||
response.message?.contains("nonce") == true ||
|
||||
response.message?.contains("invalid") == true ||
|
||||
response.message?.contains("expired") == true
|
||||
val latestSessionToken = withContext(Dispatchers.IO) {
|
||||
authRepository.sessionToken.first()
|
||||
}
|
||||
val latestUserId = withContext(Dispatchers.IO) {
|
||||
authRepository.userId.first()
|
||||
}
|
||||
|
||||
if (isCriticalError) {
|
||||
// A parallel callback auth can complete before this fallback check returns.
|
||||
if (!latestSessionToken.isNullOrEmpty() || latestUserId != null) {
|
||||
println("BATTLESCREEN: Ignoring nonce validation failure because a valid session already exists")
|
||||
isAuthenticated = true
|
||||
isCheckingAuth = false
|
||||
userId = latestUserId ?: userId
|
||||
return@launch
|
||||
}
|
||||
// Critical error - token is invalid, need to re-authenticate
|
||||
println("BATTLESCREEN: Critical authentication error, clearing state and redirecting")
|
||||
kotlinx.coroutines.CoroutineScope(Dispatchers.IO).launch {
|
||||
@ -2087,7 +1933,11 @@ fun BattlesScreen() {
|
||||
}
|
||||
isAuthenticated = false
|
||||
isCheckingAuth = false
|
||||
openBattleAuthPage()
|
||||
// Open auth URL
|
||||
val authUrl = "http://auth.nacatech.es/begin?app=443654920&redirect_uri=vbhelper://auth?token="
|
||||
val authIntent = Intent(Intent.ACTION_VIEW, Uri.parse(authUrl))
|
||||
context.startActivity(authIntent)
|
||||
println("BATTLESCREEN: Opened auth URL after critical validation failure: $authUrl")
|
||||
} else {
|
||||
// Non-critical error (e.g., network issue) - keep authenticated state
|
||||
println("BATTLESCREEN: Non-critical validation error, keeping authenticated state")
|
||||
@ -2102,7 +1952,10 @@ fun BattlesScreen() {
|
||||
isAuthenticated = false
|
||||
isCheckingAuth = false
|
||||
// If not authenticated and no fresh token in intent, open auth URL
|
||||
openBattleAuthPage()
|
||||
val authUrl = "http://auth.nacatech.es/begin?app=443654920&redirect_uri=vbhelper://auth?token="
|
||||
val authIntent = Intent(Intent.ACTION_VIEW, Uri.parse(authUrl))
|
||||
context.startActivity(authIntent)
|
||||
println("BATTLESCREEN: Opened auth URL: $authUrl")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
println("BATTLESCREEN: Error checking authentication status: ${e.message}")
|
||||
@ -2111,11 +1964,42 @@ fun BattlesScreen() {
|
||||
}
|
||||
}
|
||||
|
||||
// Handle deep link callback once on initial load and consume it.
|
||||
// Handle deep link callback to get token
|
||||
// Check intent data on initial load - handle both vbhelper:// and http://localhost:8080/authenticate?c=
|
||||
// Only process if it's a fresh ACTION_VIEW intent (deep link)
|
||||
LaunchedEffect(Unit) {
|
||||
// Small delay to ensure activity is fully initialized
|
||||
kotlinx.coroutines.delay(100)
|
||||
consumeAuthCallbackIntent()
|
||||
|
||||
val activity = context as? ComponentActivity
|
||||
val intent = activity?.intent
|
||||
|
||||
// Only process if this is a fresh deep link (ACTION_VIEW)
|
||||
if (intent?.action == Intent.ACTION_VIEW) {
|
||||
val uri = intent.data
|
||||
if (uri != null) {
|
||||
|
||||
// Handle vbhelper://auth?token= or vbhelper://auth?c= deep link
|
||||
if (uri.scheme == "vbhelper" && uri.host == "auth") {
|
||||
handleTokenFromUri(uri)
|
||||
}
|
||||
// Handle http://localhost:8080/authenticate?c= redirect
|
||||
else if ((uri.scheme == "http" || uri.scheme == "https") &&
|
||||
(uri.host == "localhost" || uri.host == "127.0.0.1" || uri.host?.contains("8080") == true)) {
|
||||
handleTokenFromUri(uri)
|
||||
}
|
||||
// Also check if there's a 'c' or 'token' parameter in any URL
|
||||
else if (uri.getQueryParameter("c") != null || uri.getQueryParameter("token") != null) {
|
||||
handleTokenFromUri(uri)
|
||||
} else {
|
||||
println("BATTLESCREEN: URI found but no token parameter detected")
|
||||
}
|
||||
} else {
|
||||
println("BATTLESCREEN: ACTION_VIEW intent but no URI found")
|
||||
}
|
||||
} else {
|
||||
println("BATTLESCREEN: Not an ACTION_VIEW intent, skipping deep link processing")
|
||||
}
|
||||
}
|
||||
|
||||
// Check intent when screen becomes visible or when authentication state changes
|
||||
@ -2125,10 +2009,15 @@ fun BattlesScreen() {
|
||||
val lifecycleOwner = activity as? LifecycleOwner
|
||||
|
||||
val observer = LifecycleEventObserver { _, event ->
|
||||
if (event == Lifecycle.Event.ON_RESUME) {
|
||||
authBrowserLaunchInFlight = false
|
||||
if (!isAuthenticated) {
|
||||
consumeAuthCallbackIntent()
|
||||
if (event == Lifecycle.Event.ON_RESUME && !isAuthenticated) {
|
||||
// Check intent data when activity resumes - only if it's a fresh ACTION_VIEW intent
|
||||
val intent = activity?.intent
|
||||
if (intent?.action == Intent.ACTION_VIEW) {
|
||||
intent.data?.let { uri ->
|
||||
if (uri.getQueryParameter("c") != null || uri.getQueryParameter("token") != null) {
|
||||
handleTokenFromUri(uri)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -2149,16 +2038,45 @@ fun BattlesScreen() {
|
||||
isAuthenticated = false
|
||||
isCheckingAuth = false
|
||||
// Open auth URL to get a fresh token
|
||||
openBattleAuthPage()
|
||||
val authUrl = "http://auth.nacatech.es/begin?app=443654920&redirect_uri=vbhelper://auth?token="
|
||||
val authIntent = Intent(Intent.ACTION_VIEW, Uri.parse(authUrl))
|
||||
try {
|
||||
context.startActivity(authIntent)
|
||||
println("BATTLESCREEN: Opened auth URL after token expiration: $authUrl")
|
||||
} catch (e: Exception) {
|
||||
println("BATTLESCREEN: Failed to open auth URL: ${e.message}")
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Also check for callback when auth state changes.
|
||||
// Also check intent when authentication state changes
|
||||
// Only process if it's a fresh ACTION_VIEW intent (deep link)
|
||||
LaunchedEffect(isAuthenticated) {
|
||||
if (!isAuthenticated) {
|
||||
kotlinx.coroutines.delay(200) // Small delay to ensure intent is available
|
||||
consumeAuthCallbackIntent()
|
||||
val activity = context as? ComponentActivity
|
||||
val intent = activity?.intent
|
||||
// Only process if this is a fresh deep link (ACTION_VIEW)
|
||||
if (intent?.action == Intent.ACTION_VIEW) {
|
||||
intent.data?.let { uri ->
|
||||
println("BATTLESCREEN: Re-checking ACTION_VIEW intent data - URI: $uri, scheme: ${uri.scheme}, host: ${uri.host}")
|
||||
// Handle vbhelper://auth?token= or vbhelper://auth?c= deep link
|
||||
if (uri.scheme == "vbhelper" && uri.host == "auth") {
|
||||
handleTokenFromUri(uri)
|
||||
}
|
||||
// Handle http://localhost:8080/authenticate?c= redirect
|
||||
else if ((uri.scheme == "http" || uri.scheme == "https") &&
|
||||
(uri.host == "localhost" || uri.host == "127.0.0.1" || uri.host?.contains("8080") == true)) {
|
||||
handleTokenFromUri(uri)
|
||||
}
|
||||
// Also check if there's a 'c' or 'token' parameter in any URL
|
||||
else if (uri.getQueryParameter("c") != null || uri.getQueryParameter("token") != null) {
|
||||
handleTokenFromUri(uri)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -2166,8 +2084,9 @@ fun BattlesScreen() {
|
||||
// Only check if permission is granted
|
||||
LaunchedEffect(hasStoragePermission) {
|
||||
if (hasStoragePermission) {
|
||||
val spriteFileManager = SpriteFileManager()
|
||||
if (!spriteFileManager.checkSpriteFilesExist()) {
|
||||
val spriteFileManager = SpriteFileManager(context)
|
||||
if (spriteFileManager.checkSpriteFilesExist()) {
|
||||
} else {
|
||||
println("BATTLESCREEN: Sprite files not found in external storage")
|
||||
}
|
||||
} else {
|
||||
@ -2314,10 +2233,6 @@ fun BattlesScreen() {
|
||||
color = Color.Gray,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Button(onClick = { openBattleAuthPage() }) {
|
||||
Text("Open Login")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -2352,12 +2267,6 @@ fun BattlesScreen() {
|
||||
items(opponentsList) { opponent ->
|
||||
Button(
|
||||
onClick = {
|
||||
val currentUserId = userId
|
||||
if (currentUserId == null) {
|
||||
Toast.makeText(context, "Session missing user id. Please re-authenticate.", Toast.LENGTH_SHORT).show()
|
||||
return@Button
|
||||
}
|
||||
|
||||
activeCardId?.let { cardId ->
|
||||
selectedOpponent = opponent
|
||||
// Randomly select background set (0, 1, or 2)
|
||||
@ -2372,7 +2281,7 @@ fun BattlesScreen() {
|
||||
else -> 0
|
||||
}
|
||||
|
||||
RetrofitHelper().getPVPWinner(context, 0, currentUserId, cardId, apiStage, 0, opponent.charaId, apiStage) { apiResult ->
|
||||
RetrofitHelper().getPVPWinner(context, 0, userId ?: 2L, cardId, apiStage, 0, opponent.charaId, apiStage) { apiResult ->
|
||||
// Check if there's an existing match
|
||||
when {
|
||||
apiResult.status.contains("Existing match found", ignoreCase = true) -> {
|
||||
@ -2473,17 +2382,14 @@ fun BattlesScreen() {
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
// Determine player and opponent stages
|
||||
// activeCharacter.stage is DB stage (2=rookie, 3=champion, 4=ultimate, 5=mega)
|
||||
// API expects stage 0-3
|
||||
val playerStage = when (activeCharacter?.stage) {
|
||||
2 -> 0 // rookie
|
||||
3 -> 1 // champion
|
||||
4 -> 2 // ultimate
|
||||
5 -> 3 // mega
|
||||
0 -> 0 // rookie
|
||||
1 -> 1 // champion
|
||||
2 -> 2 // ultimate
|
||||
3 -> 3 // mega
|
||||
else -> 0
|
||||
}
|
||||
|
||||
// selectedOpponent.stage comes from the API and is already 0-3
|
||||
val opponentStage = when (selectedOpponent?.stage) {
|
||||
0 -> 0 // rookie
|
||||
1 -> 1 // champion
|
||||
@ -2496,16 +2402,11 @@ fun BattlesScreen() {
|
||||
RetrofitHelper().getPVPWinner(
|
||||
context,
|
||||
1,
|
||||
userId ?: run {
|
||||
Toast.makeText(context, "Session missing user id. Please re-authenticate.", Toast.LENGTH_SHORT).show()
|
||||
isWinnerLoaded = true
|
||||
winnerName = "Unknown"
|
||||
return@LaunchedEffect
|
||||
},
|
||||
activeCharacter?.charaId ?: "dim011_mon01",
|
||||
userId ?: 2L,
|
||||
activeCharacter?.name ?: "Player",
|
||||
playerStage,
|
||||
0,
|
||||
selectedOpponent?.charaId ?: "dim011_mon01",
|
||||
opponentStage,
|
||||
selectedOpponent?.name ?: "Opponent",
|
||||
opponentStage
|
||||
) { apiResult ->
|
||||
// Winner might be empty in first call, but we can check HP values
|
||||
@ -2514,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
|
||||
@ -2541,21 +2442,18 @@ 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 = if (apiResult.winner.isNotEmpty()) apiResult.winner else if (playerWon) "You" else "Opponent"
|
||||
winnerName = apiResult.winner ?: if (playerWon) "You" else "Opponent"
|
||||
isWinnerLoaded = true
|
||||
|
||||
// Then send the cleanup call - this will have the actual winner name
|
||||
RetrofitHelper().getPVPWinner(
|
||||
context,
|
||||
2,
|
||||
userId ?: run {
|
||||
Toast.makeText(context, "Session missing user id. Please re-authenticate.", Toast.LENGTH_SHORT).show()
|
||||
return@getPVPWinner
|
||||
},
|
||||
activeCharacter?.charaId ?: "dim011_mon01",
|
||||
userId ?: 2L,
|
||||
activeCharacter?.name ?: "Player",
|
||||
playerStage,
|
||||
0,
|
||||
selectedOpponent?.charaId ?: "dim011_mon01",
|
||||
opponentStage,
|
||||
selectedOpponent?.name ?: "Opponent",
|
||||
opponentStage
|
||||
) { cleanupResult ->
|
||||
// Update winner name from cleanup call if available
|
||||
@ -2567,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
|
||||
@ -2704,12 +2602,7 @@ fun BattlesScreen() {
|
||||
RetrofitHelper().getPVPWinner(
|
||||
context,
|
||||
0,
|
||||
userId ?: run {
|
||||
Toast.makeText(context, "Session missing user id. Please re-authenticate.", Toast.LENGTH_SHORT).show()
|
||||
showResumeDialog = false
|
||||
existingMatchState = null
|
||||
return@let
|
||||
},
|
||||
userId ?: 2L,
|
||||
cardId,
|
||||
apiStage,
|
||||
0,
|
||||
@ -2834,12 +2727,7 @@ fun BattlesScreen() {
|
||||
RetrofitHelper().getPVPWinner(
|
||||
context,
|
||||
0,
|
||||
userId ?: run {
|
||||
Toast.makeText(context, "Session missing user id. Please re-authenticate.", Toast.LENGTH_SHORT).show()
|
||||
showResumeDialog = false
|
||||
existingMatchState = null
|
||||
return@let
|
||||
},
|
||||
userId ?: 2L,
|
||||
cardId,
|
||||
apiStage,
|
||||
0,
|
||||
|
||||
@ -33,12 +33,16 @@ import com.github.nacabaro.vbhelper.domain.device_data.BECharacterData
|
||||
import com.github.nacabaro.vbhelper.domain.device_data.VBCharacterData
|
||||
import com.github.nacabaro.vbhelper.dtos.ItemDtos
|
||||
import com.github.nacabaro.vbhelper.navigation.NavigationItems
|
||||
import com.github.nacabaro.vbhelper.screens.homeScreens.screens.BEBEmHomeScreen
|
||||
import com.github.nacabaro.vbhelper.screens.homeScreens.screens.BEDiMHomeScreen
|
||||
import com.github.nacabaro.vbhelper.screens.homeScreens.screens.VBDiMHomeScreen
|
||||
import com.github.nacabaro.vbhelper.screens.itemsScreen.ObtainedItemDialog
|
||||
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
|
||||
@ -72,8 +76,9 @@ fun HomeScreen(
|
||||
?: flowOf(emptyList())
|
||||
).collectAsState(initial = emptyList())
|
||||
|
||||
val specialMissions by (
|
||||
val vbSpecialMissions by (
|
||||
activeMon
|
||||
?.takeIf { it.characterType == DeviceType.VBDevice }
|
||||
?.let { chara ->
|
||||
storageRepository.getSpecialMissions(chara.id)
|
||||
}
|
||||
@ -141,20 +146,29 @@ fun HomeScreen(
|
||||
height = cardIconData!!.cardIconHeight
|
||||
)
|
||||
|
||||
val activeCharacter = activeMon!!
|
||||
val displaySpecialMissions = specialMissions
|
||||
|
||||
if (activeCharacter.characterType == DeviceType.BEDevice || vbData != null) {
|
||||
VBDiMHomeScreen(
|
||||
activeMon = activeCharacter,
|
||||
vbData = vbData ?: VBCharacterData(
|
||||
id = activeCharacter.id,
|
||||
generation = 0,
|
||||
totalTrophies = activeCharacter.trophies,
|
||||
),
|
||||
if (activeMon!!.isBemCard && beData != null) {
|
||||
BEBEmHomeScreen(
|
||||
activeMon = activeMon!!,
|
||||
beData = beData!!,
|
||||
transformationHistory = transformationHistory,
|
||||
contentPadding = PaddingValues(0.dp),
|
||||
specialMissions = displaySpecialMissions,
|
||||
cardIcon = cardIcon
|
||||
)
|
||||
} else if (!activeMon!!.isBemCard && activeMon!!.characterType == DeviceType.BEDevice && beData != null) {
|
||||
BEDiMHomeScreen(
|
||||
activeMon = activeMon!!,
|
||||
beData = beData!!,
|
||||
transformationHistory = transformationHistory,
|
||||
contentPadding = PaddingValues(0.dp),
|
||||
cardIcon = cardIcon
|
||||
)
|
||||
} else if (vbData != null) {
|
||||
VBDiMHomeScreen(
|
||||
activeMon = activeMon!!,
|
||||
vbData = vbData!!,
|
||||
transformationHistory = transformationHistory,
|
||||
contentPadding = PaddingValues(0.dp),
|
||||
specialMissions = vbSpecialMissions,
|
||||
homeScreenController = homeScreenController,
|
||||
onClickCollect = { item, currency ->
|
||||
collectedItem = item
|
||||
@ -164,6 +178,27 @@ fun HomeScreen(
|
||||
)
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
try {
|
||||
val intent = VitalWearCharacterExporter(application, application.container.db)
|
||||
.buildShareIntent(activeMon!!.id)
|
||||
.addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
application.startActivity(intent)
|
||||
} catch (e: Exception) {
|
||||
Toast.makeText(
|
||||
application,
|
||||
"Could not send character to VitalWear: ${e.message}",
|
||||
Toast.LENGTH_LONG
|
||||
).show()
|
||||
}
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp)
|
||||
) {
|
||||
Text(text = "Send to VitalWear")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -213,5 +248,3 @@ fun HomeScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -8,17 +8,12 @@ import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.github.nacabaro.vbhelper.dtos.ItemDtos
|
||||
import com.github.nacabaro.vbhelper.domain.items.ItemType
|
||||
import com.github.nacabaro.vbhelper.R
|
||||
|
||||
@Composable
|
||||
@ -33,12 +28,6 @@ fun ItemElement(
|
||||
.aspectRatio(1f)
|
||||
) {
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
ItemCompatibilityBadge(
|
||||
itemType = item.itemType,
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.padding(8.dp)
|
||||
)
|
||||
Icon(
|
||||
painter = painterResource(id = getIconResource(item.itemIcon)),
|
||||
contentDescription = null,
|
||||
@ -59,48 +48,3 @@ fun ItemElement(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ItemCompatibilityBadge(
|
||||
itemType: ItemType,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val (label, containerColor, contentColor) = when (itemType) {
|
||||
ItemType.VBITEM -> Triple(
|
||||
"VB",
|
||||
MaterialTheme.colorScheme.primaryContainer,
|
||||
MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
)
|
||||
ItemType.BEITEM -> Triple(
|
||||
"BE",
|
||||
MaterialTheme.colorScheme.tertiaryContainer,
|
||||
MaterialTheme.colorScheme.onTertiaryContainer,
|
||||
)
|
||||
ItemType.UNIVERSAL -> Triple(
|
||||
"VB+BE",
|
||||
MaterialTheme.colorScheme.secondaryContainer,
|
||||
MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
)
|
||||
ItemType.SPECIALMISSION -> Triple(
|
||||
"MISSION",
|
||||
MaterialTheme.colorScheme.errorContainer,
|
||||
MaterialTheme.colorScheme.onErrorContainer,
|
||||
)
|
||||
}
|
||||
|
||||
Surface(
|
||||
modifier = modifier,
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
color = containerColor,
|
||||
tonalElevation = 2.dp,
|
||||
shadowElevation = 1.dp,
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
color = contentColor,
|
||||
fontSize = 10.sp,
|
||||
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -6,7 +6,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Tab
|
||||
import androidx.compose.material3.PrimaryTabRow
|
||||
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
|
||||
) {
|
||||
|
||||
@ -7,6 +7,7 @@ import com.github.nacabaro.vbhelper.domain.device_data.SpecialMissions
|
||||
import com.github.nacabaro.vbhelper.database.AppDatabase
|
||||
import com.github.nacabaro.vbhelper.di.VBHelper
|
||||
import com.github.nacabaro.vbhelper.domain.device_data.BECharacterData
|
||||
import com.github.nacabaro.vbhelper.domain.device_data.VBCharacterData
|
||||
import com.github.nacabaro.vbhelper.dtos.ItemDtos
|
||||
import com.github.nacabaro.vbhelper.utils.DeviceType
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@ -50,12 +51,19 @@ class ItemsScreenControllerImpl (
|
||||
val item = getItem(itemId)
|
||||
val characterData = database.userCharacterDao().getCharacter(characterId)
|
||||
var beCharacterData: BECharacterData? = null
|
||||
var vbCharacterData: VBCharacterData? = null
|
||||
|
||||
if (characterData.characterType == DeviceType.BEDevice) {
|
||||
beCharacterData = database
|
||||
.userCharacterDao()
|
||||
.getBeData(characterId)
|
||||
.firstOrNull()
|
||||
|
||||
} else if (characterData.characterType == DeviceType.VBDevice) {
|
||||
vbCharacterData = database
|
||||
.userCharacterDao()
|
||||
.getVbData(characterId)
|
||||
.firstOrNull()
|
||||
}
|
||||
|
||||
if (
|
||||
@ -114,7 +122,8 @@ class ItemsScreenControllerImpl (
|
||||
.updateCharacter(characterData)
|
||||
|
||||
} else if (item.itemIcon in ItemTypes.Step8k.id .. ItemTypes.Win4.id &&
|
||||
(characterData.characterType == DeviceType.VBDevice || characterData.characterType == DeviceType.BEDevice)
|
||||
characterData.characterType == DeviceType.VBDevice &&
|
||||
vbCharacterData != null
|
||||
) {
|
||||
applySpecialMission(item.itemIcon, item.itemLength, characterId)
|
||||
}
|
||||
@ -171,12 +180,12 @@ class ItemsScreenControllerImpl (
|
||||
.getSpecialMissions(characterId)
|
||||
.first()
|
||||
|
||||
val existingMission = availableSpecialMissions.firstOrNull { it.watchId == specialMissionSlot }
|
||||
val newSpecialMission = SpecialMissions(
|
||||
id = existingMission?.id ?: 0,
|
||||
characterId = characterId,
|
||||
var newSpecialMission = availableSpecialMissions[specialMissionSlot]
|
||||
newSpecialMission = SpecialMissions(
|
||||
id = newSpecialMission.id,
|
||||
characterId = newSpecialMission.characterId,
|
||||
goal = specialMissionGoal,
|
||||
watchId = specialMissionSlot,
|
||||
watchId = newSpecialMission.watchId,
|
||||
progress = 0,
|
||||
status = SpecialMission.Status.AVAILABLE,
|
||||
timeElapsedInMinutes = 0,
|
||||
|
||||
@ -43,7 +43,7 @@ fun ChooseConnectOption(
|
||||
.padding(contentPadding)
|
||||
) {
|
||||
Text(
|
||||
text = "VitalWear/VBH",
|
||||
text = "Bandai Toys = Vital Bracelet, Vital Hero, BE Bracelet",
|
||||
fontSize = 14.sp,
|
||||
modifier = Modifier.padding(bottom = 24.dp)
|
||||
)
|
||||
|
||||
@ -24,7 +24,6 @@ import com.github.nacabaro.vbhelper.source.isMissingSecrets
|
||||
import com.github.nacabaro.vbhelper.source.proto.Secrets
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.withContext
|
||||
import com.github.nacabaro.vbhelper.R
|
||||
|
||||
@ -132,8 +131,6 @@ fun ScanScreenPreview() {
|
||||
navController = rememberNavController(),
|
||||
scanScreenController = object: ScanScreenController {
|
||||
override val secretsFlow = MutableStateFlow<Secrets>(Secrets.getDefaultInstance())
|
||||
override val detectedTransportFlow: StateFlow<DetectedTransport> = MutableStateFlow(DetectedTransport.UNKNOWN)
|
||||
override val transferStatusFlow: StateFlow<String?> = MutableStateFlow(null)
|
||||
override fun unregisterActivityLifecycleListener(key: String) { }
|
||||
override fun registerActivityLifecycleListener(
|
||||
key: String,
|
||||
@ -144,12 +141,7 @@ fun ScanScreenPreview() {
|
||||
override fun flushCharacter(cardId: Long) {}
|
||||
override fun onClickRead(secrets: Secrets, onComplete: ()->Unit, onMultipleCards: (List<Card>) -> Unit) {}
|
||||
override fun onClickCheckCard(secrets: Secrets, nfcCharacter: NfcCharacter, onComplete: () -> Unit) {}
|
||||
override fun onClickWrite(
|
||||
secrets: Secrets,
|
||||
nfcCharacter: NfcCharacter,
|
||||
characterId: Long?,
|
||||
onComplete: (ScanScreenController.WriteResult) -> Unit
|
||||
) {}
|
||||
override fun onClickWrite(secrets: Secrets, nfcCharacter: NfcCharacter, onComplete: () -> Unit) {}
|
||||
override fun cancelRead() {}
|
||||
override fun characterFromNfc(nfcCharacter: NfcCharacter, onMultipleCards: (List<Card>, NfcCharacter) -> Unit): String { return "" }
|
||||
override suspend fun characterToNfc(characterId: Long): NfcCharacter? { return null }
|
||||
|
||||
@ -5,27 +5,12 @@ import com.github.nacabaro.vbhelper.ActivityLifecycleListener
|
||||
import com.github.nacabaro.vbhelper.domain.card.Card
|
||||
import com.github.nacabaro.vbhelper.source.proto.Secrets
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
interface ScanScreenController {
|
||||
val secretsFlow: Flow<Secrets>
|
||||
val detectedTransportFlow: StateFlow<DetectedTransport>
|
||||
val transferStatusFlow: StateFlow<String?>
|
||||
|
||||
enum class WriteResult {
|
||||
MOVE_CONFIRMED,
|
||||
COPIED,
|
||||
BLOCKED_DEVICE_FULL,
|
||||
}
|
||||
|
||||
fun onClickRead(secrets: Secrets, onComplete: ()->Unit, onMultipleCards: (List<Card>) -> Unit)
|
||||
fun onClickCheckCard(secrets: Secrets, nfcCharacter: NfcCharacter, onComplete: () -> Unit)
|
||||
fun onClickWrite(
|
||||
secrets: Secrets,
|
||||
nfcCharacter: NfcCharacter,
|
||||
characterId: Long? = null,
|
||||
onComplete: (WriteResult) -> Unit
|
||||
)
|
||||
fun onClickWrite(secrets: Secrets, nfcCharacter: NfcCharacter, onComplete: () -> Unit)
|
||||
|
||||
fun cancelRead()
|
||||
|
||||
|
||||
@ -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
|
||||
@ -12,34 +11,21 @@ import android.widget.Toast
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.github.cfogrady.vbnfc.TagCommunicator
|
||||
import com.github.cfogrady.vitalwear.protos.Character
|
||||
import com.github.cfogrady.vbnfc.be.BENfcCharacter
|
||||
import com.github.cfogrady.vbnfc.data.NfcCharacter
|
||||
import com.github.cfogrady.vbnfc.data.DeviceType as NfcDeviceTypeId
|
||||
import com.github.cfogrady.vbnfc.vb.VBNfcCharacter
|
||||
import com.github.nacabaro.vbhelper.ActivityLifecycleListener
|
||||
import com.github.nacabaro.vbhelper.domain.card.Card
|
||||
import com.github.nacabaro.vbhelper.di.VBHelper
|
||||
import com.github.nacabaro.vbhelper.screens.scanScreen.converters.FromNfcConverter
|
||||
import com.github.nacabaro.vbhelper.screens.scanScreen.converters.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 com.github.nacabaro.vbhelper.utils.DeviceType
|
||||
import com.github.nacabaro.vbhelper.R
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import java.util.Arrays
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
import com.github.nacabaro.vbhelper.R
|
||||
|
||||
class ScanScreenControllerImpl(
|
||||
override val secretsFlow: Flow<Secrets>,
|
||||
@ -47,316 +33,49 @@ class ScanScreenControllerImpl(
|
||||
private val registerActivityLifecycleListener: (String, ActivityLifecycleListener)->Unit,
|
||||
private val unregisterActivityLifecycleListener: (String)->Unit,
|
||||
): ScanScreenController {
|
||||
|
||||
override val detectedTransportFlow: StateFlow<DetectedTransport>
|
||||
get() = _detectedTransportFlow
|
||||
override val transferStatusFlow: StateFlow<String?>
|
||||
get() = _transferStatusFlow
|
||||
|
||||
private val _detectedTransportFlow = MutableStateFlow(DetectedTransport.UNKNOWN)
|
||||
private val _transferStatusFlow = MutableStateFlow<String?>(null)
|
||||
private var lastScannedCharacter: NfcCharacter? = null
|
||||
private var lastRequestedCharacterId: Long? = null
|
||||
private var lastWriteCharacterId: Long? = null
|
||||
private val nfcAdapter: NfcAdapter
|
||||
private val isHandlingTag = AtomicBoolean(false)
|
||||
private var lastTagId: ByteArray? = null
|
||||
private var lastTagHandledAtMs: Long = 0L
|
||||
private val tagStateLock = Any()
|
||||
private val readerSessionCounter = AtomicLong(0L)
|
||||
@Volatile private var activeReaderSessionId: Long = 0L
|
||||
|
||||
companion object {
|
||||
private const val TAG_DEBOUNCE_MS = 1500L
|
||||
private const val TAG_IGNORE_AFTER_HANDLED_MS = 2000
|
||||
private val VITALWEAR_AID = byteArrayOf(
|
||||
0xF0.toByte(), 0x56, 0x49, 0x54, 0x41, 0x4C, 0x57, 0x45, 0x41, 0x52
|
||||
)
|
||||
private const val SW_OK = 0x9000
|
||||
// Lifecycle key for the always-on NFC suppressor that prevents Android from launching
|
||||
// com.android.apps.tag/.TagViewer whenever the watch's HCE comes into range.
|
||||
private const val LIFECYCLE_KEY_NFC_SUPPRESSOR = "nfc_suppressor"
|
||||
}
|
||||
|
||||
init {
|
||||
val maybeNfcAdapter = NfcAdapter.getDefaultAdapter(componentActivity)
|
||||
if (maybeNfcAdapter == null) {
|
||||
Toast.makeText(componentActivity, componentActivity.getString(R.string.scan_no_nfc_on_device), Toast.LENGTH_SHORT).show()
|
||||
Toast.makeText(componentActivity, componentActivity.getString(R.string.scan_no_nfc_on_device), Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
nfcAdapter = maybeNfcAdapter
|
||||
checkSecrets()
|
||||
registerNfcSuppressor()
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a lifecycle listener that keeps reader mode active (with a no-op callback)
|
||||
* whenever this Activity is in the foreground. This prevents Android's default NFC dispatch
|
||||
* system from launching com.android.apps.tag/.TagViewer when the watch's HCE service is
|
||||
* detected while the user hasn't explicitly pressed a transfer button yet.
|
||||
*
|
||||
* When the user presses Read/Write/CheckCard, [handleTag] replaces this suppressor with
|
||||
* the real transfer callback via [NfcAdapter.enableReaderMode]. After the transfer
|
||||
* completes, [enableNfcSuppressor] is called again to restore the passive suppressor.
|
||||
*/
|
||||
private fun registerNfcSuppressor() {
|
||||
registerActivityLifecycleListener(
|
||||
LIFECYCLE_KEY_NFC_SUPPRESSOR,
|
||||
object : ActivityLifecycleListener {
|
||||
override fun onResume() {
|
||||
enableNfcSuppressor()
|
||||
override fun onClickRead(secrets: Secrets, onComplete: ()->Unit, onMultipleCards: (List<Card>) -> Unit) {
|
||||
handleTag(secrets) { tagCommunicator ->
|
||||
try {
|
||||
val character = tagCommunicator.receiveCharacter()
|
||||
val resultMessage = characterFromNfc(character) { cards, nfcCharacter ->
|
||||
lastScannedCharacter = nfcCharacter
|
||||
onMultipleCards(cards)
|
||||
}
|
||||
override fun onPause() {
|
||||
disableReaderModeSafely()
|
||||
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)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Enables reader mode with a silent no-op callback. Calling [NfcAdapter.enableReaderMode]
|
||||
* suppresses Android's default tag-dispatch system (and therefore the "new tag scanned"
|
||||
* TagViewer screen) for as long as this Activity is in the foreground. The actual transfer
|
||||
* logic is wired up separately via [handleTag] when the user presses a button.
|
||||
*/
|
||||
private fun enableNfcSuppressor() {
|
||||
if (!nfcAdapter.isEnabled) return
|
||||
val options = Bundle()
|
||||
options.putInt(NfcAdapter.EXTRA_READER_PRESENCE_CHECK_DELAY, 250)
|
||||
val flags = NfcAdapter.FLAG_READER_NFC_A or
|
||||
NfcAdapter.FLAG_READER_NO_PLATFORM_SOUNDS or
|
||||
NfcAdapter.FLAG_READER_SKIP_NDEF_CHECK
|
||||
runCatching {
|
||||
nfcAdapter.enableReaderMode(
|
||||
componentActivity,
|
||||
{ /* suppress default dispatch — user must tap a transfer button */ },
|
||||
flags,
|
||||
options
|
||||
)
|
||||
}.onFailure {
|
||||
Log.w("NFC", "Failed to enable NFC suppressor reader mode", it)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Read (phone receives character FROM watch or bracelet) --------------------
|
||||
|
||||
override fun onClickRead(secrets: Secrets, onComplete: () -> Unit, onMultipleCards: (List<Card>) -> Unit) {
|
||||
_detectedTransportFlow.value = DetectedTransport.UNKNOWN
|
||||
setTransferStatus(R.string.scan_transfer_waiting_tap)
|
||||
handleTag(
|
||||
secrets,
|
||||
nfcAHandler = { tagCommunicator ->
|
||||
try {
|
||||
val character = tagCommunicator.receiveCharacter()
|
||||
val resultMessage = characterFromNfc(character) { cards, nfcCharacter ->
|
||||
lastScannedCharacter = nfcCharacter
|
||||
onMultipleCards(cards)
|
||||
}
|
||||
onComplete.invoke()
|
||||
resultMessage
|
||||
} catch (e: Exception) {
|
||||
Log.e("NFC_READ", "Error reading character from NFC", e)
|
||||
componentActivity.runOnUiThread {
|
||||
Toast.makeText(componentActivity, componentActivity.getString(R.string.scan_error_generic) + ": " + (e.message ?: e.javaClass.simpleName), Toast.LENGTH_LONG).show()
|
||||
}
|
||||
onComplete.invoke()
|
||||
componentActivity.getString(R.string.scan_error_generic)
|
||||
}
|
||||
},
|
||||
isoDepHandler = { isoDep ->
|
||||
val application = componentActivity.applicationContext as VBHelper
|
||||
val importer = VitalWearCharacterImporter(application.container.db, application.container.transferSeenDao)
|
||||
try {
|
||||
// ISO-DEP/HCE route: VitalWear characters are always imported as BE device type.
|
||||
var importResult: VitalWearCharacterImporter.ImportResult? = null
|
||||
val moved = VitalWearHceReaderClient(isoDep).moveCharacterFromWatch { character ->
|
||||
// Force imported device type to BE since source is VitalWear HCE (BE only).
|
||||
val result = importer.importCharacter(character, forcedDeviceType = DeviceType.BEDevice)
|
||||
importResult = result
|
||||
result.success
|
||||
}
|
||||
onComplete.invoke()
|
||||
if (moved) {
|
||||
importResult?.message ?: componentActivity.getString(R.string.scan_sent_character_success)
|
||||
} else {
|
||||
importResult?.message
|
||||
?: "VitalWear import was rejected. Source character remains on the watch."
|
||||
}
|
||||
} catch (readError: Exception) {
|
||||
Log.e("NFC_READ", "HCE read failed; watch may be armed as destination", readError)
|
||||
onComplete.invoke()
|
||||
"No source character detected on watch. If the watch is waiting to receive, use VBH to Watch."
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// ---- Write (phone sends character TO watch or bracelet) ------------------------
|
||||
|
||||
override fun onClickWrite(
|
||||
secrets: Secrets,
|
||||
nfcCharacter: NfcCharacter,
|
||||
characterId: Long?,
|
||||
onComplete: (ScanScreenController.WriteResult) -> Unit
|
||||
) {
|
||||
_detectedTransportFlow.value = DetectedTransport.UNKNOWN
|
||||
setTransferStatus(R.string.scan_transfer_waiting_tap)
|
||||
handleTag(
|
||||
secrets,
|
||||
nfcAHandler = { tagCommunicator ->
|
||||
try {
|
||||
val targetHeader = runCatching { tagCommunicator.readTargetHeader() }.getOrNull()
|
||||
val normalizedDeviceType = when (targetHeader?.deviceTypeId) {
|
||||
512u.toUShort() -> NfcDeviceTypeId.VitalSeriesDeviceType
|
||||
768u.toUShort() -> NfcDeviceTypeId.VitalCharactersDeviceType
|
||||
1024u.toUShort() -> NfcDeviceTypeId.VitalBraceletBEDeviceType
|
||||
else -> targetHeader?.deviceTypeId
|
||||
}
|
||||
val forcedProfile = when (normalizedDeviceType) {
|
||||
NfcDeviceTypeId.VitalBraceletBEDeviceType -> DeviceType.BEDevice
|
||||
else -> DeviceType.VBDevice
|
||||
}
|
||||
Log.i(
|
||||
"NFC_WRITE_A",
|
||||
"Target NFC-A header rawDeviceType=${targetHeader?.deviceTypeId}, normalizedDeviceType=$normalizedDeviceType, dimId=${targetHeader?.getDimId()}, forcedProfile=$forcedProfile"
|
||||
)
|
||||
|
||||
// NFC-A route: choose export profile by target bracelet type.
|
||||
// - Vital Series / Vital Characters => VB profile
|
||||
// - Vital Bracelet BE => BE profile
|
||||
val nfcACharacter = if (characterId != null) {
|
||||
runBlocking {
|
||||
ToNfcConverter(componentActivity = componentActivity)
|
||||
.characterToNfc(characterId, forcedProfile)
|
||||
}
|
||||
} else {
|
||||
nfcCharacter
|
||||
}
|
||||
|
||||
val initialSlotState = readNfcASlotState(tagCommunicator)
|
||||
if (initialSlotState.isFull()) {
|
||||
onComplete.invoke(ScanScreenController.WriteResult.BLOCKED_DEVICE_FULL)
|
||||
return@handleTag componentActivity.getString(R.string.scan_target_device_full)
|
||||
}
|
||||
|
||||
val migrationCheck = verifyActiveToBackupMigration(tagCommunicator, initialSlotState)
|
||||
if (!migrationCheck.canProceed) {
|
||||
onComplete.invoke(ScanScreenController.WriteResult.COPIED)
|
||||
return@handleTag migrationCheck.message
|
||||
}
|
||||
|
||||
// Send with VB profile (which was forced above). Real bracelets only accept VBNfcCharacter.
|
||||
return@handleTag try {
|
||||
when (nfcACharacter) {
|
||||
is VBNfcCharacter -> tagCommunicator.sendCharacter(nfcACharacter)
|
||||
is BENfcCharacter -> {
|
||||
// This should not happen since we forced VBDevice above, but fail if it does.
|
||||
Log.e("NFC_WRITE_A", "BENfcCharacter sent to NFC-A (real bracelet) — protocol error")
|
||||
throw IllegalStateException("NFC-A forced VBDevice but received BENfcCharacter")
|
||||
}
|
||||
}
|
||||
onComplete.invoke(ScanScreenController.WriteResult.MOVE_CONFIRMED)
|
||||
componentActivity.getString(R.string.scan_sent_character_success)
|
||||
} catch (deviceTypeMismatch: Exception) {
|
||||
// Device type mismatch on NFC-A is terminal for this tap.
|
||||
if (deviceTypeMismatch.message?.contains("Character doesn't match device type") == true) {
|
||||
Log.e("NFC_WRITE_A", "Device type mismatch on NFC-A with selected transfer profile", deviceTypeMismatch)
|
||||
onComplete.invoke(ScanScreenController.WriteResult.COPIED)
|
||||
"Transfer failed: bracelet rejected character profile for this tap. Retry and keep bracelet steady."
|
||||
} else {
|
||||
Log.e("NFC_WRITE_A", "NFC-A write failed", deviceTypeMismatch)
|
||||
onComplete.invoke(ScanScreenController.WriteResult.COPIED)
|
||||
throw deviceTypeMismatch
|
||||
}
|
||||
}
|
||||
} catch (writeError: Throwable) {
|
||||
Log.e("NFC_WRITE_A", "NFC-A setup failed", writeError)
|
||||
onComplete.invoke(ScanScreenController.WriteResult.COPIED)
|
||||
throw writeError
|
||||
}
|
||||
},
|
||||
isoDepHandler = { isoDep ->
|
||||
val characterId = lastRequestedCharacterId
|
||||
?: throw IllegalStateException("No character id available for VitalWear HCE write")
|
||||
val application = componentActivity.applicationContext as VBHelper
|
||||
val hceClient = VitalWearHceReaderClient(isoDep)
|
||||
try {
|
||||
// ISO-DEP/HCE route: VitalWear always uses BE profile (enforced).
|
||||
// Create protobuf with forced BE device type for correct serialization.
|
||||
val proto = runBlocking {
|
||||
VitalWearCharacterExporter(application.container.db)
|
||||
.buildCharacterProto(characterId, forcedTransferProfile = DeviceType.BEDevice)
|
||||
}
|
||||
if (proto.characterStats.deviceType != Character.CharacterStats.TransferDeviceType.TRANSFER_DEVICE_TYPE_BE) {
|
||||
throw IllegalStateException("VitalWear HCE export must use BE transfer profile")
|
||||
}
|
||||
// Send with status confirmation: only mark MOVE_CONFIRMED after watch confirms import.
|
||||
val confirmedMove = hceClient.sendCharacterToWatchAndConfirm(proto)
|
||||
if (confirmedMove) {
|
||||
onComplete.invoke(ScanScreenController.WriteResult.MOVE_CONFIRMED)
|
||||
componentActivity.getString(R.string.scan_sent_character_success)
|
||||
} else {
|
||||
// Transfer acknowledging but watch import failed or did not confirm.
|
||||
onComplete.invoke(ScanScreenController.WriteResult.COPIED)
|
||||
"Transfer sent but watch did not confirm import. Source was kept in VBH."
|
||||
}
|
||||
} catch (writeError: Throwable) {
|
||||
Log.e("NFC_WRITE_HCE", "HCE write failed", writeError)
|
||||
onComplete.invoke(ScanScreenController.WriteResult.COPIED)
|
||||
throw writeError
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
|
||||
// ---- Check card (NFC-A only; ISO-DEP watches don't need a DIM prep) -----------
|
||||
|
||||
override fun onClickCheckCard(secrets: Secrets, nfcCharacter: NfcCharacter, onComplete: () -> Unit) {
|
||||
_detectedTransportFlow.value = DetectedTransport.UNKNOWN
|
||||
setTransferStatus(R.string.scan_transfer_waiting_tap)
|
||||
handleTag(
|
||||
secrets,
|
||||
nfcAHandler = { tagCommunicator ->
|
||||
tagCommunicator.prepareDIMForCharacter(nfcCharacter.dimId)
|
||||
onComplete.invoke()
|
||||
componentActivity.getString(R.string.scan_sent_dim_success)
|
||||
},
|
||||
// HCE equivalent of the prep step: verify watch is armed for phone->watch transfer.
|
||||
isoDepHandler = { isoDep ->
|
||||
runCatching {
|
||||
VitalWearHceReaderClient(isoDep).verifyWatchReadyToReceive()
|
||||
}.fold(
|
||||
onSuccess = {
|
||||
onComplete.invoke()
|
||||
"Watch ready. Tap again to send character."
|
||||
},
|
||||
onFailure = { error ->
|
||||
Log.e("NFC_HCE", "Watch is not ready to receive character", error)
|
||||
"Watch is not ready. On watch: Transfer > Receive from VBH, then tap again."
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// ---- Cancel / lifecycle -------------------------------------------------------
|
||||
|
||||
override fun cancelRead() {
|
||||
_detectedTransportFlow.value = DetectedTransport.UNKNOWN
|
||||
setTransferStatus(R.string.scan_transfer_cancelled)
|
||||
activeReaderSessionId = readerSessionCounter.incrementAndGet()
|
||||
isHandlingTag.set(false)
|
||||
synchronized(tagStateLock) {
|
||||
lastTagId = null
|
||||
lastTagHandledAtMs = 0L
|
||||
if(nfcAdapter.isEnabled) {
|
||||
nfcAdapter.disableReaderMode(componentActivity)
|
||||
}
|
||||
// Re-arm the suppressor so the TagViewer doesn't appear while the user is still
|
||||
// on the scan screen but hasn't pressed a transfer button yet.
|
||||
enableNfcSuppressor()
|
||||
}
|
||||
|
||||
override fun registerActivityLifecycleListener(key: String, activityLifecycleListener: ActivityLifecycleListener) {
|
||||
override fun registerActivityLifecycleListener(
|
||||
key: String,
|
||||
activityLifecycleListener: ActivityLifecycleListener
|
||||
) {
|
||||
registerActivityLifecycleListener.invoke(key, activityLifecycleListener)
|
||||
}
|
||||
|
||||
@ -364,232 +83,43 @@ class ScanScreenControllerImpl(
|
||||
unregisterActivityLifecycleListener.invoke(key)
|
||||
}
|
||||
|
||||
// ---- NFC adapter wiring -------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Arms the NFC reader for both NFC-A (bracelet) and ISO-DEP (VitalWear HCE).
|
||||
* [isoDepHandler] is optional; when null the reader only accepts NFC-A.
|
||||
*/
|
||||
private fun handleTag(
|
||||
secrets: Secrets,
|
||||
nfcAHandler: (TagCommunicator) -> String,
|
||||
isoDepHandler: ((IsoDep) -> String)? = null
|
||||
) {
|
||||
// EXTRACTED DIRECTLY FROM EXAMPLE APP
|
||||
private fun handleTag(secrets: Secrets, handlerFunc: (TagCommunicator)->String) {
|
||||
if (!nfcAdapter.isEnabled) {
|
||||
showWirelessSettings()
|
||||
return
|
||||
} 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), NfcAdapter.FLAG_READER_NFC_A or NfcAdapter.FLAG_READER_SKIP_NDEF_CHECK,
|
||||
options
|
||||
)
|
||||
}
|
||||
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)
|
||||
val flags = NfcAdapter.FLAG_READER_NFC_A or
|
||||
NfcAdapter.FLAG_READER_NO_PLATFORM_SOUNDS or
|
||||
NfcAdapter.FLAG_READER_SKIP_NDEF_CHECK
|
||||
val sessionId = readerSessionCounter.incrementAndGet()
|
||||
activeReaderSessionId = sessionId
|
||||
isHandlingTag.set(false)
|
||||
synchronized(tagStateLock) {
|
||||
lastTagId = null
|
||||
lastTagHandledAtMs = 0L
|
||||
}
|
||||
disableReaderModeSafely()
|
||||
nfcAdapter.enableReaderMode(
|
||||
componentActivity,
|
||||
buildOnReadTag(secrets, nfcAHandler, isoDepHandler, sessionId),
|
||||
flags,
|
||||
options
|
||||
)
|
||||
}
|
||||
|
||||
private fun buildOnReadTag(
|
||||
secrets: Secrets,
|
||||
nfcAHandler: (TagCommunicator) -> String,
|
||||
isoDepHandler: ((IsoDep) -> String)?,
|
||||
sessionId: Long,
|
||||
): (Tag) -> Unit {
|
||||
return { tag ->
|
||||
if (activeReaderSessionId == sessionId) {
|
||||
val now = System.currentTimeMillis()
|
||||
val shouldHandleTag = synchronized(tagStateLock) {
|
||||
val sameRecentTag = lastTagId != null && tag.id != null && Arrays.equals(lastTagId, tag.id) &&
|
||||
(now - lastTagHandledAtMs) < TAG_DEBOUNCE_MS
|
||||
if (sameRecentTag || !isHandlingTag.compareAndSet(false, true)) {
|
||||
false
|
||||
} else {
|
||||
lastTagId = tag.id?.clone()
|
||||
lastTagHandledAtMs = now
|
||||
true
|
||||
}
|
||||
}
|
||||
if (shouldHandleTag) {
|
||||
setTransferStatus(R.string.scan_transfer_detected_keep_tap)
|
||||
|
||||
// Detect transport once per tap and lock to that route for this transfer.
|
||||
// Real Bandai bracelets route through NFC-A; VitalWear routes through ISO-DEP/HCE.
|
||||
val isoDep = IsoDep.get(tag)
|
||||
val hasNfcARoute = NfcA.get(tag) != null
|
||||
val hasIsoDepHandler = isoDep != null && isoDepHandler != null
|
||||
val confirmedVitalWear = if (hasIsoDepHandler) {
|
||||
runCatching { isVitalWearHceTarget(isoDep) }.getOrDefault(false)
|
||||
} else {
|
||||
false
|
||||
}
|
||||
try {
|
||||
if (hasIsoDepHandler && confirmedVitalWear) {
|
||||
val isoDepTarget = isoDep
|
||||
val isoDepAction = isoDepHandler
|
||||
try {
|
||||
_detectedTransportFlow.value = DetectedTransport.ISO_DEP
|
||||
isoDepTarget.connect()
|
||||
isoDepTarget.use {
|
||||
setTransferStatus(R.string.scan_transfer_in_progress)
|
||||
val successText = isoDepAction.invoke(isoDepTarget)
|
||||
componentActivity.runOnUiThread {
|
||||
Toast.makeText(componentActivity, successText, Toast.LENGTH_SHORT).show()
|
||||
setTransferStatus(R.string.scan_transfer_complete_remove)
|
||||
}
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
Log.e("NFC_HCE", "IsoDep transfer failed", e)
|
||||
componentActivity.runOnUiThread {
|
||||
Toast.makeText(componentActivity, componentActivity.getString(R.string.scan_error_generic), Toast.LENGTH_SHORT).show()
|
||||
setTransferStatus(R.string.scan_transfer_failed_try_again)
|
||||
}
|
||||
}
|
||||
} else if (hasIsoDepHandler && !confirmedVitalWear && !hasNfcARoute) {
|
||||
// Hard-fail only when IsoDep is present without any NFC-A fallback.
|
||||
// This keeps HCE safety while allowing real bracelets to route via NFC-A.
|
||||
Log.w("NFC_ROUTE", "IsoDep present but VitalWear AID not confirmed and no NFC-A route; cancelling transfer")
|
||||
_detectedTransportFlow.value = DetectedTransport.UNKNOWN
|
||||
componentActivity.runOnUiThread {
|
||||
Toast.makeText(componentActivity, "VitalWear HCE not detected. Transfer cancelled.", Toast.LENGTH_SHORT).show()
|
||||
setTransferStatus(R.string.scan_transfer_failed_try_again)
|
||||
}
|
||||
} else if (hasNfcARoute) {
|
||||
if (hasIsoDepHandler && !confirmedVitalWear) {
|
||||
Log.i("NFC_ROUTE", "IsoDep present but VitalWear AID not confirmed; routing to NFC-A fallback")
|
||||
}
|
||||
if (!handleNfcATag(tag, secrets, nfcAHandler)) {
|
||||
componentActivity.runOnUiThread {
|
||||
Toast.makeText(componentActivity, componentActivity.getString(R.string.scan_tag_not_vb), Toast.LENGTH_SHORT).show()
|
||||
setTransferStatus(R.string.scan_transfer_failed_try_again)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (hasIsoDepHandler && !confirmedVitalWear) {
|
||||
Log.w("NFC_ROUTE", "IsoDep tag does not expose VitalWear HCE AID and has no NFC-A fallback")
|
||||
}
|
||||
_detectedTransportFlow.value = DetectedTransport.UNKNOWN
|
||||
componentActivity.runOnUiThread {
|
||||
Toast.makeText(componentActivity, componentActivity.getString(R.string.scan_tag_not_vb), Toast.LENGTH_SHORT).show()
|
||||
setTransferStatus(R.string.scan_transfer_failed_try_again)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
finishHandledTagSession(tag, sessionId)
|
||||
}
|
||||
// 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun finishHandledTagSession(tag: Tag, sessionId: Long) {
|
||||
if (activeReaderSessionId != sessionId) return
|
||||
|
||||
// Keep Android from redispatching this same tag to other NFC apps while still in range.
|
||||
runCatching {
|
||||
nfcAdapter.ignore(
|
||||
tag,
|
||||
TAG_IGNORE_AFTER_HANDLED_MS,
|
||||
NfcAdapter.OnTagRemovedListener { },
|
||||
null
|
||||
)
|
||||
}.onFailure {
|
||||
Log.w("NFC", "Failed to ignore handled tag", it)
|
||||
}
|
||||
|
||||
// Restore the passive suppressor so devices can remain close without Android
|
||||
// launching the TagViewer between transfers or while waiting for the next button press.
|
||||
enableNfcSuppressor()
|
||||
isHandlingTag.set(false)
|
||||
}
|
||||
|
||||
private fun handleNfcATag(
|
||||
tag: Tag,
|
||||
secrets: Secrets,
|
||||
nfcAHandler: (TagCommunicator) -> String,
|
||||
): Boolean {
|
||||
val nfcData = NfcA.get(tag) ?: return false
|
||||
_detectedTransportFlow.value = DetectedTransport.NFC_A
|
||||
return try {
|
||||
nfcData.connect()
|
||||
nfcData.use {
|
||||
val tagCommunicator = TagCommunicator.getInstance(nfcData, secrets.getCryptographicTransformerMap())
|
||||
setTransferStatus(R.string.scan_transfer_in_progress)
|
||||
val successText = nfcAHandler(tagCommunicator)
|
||||
val successText = handlerFunc(tagCommunicator)
|
||||
componentActivity.runOnUiThread {
|
||||
Toast.makeText(componentActivity, successText, Toast.LENGTH_SHORT).show()
|
||||
setTransferStatus(R.string.scan_transfer_complete_remove)
|
||||
}
|
||||
}
|
||||
true
|
||||
} catch (e: Throwable) {
|
||||
val staleTagSession = e is SecurityException && (e.message?.contains("out of date", ignoreCase = true) == true)
|
||||
if (staleTagSession) {
|
||||
Log.w("NFC_A", "Ignoring stale NFC-A tag session; waiting for a fresh tap", e)
|
||||
componentActivity.runOnUiThread {
|
||||
Toast.makeText(componentActivity, "Tag session expired. Please tap again.", Toast.LENGTH_SHORT).show()
|
||||
setTransferStatus(R.string.scan_transfer_waiting_tap)
|
||||
}
|
||||
return false
|
||||
}
|
||||
Log.e("NFC_A", "NfcA transfer failed", e)
|
||||
componentActivity.runOnUiThread {
|
||||
Toast.makeText(componentActivity, componentActivity.getString(R.string.scan_error_generic), Toast.LENGTH_SHORT).show()
|
||||
setTransferStatus(R.string.scan_transfer_failed_try_again)
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
private fun setTransferStatus(messageRes: Int) {
|
||||
_transferStatusFlow.value = componentActivity.getString(messageRes)
|
||||
}
|
||||
|
||||
private fun disableReaderModeSafely() {
|
||||
if (!nfcAdapter.isEnabled) return
|
||||
runCatching { nfcAdapter.disableReaderMode(componentActivity) }
|
||||
}
|
||||
|
||||
private fun isVitalWearHceTarget(isoDep: IsoDep): Boolean {
|
||||
return runCatching {
|
||||
val originalTimeout = isoDep.timeout
|
||||
try {
|
||||
isoDep.timeout = 500
|
||||
if (!isoDep.isConnected) {
|
||||
isoDep.connect()
|
||||
}
|
||||
val selectApdu = byteArrayOf(0x00, 0xA4.toByte(), 0x04, 0x00, VITALWEAR_AID.size.toByte()) + VITALWEAR_AID
|
||||
val response = isoDep.transceive(selectApdu)
|
||||
statusWord(response) == SW_OK
|
||||
} finally {
|
||||
isoDep.timeout = originalTimeout
|
||||
runCatching { if (isoDep.isConnected) isoDep.close() }
|
||||
}
|
||||
}.getOrDefault(false)
|
||||
}
|
||||
|
||||
private fun statusWord(response: ByteArray): Int {
|
||||
if (response.size < 2) return -1
|
||||
return ((response[response.size - 2].toInt() and 0xFF) shl 8) or
|
||||
(response[response.size - 1].toInt() and 0xFF)
|
||||
}
|
||||
|
||||
// ---- Misc ---------------------------------------------------------------------
|
||||
|
||||
private fun checkSecrets() {
|
||||
componentActivity.lifecycleScope.launch(Dispatchers.IO) {
|
||||
if (secretsFlow.stateIn(componentActivity.lifecycleScope).value.isMissingSecrets()) {
|
||||
if(secretsFlow.stateIn(componentActivity.lifecycleScope).value.isMissingSecrets()) {
|
||||
componentActivity.runOnUiThread {
|
||||
Toast.makeText(componentActivity, componentActivity.getString(R.string.scan_missing_secrets), Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
@ -597,153 +127,74 @@ class ScanScreenControllerImpl(
|
||||
}
|
||||
}
|
||||
|
||||
override fun onClickWrite(
|
||||
secrets: Secrets,
|
||||
nfcCharacter: NfcCharacter,
|
||||
onComplete: () -> Unit
|
||||
) {
|
||||
handleTag(secrets) { tagCommunicator ->
|
||||
try {
|
||||
if (nfcCharacter is VBNfcCharacter) {
|
||||
Log.d("SendCharacter", "VBNfcCharacter")
|
||||
val castNfcCharacter: VBNfcCharacter = nfcCharacter
|
||||
tagCommunicator.sendCharacter(castNfcCharacter)
|
||||
} else if (nfcCharacter is BENfcCharacter) {
|
||||
Log.d("SendCharacter", "BENfcCharacter")
|
||||
val castNfcCharacter: BENfcCharacter = nfcCharacter
|
||||
tagCommunicator.sendCharacter(castNfcCharacter)
|
||||
}
|
||||
onComplete.invoke()
|
||||
componentActivity.getString(R.string.scan_sent_character_success)
|
||||
} catch (e: Throwable) {
|
||||
Log.e("TAG", e.stackTraceToString())
|
||||
componentActivity.getString(R.string.scan_error_generic)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onClickCheckCard(
|
||||
secrets: Secrets,
|
||||
nfcCharacter: NfcCharacter,
|
||||
onComplete: () -> Unit
|
||||
) {
|
||||
handleTag(secrets) { tagCommunicator ->
|
||||
tagCommunicator.prepareDIMForCharacter(nfcCharacter.dimId)
|
||||
onComplete.invoke()
|
||||
componentActivity.getString(R.string.scan_sent_dim_success)
|
||||
}
|
||||
}
|
||||
|
||||
// EXTRACTED DIRECTLY FROM EXAMPLE APP
|
||||
private fun showWirelessSettings() {
|
||||
Toast.makeText(componentActivity, componentActivity.getString(R.string.scan_nfc_must_be_enabled), Toast.LENGTH_SHORT).show()
|
||||
Toast.makeText(componentActivity, componentActivity.getString(R.string.scan_nfc_must_be_enabled), Toast.LENGTH_SHORT).show()
|
||||
componentActivity.startActivity(Intent(Settings.ACTION_WIRELESS_SETTINGS))
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort, non-destructive slot-capacity check for NFC-A devices.
|
||||
*/
|
||||
private fun readNfcASlotState(tagCommunicator: TagCommunicator): NfcASlotState {
|
||||
val communicatorClass = tagCommunicator.javaClass
|
||||
|
||||
val countMethod = communicatorClass.methods.firstOrNull {
|
||||
it.parameterCount == 0 &&
|
||||
Number::class.java.isAssignableFrom(it.returnType) &&
|
||||
it.name.contains("count", ignoreCase = true) &&
|
||||
it.name.contains("character", ignoreCase = true)
|
||||
}
|
||||
val count = if (countMethod != null) {
|
||||
runCatching { (countMethod.invoke(tagCommunicator) as Number).toInt() }.getOrNull()
|
||||
} else null
|
||||
|
||||
val activeMethod = communicatorClass.methods.firstOrNull {
|
||||
it.parameterCount == 0 && it.returnType == java.lang.Boolean.TYPE &&
|
||||
(it.name.contains("active", ignoreCase = true) || it.name.contains("current", ignoreCase = true)) &&
|
||||
it.name.contains("character", ignoreCase = true)
|
||||
}
|
||||
val backupMethod = communicatorClass.methods.firstOrNull {
|
||||
it.parameterCount == 0 && it.returnType == java.lang.Boolean.TYPE &&
|
||||
it.name.contains("backup", ignoreCase = true) &&
|
||||
it.name.contains("character", ignoreCase = true)
|
||||
}
|
||||
|
||||
val activePresent = if (activeMethod != null) {
|
||||
runCatching { activeMethod.invoke(tagCommunicator) as Boolean }.getOrNull()
|
||||
} else null
|
||||
val backupPresent = if (backupMethod != null) {
|
||||
runCatching { backupMethod.invoke(tagCommunicator) as Boolean }.getOrNull()
|
||||
} else null
|
||||
|
||||
if (count == null && activePresent == null && backupPresent == null) {
|
||||
Log.w("NFC_A", "Unable to introspect NFC-A slot occupancy; defaulting to non-blocking write path")
|
||||
}
|
||||
|
||||
return NfcASlotState(count = count, activePresent = activePresent, backupPresent = backupPresent)
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to mirror official toy behavior by moving the active character to backup.
|
||||
* If the library already does this internally, this is a harmless no-op.
|
||||
*/
|
||||
private fun verifyActiveToBackupMigration(
|
||||
tagCommunicator: TagCommunicator,
|
||||
beforeState: NfcASlotState,
|
||||
): SlotMigrationCheck {
|
||||
val migrationResult = moveActiveToBackupIfSupported(tagCommunicator)
|
||||
if (migrationResult.attempted && !migrationResult.success) {
|
||||
return SlotMigrationCheck(
|
||||
canProceed = false,
|
||||
message = "Transfer blocked. Could not move active character to backup safely."
|
||||
)
|
||||
}
|
||||
|
||||
// If we could not introspect occupancy at all, keep legacy behavior and proceed.
|
||||
if (!beforeState.hasOccupancySignal()) {
|
||||
return SlotMigrationCheck(canProceed = true, message = "")
|
||||
}
|
||||
|
||||
val afterState = readNfcASlotState(tagCommunicator)
|
||||
val migrationVerified = afterState.backupPresent == true ||
|
||||
(beforeState.count != null && afterState.count != null && afterState.count >= beforeState.count)
|
||||
if (!migrationVerified) {
|
||||
// Some real bracelets do not expose stable occupancy signals through the current
|
||||
// reflection-based probing. In that case, prefer compatibility over false blocks.
|
||||
Log.w("NFC_A", "Could not verify active->backup migration from slot signals; proceeding with write")
|
||||
return SlotMigrationCheck(canProceed = true, message = "")
|
||||
}
|
||||
|
||||
return SlotMigrationCheck(canProceed = true, message = "")
|
||||
}
|
||||
|
||||
private fun moveActiveToBackupIfSupported(tagCommunicator: TagCommunicator): MigrationInvokeResult {
|
||||
val communicatorClass = tagCommunicator.javaClass
|
||||
val candidate = communicatorClass.methods.firstOrNull {
|
||||
it.parameterCount == 0 &&
|
||||
(
|
||||
(it.name.contains("move", ignoreCase = true) && it.name.contains("backup", ignoreCase = true)) ||
|
||||
(it.name.contains("shift", ignoreCase = true) && it.name.contains("backup", ignoreCase = true)) ||
|
||||
(it.name.contains("promote", ignoreCase = true) && it.name.contains("backup", ignoreCase = true))
|
||||
)
|
||||
}
|
||||
if (candidate == null) {
|
||||
return MigrationInvokeResult(attempted = false, success = true)
|
||||
}
|
||||
|
||||
return runCatching { candidate.invoke(tagCommunicator) }
|
||||
.fold(
|
||||
onSuccess = { MigrationInvokeResult(attempted = true, success = true) },
|
||||
onFailure = {
|
||||
Log.w("NFC_A", "Failed to move active character to backup", it)
|
||||
MigrationInvokeResult(attempted = true, success = false)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private data class NfcASlotState(
|
||||
val count: Int?,
|
||||
val activePresent: Boolean?,
|
||||
val backupPresent: Boolean?,
|
||||
) {
|
||||
fun hasOccupancySignal(): Boolean {
|
||||
return count != null || activePresent != null || backupPresent != null
|
||||
}
|
||||
|
||||
fun isFull(): Boolean {
|
||||
if (count != null) {
|
||||
return count >= 2
|
||||
}
|
||||
if (activePresent != null && backupPresent != null) {
|
||||
return activePresent && backupPresent
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private data class SlotMigrationCheck(
|
||||
val canProceed: Boolean,
|
||||
val message: String,
|
||||
)
|
||||
|
||||
private data class MigrationInvokeResult(
|
||||
val attempted: Boolean,
|
||||
val success: Boolean,
|
||||
)
|
||||
|
||||
override fun characterFromNfc(nfcCharacter: NfcCharacter, onMultipleCards: (List<Card>, NfcCharacter) -> Unit): String {
|
||||
return FromNfcConverter(componentActivity = componentActivity).addCharacter(nfcCharacter, onMultipleCards)
|
||||
override fun characterFromNfc(
|
||||
nfcCharacter: NfcCharacter,
|
||||
onMultipleCards: (List<Card>, NfcCharacter) -> Unit
|
||||
): String {
|
||||
val nfcConverter = FromNfcConverter(
|
||||
componentActivity = componentActivity
|
||||
)
|
||||
return nfcConverter.addCharacter(nfcCharacter, onMultipleCards)
|
||||
}
|
||||
|
||||
override suspend fun characterToNfc(characterId: Long): NfcCharacter {
|
||||
lastRequestedCharacterId = characterId
|
||||
val character = ToNfcConverter(componentActivity = componentActivity).characterToNfc(characterId)
|
||||
val nfcGenerator = ToNfcConverter(
|
||||
componentActivity = componentActivity
|
||||
)
|
||||
|
||||
val character = nfcGenerator.characterToNfc(characterId)
|
||||
Log.d("CharacterType", character.toString())
|
||||
return character
|
||||
}
|
||||
|
||||
override fun flushCharacter(cardId: Long) {
|
||||
val nfcConverter = FromNfcConverter(componentActivity = componentActivity)
|
||||
val nfcConverter = FromNfcConverter(
|
||||
componentActivity = componentActivity
|
||||
)
|
||||
|
||||
componentActivity.lifecycleScope.launch(Dispatchers.IO) {
|
||||
if (lastScannedCharacter != null) {
|
||||
nfcConverter.addCharacterUsingCard(lastScannedCharacter!!, cardId)
|
||||
|
||||
@ -3,7 +3,6 @@ package com.github.nacabaro.vbhelper.screens.scanScreen.converters
|
||||
import androidx.activity.ComponentActivity
|
||||
import com.github.cfogrady.vbnfc.be.BENfcCharacter
|
||||
import com.github.cfogrady.vbnfc.data.NfcCharacter
|
||||
import com.github.cfogrady.vbnfc.vb.SpecialMission
|
||||
import com.github.cfogrady.vbnfc.vb.VBNfcCharacter
|
||||
import com.github.nacabaro.vbhelper.di.VBHelper
|
||||
import com.github.nacabaro.vbhelper.domain.card.Card
|
||||
@ -20,7 +19,6 @@ class FromNfcConverter (
|
||||
) {
|
||||
private val application = componentActivity.applicationContext as VBHelper
|
||||
private val database = application.container.db
|
||||
private val transferSeenDao = application.container.transferSeenDao
|
||||
|
||||
|
||||
fun addCharacterUsingCard(
|
||||
@ -120,16 +118,11 @@ class FromNfcConverter (
|
||||
.userCharacterDao()
|
||||
.insertCharacterData(characterData)
|
||||
|
||||
val seenTimestamp = System.currentTimeMillis()
|
||||
markSeen(cardData, nfcCharacter.charIndex.toInt(), seenTimestamp)
|
||||
|
||||
if (nfcCharacter is BENfcCharacter) {
|
||||
addBeCharacterToDatabase(
|
||||
characterId = characterId,
|
||||
nfcCharacter = nfcCharacter
|
||||
)
|
||||
// Keep a VB profile alongside BE stats for later VB-target exports.
|
||||
addVbCharacterProfileFromBe(characterId, nfcCharacter)
|
||||
} else if (nfcCharacter is VBNfcCharacter) {
|
||||
addVbCharacterToDatabase(
|
||||
characterId = characterId,
|
||||
@ -236,8 +229,8 @@ class FromNfcConverter (
|
||||
itemType = nfcCharacter.itemType.toInt(),
|
||||
itemMultiplier = nfcCharacter.itemMultiplier.toInt(),
|
||||
itemRemainingTime = nfcCharacter.itemRemainingTime.toInt(),
|
||||
otp0 = nfcCharacter.getOtp0().joinToString("") { "%02x".format(it) },
|
||||
otp1 = nfcCharacter.getOtp1().joinToString("") { "%02x".format(it) },
|
||||
otp0 = "", //nfcCharacter.value!!.otp0.toString(),
|
||||
otp1 = "", //nfcCharacter.value!!.otp1.toString(),
|
||||
minorVersion = nfcCharacter.characterCreationFirmwareVersion.minorVersion.toInt(),
|
||||
majorVersion = nfcCharacter.characterCreationFirmwareVersion.majorVersion.toInt(),
|
||||
)
|
||||
@ -247,42 +240,6 @@ class FromNfcConverter (
|
||||
.insertBECharacterData(extraCharacterData)
|
||||
}
|
||||
|
||||
private fun addVbCharacterProfileFromBe(
|
||||
characterId: Long,
|
||||
nfcCharacter: BENfcCharacter,
|
||||
) {
|
||||
val historyCount = nfcCharacter.transformationHistory.count { it.toCharIndex.toInt() != 255 }
|
||||
val vbData = VBCharacterData(
|
||||
id = characterId,
|
||||
generation = (historyCount - 1).coerceAtLeast(0),
|
||||
totalTrophies = nfcCharacter.trophies.toInt(),
|
||||
)
|
||||
database.userCharacterDao().insertVBCharacterData(vbData)
|
||||
|
||||
// Populate empty mission slots for BE characters for UI consistency
|
||||
addEmptyMissionSlotsForBe(characterId)
|
||||
}
|
||||
|
||||
private fun addEmptyMissionSlotsForBe(characterId: Long) {
|
||||
// Create 4 empty mission slots (one for each watch position 0-3)
|
||||
val emptyMissions = (0..3).map { watchId ->
|
||||
SpecialMissions(
|
||||
characterId = characterId,
|
||||
watchId = watchId,
|
||||
missionType = SpecialMission.Type.NONE,
|
||||
status = SpecialMission.Status.UNAVAILABLE,
|
||||
goal = 0,
|
||||
progress = 0,
|
||||
timeElapsedInMinutes = 0,
|
||||
timeLimitInMinutes = 0,
|
||||
)
|
||||
}
|
||||
|
||||
database
|
||||
.userCharacterDao()
|
||||
.insertSpecialMissions(*emptyMissions.toTypedArray())
|
||||
}
|
||||
|
||||
|
||||
|
||||
private fun addVitalsHistoryToDatabase(
|
||||
@ -335,14 +292,12 @@ class FromNfcConverter (
|
||||
|
||||
database
|
||||
.dexDao()
|
||||
.insertCharacter(item.toCharIndex.toInt(), dimData.id, date)
|
||||
transferSeenDao.markSeen(dimData.name, item.toCharIndex.toInt(), date)
|
||||
.insertCharacter(
|
||||
item.toCharIndex.toInt(),
|
||||
dimData.id,
|
||||
date
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun markSeen(cardData: Card, slotId: Int, timestamp: Long) {
|
||||
database.dexDao().insertCharacter(slotId, cardData.id, timestamp)
|
||||
transferSeenDao.markSeen(cardData.name, slotId, timestamp)
|
||||
}
|
||||
}
|
||||
@ -11,42 +11,22 @@ import com.github.cfogrady.vbnfc.vb.SpecialMission
|
||||
import com.github.cfogrady.vbnfc.vb.VBNfcCharacter
|
||||
import com.github.nacabaro.vbhelper.database.AppDatabase
|
||||
import com.github.nacabaro.vbhelper.di.VBHelper
|
||||
import com.github.nacabaro.vbhelper.domain.device_data.BECharacterData
|
||||
import com.github.nacabaro.vbhelper.domain.device_data.UserCharacter
|
||||
import com.github.nacabaro.vbhelper.domain.device_data.VBCharacterData
|
||||
import com.github.nacabaro.vbhelper.dtos.CharacterDtos
|
||||
import com.github.nacabaro.vbhelper.utils.DeviceType
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
import java.util.Date
|
||||
|
||||
class ToNfcConverter(
|
||||
private val componentActivity: ComponentActivity
|
||||
) {
|
||||
companion object {
|
||||
private const val MIN_NFC_TRANSFORMATION_YEAR = 2021
|
||||
private const val MAX_NFC_TRANSFORMATION_YEAR = 2035
|
||||
|
||||
internal fun shouldEncodeAsBem(
|
||||
forcedProfile: DeviceType?,
|
||||
storedDeviceType: DeviceType,
|
||||
): Boolean {
|
||||
return when (forcedProfile) {
|
||||
DeviceType.BEDevice -> true
|
||||
DeviceType.VBDevice -> false
|
||||
else -> storedDeviceType == DeviceType.BEDevice
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val application: VBHelper = componentActivity.applicationContext as VBHelper
|
||||
private val database: AppDatabase = application.container.db
|
||||
|
||||
|
||||
|
||||
suspend fun characterToNfc(
|
||||
characterId: Long,
|
||||
forcedProfile: DeviceType? = null,
|
||||
characterId: Long
|
||||
): NfcCharacter {
|
||||
val app = componentActivity.applicationContext as VBHelper
|
||||
val database = app.container.db
|
||||
@ -59,13 +39,7 @@ class ToNfcConverter(
|
||||
.characterDao()
|
||||
.getCharacterInfo(userCharacter.charId)
|
||||
|
||||
// Forced profile overrides stored device type for transfer semantics:
|
||||
// - NFC-A route forces VBDevice (real bracelets only understand VB)
|
||||
// - HCE route forces BEDevice (VitalWear only sends/accepts BE)
|
||||
// Otherwise, use the character's own stored type.
|
||||
val shouldEncodeAsBem = shouldEncodeAsBem(forcedProfile, userCharacter.characterType)
|
||||
|
||||
return if (shouldEncodeAsBem)
|
||||
return if (userCharacter.characterType == DeviceType.BEDevice)
|
||||
nfcToBENfc(characterId, characterInfo, userCharacter)
|
||||
else
|
||||
nfcToVBNfc(characterId, characterInfo, userCharacter)
|
||||
@ -81,12 +55,7 @@ class ToNfcConverter(
|
||||
val vbData = database
|
||||
.userCharacterDao()
|
||||
.getVbData(characterId)
|
||||
.firstOrNull()
|
||||
?: VBCharacterData(
|
||||
id = characterId,
|
||||
generation = 0,
|
||||
totalTrophies = userCharacter.trophies
|
||||
)
|
||||
.first()
|
||||
|
||||
val paddedTransformationArray = generateTransformationHistory(characterId, 9)
|
||||
|
||||
@ -207,47 +176,11 @@ class ToNfcConverter(
|
||||
): BENfcCharacter {
|
||||
val beData = database
|
||||
.userCharacterDao()
|
||||
.getBeDataOrNull(characterId)
|
||||
?: BECharacterData(
|
||||
id = characterId,
|
||||
trainingHp = 0,
|
||||
trainingAp = 0,
|
||||
trainingBp = 0,
|
||||
remainingTrainingTimeInMinutes = 0,
|
||||
itemEffectMentalStateValue = 0,
|
||||
itemEffectMentalStateMinutesRemaining = 0,
|
||||
itemEffectActivityLevelValue = 0,
|
||||
itemEffectActivityLevelMinutesRemaining = 0,
|
||||
itemEffectVitalPointsChangeValue = 0,
|
||||
itemEffectVitalPointsChangeMinutesRemaining = 0,
|
||||
abilityRarity = NfcCharacter.AbilityRarity.None,
|
||||
abilityType = 0,
|
||||
abilityBranch = 0,
|
||||
abilityReset = 0,
|
||||
rank = 0,
|
||||
itemType = 0,
|
||||
itemMultiplier = 0,
|
||||
itemRemainingTime = 0,
|
||||
otp0 = "",
|
||||
otp1 = "",
|
||||
minorVersion = 0,
|
||||
majorVersion = 0,
|
||||
)
|
||||
.getBeData(characterId)
|
||||
.first()
|
||||
|
||||
val paddedTransformationArray = generateTransformationHistory(characterId)
|
||||
|
||||
// Convert hex-encoded OTP strings back to ByteArrays; use empty arrays if not stored
|
||||
val otp0ByteArray = if (beData.otp0.isNotEmpty()) {
|
||||
beData.otp0.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
|
||||
} else {
|
||||
ByteArray(8) // Default to 8-byte zero array if not available
|
||||
}
|
||||
val otp1ByteArray = if (beData.otp1.isNotEmpty()) {
|
||||
beData.otp1.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
|
||||
} else {
|
||||
ByteArray(8) // Default to 8-byte zero array if not available
|
||||
}
|
||||
|
||||
val nfcData = BENfcCharacter(
|
||||
dimId = characterInfo.cardId.toUShort(),
|
||||
charIndex = characterInfo.charId.toUShort(),
|
||||
@ -288,8 +221,8 @@ class ToNfcConverter(
|
||||
itemType = beData.itemType.toByte(),
|
||||
itemMultiplier = beData.itemMultiplier.toByte(),
|
||||
itemRemainingTime = beData.itemRemainingTime.toByte(),
|
||||
otp0 = otp0ByteArray,
|
||||
otp1 = otp1ByteArray,
|
||||
otp0 = byteArrayOf(8),
|
||||
otp1 = byteArrayOf(8),
|
||||
characterCreationFirmwareVersion = FirmwareVersion(
|
||||
minorVersion = beData.minorVersion.toByte(),
|
||||
majorVersion = beData.majorVersion.toByte()
|
||||
@ -321,15 +254,11 @@ class ToNfcConverter(
|
||||
"Day: ${calendar.get(Calendar.DAY_OF_MONTH)}"
|
||||
)
|
||||
|
||||
val rawYear = calendar.get(Calendar.YEAR)
|
||||
val normalizedYear = rawYear.coerceIn(MIN_NFC_TRANSFORMATION_YEAR, MAX_NFC_TRANSFORMATION_YEAR)
|
||||
if (normalizedYear != rawYear) {
|
||||
Log.w("TransformationHistory", "Normalizing out-of-range transformation year $rawYear to $normalizedYear")
|
||||
}
|
||||
|
||||
NfcCharacter.Transformation(
|
||||
toCharIndex = it.monIndex.toUByte(),
|
||||
year = normalizedYear.toUShort(),
|
||||
year = calendar
|
||||
.get(Calendar.YEAR)
|
||||
.toUShort(),
|
||||
month = (calendar
|
||||
.get(Calendar.MONTH) + 1)
|
||||
.toUByte(),
|
||||
|
||||
@ -1,54 +1,47 @@
|
||||
package com.github.nacabaro.vbhelper.screens.scanScreen.screens
|
||||
|
||||
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.Button
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import com.github.nacabaro.vbhelper.screens.scanScreen.TransferHaptics
|
||||
import com.github.nacabaro.vbhelper.utils.ImageBitmapData
|
||||
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.nacabaro.vbhelper.components.TopBanner
|
||||
import com.github.nacabaro.vbhelper.R
|
||||
|
||||
@Composable
|
||||
fun ActionScreen(
|
||||
topBannerText: String,
|
||||
detectedTransportMessage: String?,
|
||||
transferStatusMessage: String?,
|
||||
characterPreview: ImageBitmapData? = null,
|
||||
onClickCancel: () -> Unit,
|
||||
isTransferring: Boolean = true,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val transferHaptics = remember(context) { TransferHaptics(context) }
|
||||
|
||||
LaunchedEffect(transferStatusMessage) {
|
||||
if (transferStatusMessage != null && transferStatusMessage.contains("progress", ignoreCase = true)) {
|
||||
transferHaptics.onTransferProgress()
|
||||
Scaffold (
|
||||
topBar = {
|
||||
TopBanner(
|
||||
text = topBannerText,
|
||||
onBackClick = onClickCancel
|
||||
)
|
||||
}
|
||||
) { innerPadding ->
|
||||
Column (
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
modifier = Modifier
|
||||
.padding(innerPadding)
|
||||
.fillMaxSize()
|
||||
) {
|
||||
Text(stringResource(R.string.action_place_near_reader))
|
||||
Button(
|
||||
onClick = onClickCancel,
|
||||
modifier = Modifier.padding(16.dp)
|
||||
) {
|
||||
Text(stringResource(R.string.action_cancel))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (transferStatusMessage?.contains("complete", ignoreCase = true) == true) {
|
||||
TransferCompleteScreen(
|
||||
topBannerText = topBannerText,
|
||||
resultMessage = transferStatusMessage,
|
||||
onClickOk = onClickCancel,
|
||||
isSuccess = !transferStatusMessage.contains("fail", ignoreCase = true)
|
||||
)
|
||||
} else if (isTransferring) {
|
||||
TransferAnimationScreen(
|
||||
topBannerText = topBannerText,
|
||||
detectedTransportMessage = detectedTransportMessage,
|
||||
transferStatusMessage = transferStatusMessage,
|
||||
characterPreview = characterPreview,
|
||||
onClickCancel = onClickCancel,
|
||||
isTransferring = true
|
||||
)
|
||||
} else {
|
||||
TransferAnimationScreen(
|
||||
topBannerText = topBannerText,
|
||||
detectedTransportMessage = detectedTransportMessage,
|
||||
transferStatusMessage = transferStatusMessage,
|
||||
characterPreview = characterPreview,
|
||||
onClickCancel = onClickCancel,
|
||||
isTransferring = false
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -1,26 +1,20 @@
|
||||
package com.github.nacabaro.vbhelper.screens.scanScreen.screens
|
||||
|
||||
import android.util.Log
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
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 com.github.nacabaro.vbhelper.ActivityLifecycleListener
|
||||
import com.github.nacabaro.vbhelper.domain.card.Card
|
||||
import com.github.nacabaro.vbhelper.screens.cardScreen.ChooseCard
|
||||
import com.github.nacabaro.vbhelper.screens.scanScreen.DetectedTransport
|
||||
import com.github.nacabaro.vbhelper.screens.scanScreen.SCAN_SCREEN_ACTIVITY_LIFECYCLE_LISTENER
|
||||
import com.github.nacabaro.vbhelper.screens.scanScreen.ScanScreenController
|
||||
import com.github.nacabaro.vbhelper.screens.scanScreen.TransferHaptics
|
||||
import com.github.nacabaro.vbhelper.R
|
||||
|
||||
private const val TAG = "ReadingScreen"
|
||||
|
||||
@Composable
|
||||
fun ReadingScreen(
|
||||
@ -28,17 +22,7 @@ fun ReadingScreen(
|
||||
onCancel: () -> Unit,
|
||||
onComplete: () -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val transferHaptics = remember(context) { TransferHaptics(context) }
|
||||
val secrets by scanScreenController.secretsFlow.collectAsState(null)
|
||||
val transferStatus by scanScreenController.transferStatusFlow.collectAsState(null)
|
||||
val detectedTransport by scanScreenController.detectedTransportFlow.collectAsState()
|
||||
|
||||
val transportMessage = when (detectedTransport) {
|
||||
DetectedTransport.NFC_A -> stringResource(R.string.scan_transport_bandai_bracelet)
|
||||
DetectedTransport.ISO_DEP -> stringResource(R.string.scan_transport_vitalwear_hce)
|
||||
DetectedTransport.UNKNOWN -> null
|
||||
}
|
||||
|
||||
var cardsRead by remember { mutableStateOf<List<Card>?>(null) }
|
||||
|
||||
@ -46,80 +30,55 @@ fun ReadingScreen(
|
||||
var isDoneReadingCharacter by remember { mutableStateOf(false) }
|
||||
var cardSelectScreen by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(readingScreen) {
|
||||
if (readingScreen) {
|
||||
transferHaptics.onTransferStart()
|
||||
}
|
||||
}
|
||||
|
||||
fun startReadIfNeeded() {
|
||||
val availableSecrets = secrets
|
||||
if (availableSecrets == null) {
|
||||
Log.d(TAG, "startReadIfNeeded: skipped (no secrets)")
|
||||
return
|
||||
}
|
||||
if (!readingScreen || isDoneReadingCharacter || cardSelectScreen) {
|
||||
Log.d(
|
||||
TAG,
|
||||
"startReadIfNeeded: skipped (readingScreen=$readingScreen, done=$isDoneReadingCharacter, cardSelect=$cardSelectScreen)"
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
Log.d(TAG, "startReadIfNeeded: arming onClickRead")
|
||||
scanScreenController.onClickRead(
|
||||
secrets = availableSecrets,
|
||||
onComplete = {
|
||||
Log.d(TAG, "onClickRead.onComplete: marking read complete")
|
||||
transferHaptics.onTransferComplete()
|
||||
readingScreen = false
|
||||
isDoneReadingCharacter = true
|
||||
},
|
||||
onMultipleCards = { cards ->
|
||||
Log.d(TAG, "onClickRead.onMultipleCards: showing card select (count=${cards.size})")
|
||||
transferHaptics.onTransferProgress()
|
||||
cardsRead = cards
|
||||
readingScreen = false
|
||||
cardSelectScreen = true
|
||||
isDoneReadingCharacter = true
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
DisposableEffect(readingScreen) {
|
||||
if(readingScreen) {
|
||||
Log.d(TAG, "DisposableEffect: readingScreen=true, registering lifecycle listener")
|
||||
scanScreenController.registerActivityLifecycleListener(
|
||||
SCAN_SCREEN_ACTIVITY_LIFECYCLE_LISTENER,
|
||||
object: ActivityLifecycleListener {
|
||||
override fun onPause() {
|
||||
Log.d(TAG, "lifecycle.onPause: cancelRead")
|
||||
scanScreenController.cancelRead()
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
Log.d(TAG, "lifecycle.onResume: attempting startReadIfNeeded")
|
||||
startReadIfNeeded()
|
||||
scanScreenController.onClickRead(
|
||||
secrets = secrets!!,
|
||||
onComplete = {
|
||||
isDoneReadingCharacter = true
|
||||
},
|
||||
onMultipleCards = { cards ->
|
||||
cardsRead = cards
|
||||
readingScreen = false
|
||||
cardSelectScreen = true
|
||||
isDoneReadingCharacter = true
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
startReadIfNeeded()
|
||||
scanScreenController.onClickRead(
|
||||
secrets = secrets!!,
|
||||
onComplete = {
|
||||
isDoneReadingCharacter = true
|
||||
},
|
||||
onMultipleCards = { cards ->
|
||||
cardsRead = cards
|
||||
readingScreen = false
|
||||
cardSelectScreen = true
|
||||
isDoneReadingCharacter = true
|
||||
}
|
||||
)
|
||||
}
|
||||
onDispose {
|
||||
if(readingScreen) {
|
||||
Log.d(TAG, "DisposableEffect.onDispose: unregister listener + cancelRead")
|
||||
scanScreenController.unregisterActivityLifecycleListener(
|
||||
SCAN_SCREEN_ACTIVITY_LIFECYCLE_LISTENER
|
||||
)
|
||||
scanScreenController.cancelRead()
|
||||
} else {
|
||||
Log.d(TAG, "DisposableEffect.onDispose: readingScreen=false, nothing to cancel")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isDoneReadingCharacter && !cardSelectScreen) {
|
||||
Log.d(TAG, "state gate: done read without cardSelect, calling onComplete")
|
||||
readingScreen = false
|
||||
onComplete()
|
||||
}
|
||||
@ -127,33 +86,24 @@ fun ReadingScreen(
|
||||
if (!readingScreen) {
|
||||
ReadCharacterScreen(
|
||||
onClickConfirm = {
|
||||
Log.d(TAG, "ReadCharacterScreen.onClickConfirm: entering reading screen")
|
||||
readingScreen = true
|
||||
},
|
||||
onClickCancel = {
|
||||
Log.d(TAG, "ReadCharacterScreen.onClickCancel: user cancelled")
|
||||
onCancel()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (readingScreen) {
|
||||
ActionScreen(
|
||||
topBannerText = stringResource(R.string.reading_character_title),
|
||||
detectedTransportMessage = transportMessage,
|
||||
transferStatusMessage = transferStatus,
|
||||
onClickCancel = {
|
||||
Log.d(TAG, "ActionScreen.onCancel: cancelRead + onCancel")
|
||||
ActionScreen(topBannerText = stringResource(R.string.reading_character_title),) {
|
||||
readingScreen = false
|
||||
scanScreenController.cancelRead()
|
||||
onCancel()
|
||||
}
|
||||
)
|
||||
}
|
||||
} else if (cardSelectScreen) {
|
||||
ChooseCard(
|
||||
cards = cardsRead!!,
|
||||
onCardSelected = { card ->
|
||||
Log.d(TAG, "ChooseCard.onCardSelected: selected cardId=${card.id}")
|
||||
cardSelectScreen = false
|
||||
scanScreenController.flushCharacter(card.id)
|
||||
}
|
||||
|
||||
@ -7,21 +7,14 @@ import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import com.github.cfogrady.vbnfc.data.NfcCharacter
|
||||
import com.github.nacabaro.vbhelper.ActivityLifecycleListener
|
||||
import com.github.nacabaro.vbhelper.di.VBHelper
|
||||
import com.github.nacabaro.vbhelper.screens.scanScreen.DetectedTransport
|
||||
import com.github.nacabaro.vbhelper.screens.scanScreen.SCAN_SCREEN_ACTIVITY_LIFECYCLE_LISTENER
|
||||
import com.github.nacabaro.vbhelper.screens.scanScreen.ScanScreenController
|
||||
import com.github.nacabaro.vbhelper.screens.scanScreen.ScanScreenController.WriteResult
|
||||
import com.github.nacabaro.vbhelper.screens.scanScreen.TransferHaptics
|
||||
import com.github.nacabaro.vbhelper.source.StorageRepository
|
||||
import com.github.nacabaro.vbhelper.utils.BitmapData
|
||||
import com.github.nacabaro.vbhelper.utils.ImageBitmapData
|
||||
import com.github.nacabaro.vbhelper.utils.getImageBitmap
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
@ -36,53 +29,18 @@ fun WritingScreen(
|
||||
onCancel: () -> Unit,
|
||||
) {
|
||||
val secrets by scanScreenController.secretsFlow.collectAsState(null)
|
||||
val transferStatus by scanScreenController.transferStatusFlow.collectAsState(null)
|
||||
val detectedTransport by scanScreenController.detectedTransportFlow.collectAsState()
|
||||
|
||||
val transportMessage = when (detectedTransport) {
|
||||
DetectedTransport.NFC_A -> stringResource(R.string.scan_transport_bandai_bracelet)
|
||||
DetectedTransport.ISO_DEP -> stringResource(R.string.scan_transport_vitalwear_hce)
|
||||
DetectedTransport.UNKNOWN -> null
|
||||
}
|
||||
|
||||
val application = LocalContext.current.applicationContext as VBHelper
|
||||
val storageRepository = StorageRepository(application.container.db)
|
||||
val context = LocalContext.current
|
||||
|
||||
val characterPreview by produceState<ImageBitmapData?>(initialValue = null, key1 = characterId) {
|
||||
value = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
storageRepository.getSingleCharacter(characterId)
|
||||
}.getOrNull()?.let { character ->
|
||||
if (character.spriteWidth > 0 && character.spriteHeight > 0 && character.spriteIdle.isNotEmpty()) {
|
||||
BitmapData(
|
||||
bitmap = character.spriteIdle,
|
||||
width = character.spriteWidth,
|
||||
height = character.spriteHeight
|
||||
).getImageBitmap(
|
||||
context = context,
|
||||
multiplier = 4,
|
||||
obscure = false
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var writing by remember { mutableStateOf(false) }
|
||||
var writingScreen by remember { mutableStateOf(false) }
|
||||
var writingConfirmScreen by remember { mutableStateOf(false) }
|
||||
var isDoneSendingCard by remember { mutableStateOf(false) }
|
||||
var isDoneWritingCharacter by remember { mutableStateOf(false) }
|
||||
var writeResult by remember { mutableStateOf<WriteResult?>(null) }
|
||||
|
||||
DisposableEffect(writing) {
|
||||
if (writing) {
|
||||
val transferHaptics = TransferHaptics(application)
|
||||
transferHaptics.onTransferStart()
|
||||
|
||||
scanScreenController.registerActivityLifecycleListener(
|
||||
SCAN_SCREEN_ACTIVITY_LIFECYCLE_LISTENER,
|
||||
object : ActivityLifecycleListener {
|
||||
@ -94,13 +52,10 @@ fun WritingScreen(
|
||||
if (!isDoneSendingCard) {
|
||||
scanScreenController.onClickCheckCard(secrets!!, nfcCharacter) {
|
||||
isDoneSendingCard = true
|
||||
transferHaptics.onTransferProgress()
|
||||
}
|
||||
} else if (!isDoneWritingCharacter) {
|
||||
scanScreenController.onClickWrite(secrets!!, nfcCharacter, characterId) { result ->
|
||||
writeResult = result
|
||||
scanScreenController.onClickWrite(secrets!!, nfcCharacter) {
|
||||
isDoneWritingCharacter = true
|
||||
transferHaptics.onTransferProgress()
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -111,13 +66,10 @@ fun WritingScreen(
|
||||
if (!isDoneSendingCard) {
|
||||
scanScreenController.onClickCheckCard(secrets!!, nfcCharacter) {
|
||||
isDoneSendingCard = true
|
||||
transferHaptics.onTransferProgress()
|
||||
}
|
||||
} else if (!isDoneWritingCharacter) {
|
||||
scanScreenController.onClickWrite(secrets!!, nfcCharacter, characterId) { result ->
|
||||
writeResult = result
|
||||
scanScreenController.onClickWrite(secrets!!, nfcCharacter) {
|
||||
isDoneWritingCharacter = true
|
||||
transferHaptics.onTransferProgress()
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -147,16 +99,10 @@ fun WritingScreen(
|
||||
)
|
||||
} else if (!isDoneSendingCard) {
|
||||
writing = true
|
||||
ActionScreen(
|
||||
topBannerText = stringResource(R.string.sending_card_title),
|
||||
detectedTransportMessage = transportMessage,
|
||||
transferStatusMessage = transferStatus,
|
||||
characterPreview = characterPreview,
|
||||
onClickCancel = {
|
||||
ActionScreen( topBannerText = stringResource(R.string.sending_card_title)) {
|
||||
scanScreenController.cancelRead()
|
||||
onCancel()
|
||||
}
|
||||
)
|
||||
}
|
||||
} else if (!writingConfirmScreen) {
|
||||
writing = false
|
||||
WriteCharacterScreen (
|
||||
@ -171,17 +117,11 @@ fun WritingScreen(
|
||||
)
|
||||
} else if (!isDoneWritingCharacter) {
|
||||
writing = true
|
||||
ActionScreen(
|
||||
topBannerText = stringResource(R.string.writing_character_action_title),
|
||||
detectedTransportMessage = transportMessage,
|
||||
transferStatusMessage = transferStatus,
|
||||
characterPreview = characterPreview,
|
||||
onClickCancel = {
|
||||
ActionScreen(topBannerText = stringResource(R.string.writing_character_action_title)) {
|
||||
isDoneSendingCard = false
|
||||
scanScreenController.cancelRead()
|
||||
onCancel()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
var completedWriting by remember { mutableStateOf(false) }
|
||||
@ -189,11 +129,8 @@ fun WritingScreen(
|
||||
LaunchedEffect(isDoneSendingCard, isDoneWritingCharacter) {
|
||||
withContext(Dispatchers.IO) {
|
||||
if (isDoneSendingCard && isDoneWritingCharacter) {
|
||||
// Only delete source character if transfer was confirmed as move.
|
||||
// COPIED means transfer was sent but not confirmed, so keep source.
|
||||
if (writeResult == WriteResult.MOVE_CONFIRMED) {
|
||||
storageRepository.deleteCharacter(characterId)
|
||||
}
|
||||
storageRepository
|
||||
.deleteCharacter(characterId)
|
||||
completedWriting = true
|
||||
}
|
||||
}
|
||||
|
||||
@ -5,7 +5,6 @@ import android.net.Uri
|
||||
import androidx.compose.foundation.clickable
|
||||
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
|
||||
@ -13,12 +12,8 @@ import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
@ -72,7 +67,6 @@ fun SettingsScreen(
|
||||
settingsScreenController.onClickImportCard()
|
||||
}
|
||||
|
||||
|
||||
SettingsSection(title = stringResource(R.string.settings_section_about))
|
||||
SettingsEntry(
|
||||
title = stringResource(R.string.settings_credits_title),
|
||||
@ -104,38 +98,6 @@ fun SettingsScreen(
|
||||
) {
|
||||
settingsScreenController.onClickImportDatabase()
|
||||
}
|
||||
|
||||
SettingsSection(title = stringResource(R.string.settings_section_companion_tools))
|
||||
SettingsEntry(
|
||||
title = stringResource(R.string.settings_companion_import_card_image_title),
|
||||
description = stringResource(R.string.settings_companion_import_card_image_desc)
|
||||
) {
|
||||
settingsScreenController.onClickCompanionImportCardImage()
|
||||
}
|
||||
SettingsEntry(
|
||||
title = stringResource(R.string.settings_companion_import_firmware_title),
|
||||
description = stringResource(R.string.settings_companion_import_firmware_desc)
|
||||
) {
|
||||
settingsScreenController.onClickCompanionImportFirmware()
|
||||
}
|
||||
SettingsEntry(
|
||||
title = stringResource(R.string.settings_companion_send_watch_logs_title),
|
||||
description = stringResource(R.string.settings_companion_send_watch_logs_desc)
|
||||
) {
|
||||
settingsScreenController.onClickCompanionSendWatchLogs()
|
||||
}
|
||||
SettingsEntry(
|
||||
title = stringResource(R.string.settings_companion_send_phone_logs_title),
|
||||
description = stringResource(R.string.settings_companion_send_phone_logs_desc)
|
||||
) {
|
||||
settingsScreenController.onClickCompanionSendPhoneLogs()
|
||||
}
|
||||
SettingsSwitchEntry(
|
||||
title = stringResource(R.string.settings_enable_dim_to_bem_title),
|
||||
description = stringResource(R.string.settings_enable_dim_to_bem_desc),
|
||||
isEnabled = settingsScreenController,
|
||||
onToggle = { settingsScreenController.onToggleDimToBemConversion(it) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -161,40 +123,6 @@ fun SettingsEntry(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SettingsSwitchEntry(
|
||||
title: String,
|
||||
description: String,
|
||||
isEnabled: SettingsScreenControllerImpl,
|
||||
onToggle: (Boolean) -> Unit
|
||||
) {
|
||||
val dimToBemEnabled by isEnabled.dimToBemConversionEnabled.collectAsState(false)
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(end = 16.dp)
|
||||
) {
|
||||
Text(text = title)
|
||||
Text(
|
||||
text = description,
|
||||
fontSize = 12.sp,
|
||||
color = MaterialTheme.colorScheme.outline
|
||||
)
|
||||
}
|
||||
Switch(
|
||||
checked = dimToBemEnabled,
|
||||
onCheckedChange = onToggle
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SettingsSection(
|
||||
title: String
|
||||
|
||||
@ -5,8 +5,4 @@ interface SettingsScreenController {
|
||||
fun onClickImportDatabase()
|
||||
fun onClickImportApk()
|
||||
fun onClickImportCard()
|
||||
fun onClickCompanionImportCardImage()
|
||||
fun onClickCompanionImportFirmware()
|
||||
fun onClickCompanionSendWatchLogs()
|
||||
fun onClickCompanionSendPhoneLogs()
|
||||
}
|
||||
@ -1,6 +1,5 @@
|
||||
package com.github.nacabaro.vbhelper.screens.settingsScreen
|
||||
|
||||
import android.content.Intent
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import kotlinx.coroutines.launch
|
||||
import android.net.Uri
|
||||
@ -8,13 +7,8 @@ import android.widget.Toast
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.result.ActivityResultLauncher
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import com.github.nacabaro.vbhelper.companion.card.CompanionImportCardActivity
|
||||
import com.github.nacabaro.vbhelper.companion.firmware.CompanionFirmwareImportActivity
|
||||
import com.github.nacabaro.vbhelper.companion.logs.CompanionWatchLogsActivity
|
||||
import com.github.nacabaro.vbhelper.companion.logging.TinyLogTree
|
||||
import com.github.nacabaro.vbhelper.database.AppDatabase
|
||||
import com.github.nacabaro.vbhelper.di.VBHelper
|
||||
import com.github.nacabaro.vbhelper.R
|
||||
import com.github.nacabaro.vbhelper.screens.settingsScreen.controllers.CardImportController
|
||||
import com.github.nacabaro.vbhelper.screens.settingsScreen.controllers.DatabaseManagementController
|
||||
import com.github.nacabaro.vbhelper.source.ApkSecretsImporter
|
||||
@ -22,7 +16,6 @@ import com.github.nacabaro.vbhelper.source.SecretsImporter
|
||||
import com.github.nacabaro.vbhelper.source.SecretsRepository
|
||||
import com.github.nacabaro.vbhelper.source.proto.Secrets
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.first
|
||||
|
||||
|
||||
class SettingsScreenControllerImpl(
|
||||
@ -36,14 +29,11 @@ class SettingsScreenControllerImpl(
|
||||
private val application = context.applicationContext as VBHelper
|
||||
private val secretsRepository: SecretsRepository = application.container.dataStoreSecretsRepository
|
||||
private val database: AppDatabase = application.container.db
|
||||
private val cardSettingsRepository = application.container.cardSettingsRepository
|
||||
private val databaseManagementController = DatabaseManagementController(
|
||||
componentActivity = context,
|
||||
application = application
|
||||
)
|
||||
|
||||
val dimToBemConversionEnabled = cardSettingsRepository.enableDimToBemConversion
|
||||
|
||||
init {
|
||||
filePickerLauncher = context.registerForActivityResult(
|
||||
ActivityResultContracts.CreateDocument("application/octet-stream")
|
||||
@ -111,41 +101,13 @@ class SettingsScreenControllerImpl(
|
||||
filePickerCard.launch(arrayOf("*/*"))
|
||||
}
|
||||
|
||||
override fun onClickCompanionImportCardImage() {
|
||||
context.startActivity(Intent(context, CompanionImportCardActivity::class.java))
|
||||
}
|
||||
|
||||
override fun onClickCompanionImportFirmware() {
|
||||
context.startActivity(Intent(context, CompanionFirmwareImportActivity::class.java))
|
||||
}
|
||||
|
||||
override fun onClickCompanionSendWatchLogs() {
|
||||
context.startActivity(Intent(context, CompanionWatchLogsActivity::class.java))
|
||||
}
|
||||
|
||||
override fun onClickCompanionSendPhoneLogs() {
|
||||
val file = TinyLogTree.getMostRecentLogFile(context.applicationContext)
|
||||
if (file == null) {
|
||||
Toast.makeText(context, context.getString(R.string.companion_logs_no_files), Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
}
|
||||
application.companionLogService.sendLogFile(context.applicationContext, file, context)
|
||||
}
|
||||
|
||||
fun onToggleDimToBemConversion(enabled: Boolean) {
|
||||
context.lifecycleScope.launch(Dispatchers.IO) {
|
||||
cardSettingsRepository.setEnableDimToBemConversion(enabled)
|
||||
}
|
||||
}
|
||||
|
||||
private fun importCard(uri: Uri) {
|
||||
context.lifecycleScope.launch(Dispatchers.IO) {
|
||||
val contentResolver = context.contentResolver
|
||||
val inputStream = contentResolver.openInputStream(uri)
|
||||
|
||||
inputStream.use { fileReader ->
|
||||
val dimToBemEnabled = cardSettingsRepository.enableDimToBemConversion.first()
|
||||
val cardImportController = CardImportController(database, dimToBemEnabled)
|
||||
val cardImportController = CardImportController(database)
|
||||
cardImportController.importCard(fileReader)
|
||||
}
|
||||
|
||||
|
||||
@ -13,8 +13,7 @@ import com.github.nacabaro.vbhelper.domain.characters.Sprite
|
||||
import java.io.InputStream
|
||||
|
||||
class CardImportController(
|
||||
private val database: AppDatabase,
|
||||
private val enableDimToBemConversion: Boolean = false
|
||||
private val database: AppDatabase
|
||||
) {
|
||||
suspend fun importCard(
|
||||
fileReader: InputStream?
|
||||
@ -118,16 +117,6 @@ class CardImportController(
|
||||
.spriteDao()
|
||||
.insertSprite(domainSprite)
|
||||
|
||||
// Apply DIM-to-BEM conversion if enabled and card is a DIM card (not BEM)
|
||||
var baseHp = characters[index].hp
|
||||
var baseBp = characters[index].dp
|
||||
var baseAp = characters[index].ap
|
||||
|
||||
if (enableDimToBemConversion && card is DimCard) {
|
||||
baseHp = convertDimStatToBem(baseHp, characters[index].stage, Stat.HP)
|
||||
baseBp = convertDimStatToBem(baseBp, characters[index].stage, Stat.BP)
|
||||
baseAp = convertDimStatToBem(baseAp, characters[index].stage, Stat.AP)
|
||||
}
|
||||
|
||||
domainCharacters.add(
|
||||
CardCharacter(
|
||||
@ -137,9 +126,9 @@ class CardImportController(
|
||||
nameSprite = card.spriteData.sprites[spriteCounter].pixelData,
|
||||
stage = characters[index].stage,
|
||||
attribute = NfcCharacter.Attribute.entries[characters[index].attribute],
|
||||
baseHp = baseHp,
|
||||
baseBp = baseBp,
|
||||
baseAp = baseAp,
|
||||
baseHp = characters[index].hp,
|
||||
baseBp = characters[index].dp,
|
||||
baseAp = characters[index].ap,
|
||||
nameWidth = card.spriteData.sprites[spriteCounter].spriteDimensions.width,
|
||||
nameHeight = card.spriteData.sprites[spriteCounter].spriteDimensions.height
|
||||
)
|
||||
@ -161,40 +150,6 @@ class CardImportController(
|
||||
.insertCharacter(*domainCharacters.toTypedArray())
|
||||
}
|
||||
|
||||
private enum class Stat { HP, BP, AP }
|
||||
|
||||
private fun convertDimStatToBem(dimStat: Int, phase: Int, statType: Stat): Int {
|
||||
// DIM-to-BEM conversion factors based on phase and stat type
|
||||
// These are approximations based on the average conversion ratios
|
||||
val conversionFactor = when (phase) {
|
||||
1 -> 0.0f
|
||||
2 -> 0.0f
|
||||
3 -> when (statType) {
|
||||
Stat.HP -> 4657.5f
|
||||
Stat.BP -> 4657.5f
|
||||
Stat.AP -> 1022.5f
|
||||
}
|
||||
4 -> when (statType) {
|
||||
Stat.HP -> 5171.528f
|
||||
Stat.BP -> 5028.528f
|
||||
Stat.AP -> 1222.9167f
|
||||
}
|
||||
5 -> when (statType) {
|
||||
Stat.HP -> 5664.4f
|
||||
Stat.BP -> 5036.4f
|
||||
Stat.AP -> 1505.7333f
|
||||
}
|
||||
6 -> when (statType) {
|
||||
Stat.HP -> 5972.2974f
|
||||
Stat.BP -> 5256.2974f
|
||||
Stat.AP -> 1976.7568f
|
||||
}
|
||||
else -> 0.0f
|
||||
}
|
||||
|
||||
return (dimStat * conversionFactor).toInt()
|
||||
}
|
||||
|
||||
private suspend fun importAdventureMissions(
|
||||
cardId: Long,
|
||||
card: com.github.cfogrady.vb.dim.card.Card<*, *, *, *, *, *>
|
||||
|
||||
@ -1,14 +1,11 @@
|
||||
package com.github.nacabaro.vbhelper.screens.settingsScreen.controllers
|
||||
|
||||
import androidx.room.Room
|
||||
import android.database.sqlite.SQLiteDatabase
|
||||
import android.net.Uri
|
||||
import android.provider.OpenableColumns
|
||||
import android.util.Log
|
||||
import android.widget.Toast
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.github.nacabaro.vbhelper.database.AppDatabase
|
||||
import com.github.nacabaro.vbhelper.di.VBHelper
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
@ -21,19 +18,6 @@ class DatabaseManagementController(
|
||||
val application: VBHelper
|
||||
) {
|
||||
private val roomDbName = "internalDb"
|
||||
private val requiredLegacyVersions = setOf(1, 2, 3, 4, 5)
|
||||
private val requiredCoreTables = setOf(
|
||||
"UserCharacter",
|
||||
"Character",
|
||||
"Card",
|
||||
"CardCharacter",
|
||||
"TransformationHistory",
|
||||
)
|
||||
|
||||
private data class ValidationResult(
|
||||
val isValid: Boolean,
|
||||
val reason: String? = null,
|
||||
)
|
||||
|
||||
fun exportDatabase( destinationUri: Uri) {
|
||||
componentActivity.lifecycleScope.launch(Dispatchers.IO) {
|
||||
@ -75,19 +59,6 @@ class DatabaseManagementController(
|
||||
return@launch
|
||||
}
|
||||
|
||||
val validationResult = validateImportBackup(sourceUri)
|
||||
if (!validationResult.isValid) {
|
||||
componentActivity.runOnUiThread {
|
||||
Toast.makeText(
|
||||
componentActivity,
|
||||
validationResult.reason
|
||||
?: "Import blocked: backup is incompatible with this VBH-VW version.",
|
||||
Toast.LENGTH_LONG
|
||||
).show()
|
||||
}
|
||||
return@launch
|
||||
}
|
||||
|
||||
application.container.db.close()
|
||||
|
||||
val dbPath = componentActivity.getDatabasePath(roomDbName)
|
||||
@ -121,118 +92,6 @@ class DatabaseManagementController(
|
||||
}
|
||||
}
|
||||
|
||||
private fun validateImportBackup(sourceUri: Uri): ValidationResult {
|
||||
val tempDbName = "import_validation_temp.db"
|
||||
val tempDbPath = componentActivity.getDatabasePath(tempDbName)
|
||||
val tempShm = File(tempDbPath.parentFile, "$tempDbName-shm")
|
||||
val tempWal = File(tempDbPath.parentFile, "$tempDbName-wal")
|
||||
|
||||
try {
|
||||
if (tempDbPath.exists()) tempDbPath.delete()
|
||||
if (tempShm.exists()) tempShm.delete()
|
||||
if (tempWal.exists()) tempWal.delete()
|
||||
|
||||
componentActivity.contentResolver.openInputStream(sourceUri)?.use { inputStream ->
|
||||
tempDbPath.outputStream().use { outputStream ->
|
||||
copyFile(inputStream, outputStream)
|
||||
}
|
||||
} ?: return ValidationResult(false, "Import blocked: unable to read selected backup file.")
|
||||
|
||||
// Fast static checks before attempting full Room open/migration.
|
||||
val legacyVersion = readPragmaUserVersion(tempDbPath)
|
||||
if (legacyVersion !in requiredLegacyVersions) {
|
||||
return ValidationResult(
|
||||
false,
|
||||
"Import blocked: unsupported DB version ($legacyVersion). Use a VBH-VW export from a compatible app version."
|
||||
)
|
||||
}
|
||||
|
||||
val tableNames = readTableNames(tempDbPath)
|
||||
if (!tableNames.contains("room_master_table")) {
|
||||
return ValidationResult(
|
||||
false,
|
||||
"Import blocked: backup is not a Room VBH-VW database (room metadata missing)."
|
||||
)
|
||||
}
|
||||
|
||||
val missingTables = requiredCoreTables.filterNot { tableNames.contains(it) }
|
||||
if (missingTables.isNotEmpty()) {
|
||||
return ValidationResult(
|
||||
false,
|
||||
"Import blocked: backup is missing required tables (${missingTables.joinToString(", ")})."
|
||||
)
|
||||
}
|
||||
|
||||
// Hard validation: open with the app's exact Room schema + migrations.
|
||||
val probeDb = Room.databaseBuilder(
|
||||
componentActivity,
|
||||
AppDatabase::class.java,
|
||||
tempDbName
|
||||
)
|
||||
.addMigrations(AppDatabase.MIGRATION_1_2)
|
||||
.addMigrations(AppDatabase.MIGRATION_2_3)
|
||||
.addMigrations(AppDatabase.MIGRATION_3_5)
|
||||
.addMigrations(AppDatabase.MIGRATION_4_5)
|
||||
.addMigrations(AppDatabase.MIGRATION_5_6)
|
||||
.build()
|
||||
|
||||
try {
|
||||
probeDb.openHelper.writableDatabase.query("SELECT 1").close()
|
||||
} catch (e: Exception) {
|
||||
return ValidationResult(
|
||||
false,
|
||||
"Import blocked: backup cannot be opened/migrated by this VBH-VW build."
|
||||
)
|
||||
} finally {
|
||||
probeDb.close()
|
||||
}
|
||||
|
||||
return ValidationResult(true)
|
||||
} finally {
|
||||
if (tempDbPath.exists()) tempDbPath.delete()
|
||||
if (tempShm.exists()) tempShm.delete()
|
||||
if (tempWal.exists()) tempWal.delete()
|
||||
}
|
||||
}
|
||||
|
||||
private fun readPragmaUserVersion(dbFile: File): Int {
|
||||
val sqliteDb = SQLiteDatabase.openDatabase(
|
||||
dbFile.absolutePath,
|
||||
null,
|
||||
SQLiteDatabase.OPEN_READONLY
|
||||
)
|
||||
try {
|
||||
sqliteDb.rawQuery("PRAGMA user_version", null).use { cursor ->
|
||||
if (cursor.moveToFirst()) {
|
||||
return cursor.getInt(0)
|
||||
}
|
||||
}
|
||||
return -1
|
||||
} finally {
|
||||
sqliteDb.close()
|
||||
}
|
||||
}
|
||||
|
||||
private fun readTableNames(dbFile: File): Set<String> {
|
||||
val sqliteDb = SQLiteDatabase.openDatabase(
|
||||
dbFile.absolutePath,
|
||||
null,
|
||||
SQLiteDatabase.OPEN_READONLY
|
||||
)
|
||||
try {
|
||||
val tableNames = mutableSetOf<String>()
|
||||
sqliteDb.rawQuery("SELECT name FROM sqlite_master WHERE type='table'", null)
|
||||
.use { cursor ->
|
||||
while (cursor.moveToNext()) {
|
||||
tableNames.add(cursor.getString(0))
|
||||
}
|
||||
}
|
||||
return tableNames
|
||||
} finally {
|
||||
sqliteDb.close()
|
||||
}
|
||||
}
|
||||
|
||||
private fun copyFile(inputStream: InputStream, outputStream: OutputStream) {
|
||||
val buffer = ByteArray(1024)
|
||||
var bytesRead: Int
|
||||
|
||||
@ -44,18 +44,12 @@ class AuthRepository(
|
||||
preferences[IS_AUTHENTICATED] = isAuthenticated
|
||||
if (nacatechToken != null) {
|
||||
preferences[AUTH_TOKEN] = nacatechToken
|
||||
} else {
|
||||
preferences.remove(AUTH_TOKEN)
|
||||
}
|
||||
if (sessionToken != null) {
|
||||
preferences[SESSION_TOKEN] = sessionToken
|
||||
} else {
|
||||
preferences.remove(SESSION_TOKEN)
|
||||
}
|
||||
if (userId != null) {
|
||||
preferences[USER_ID] = userId
|
||||
} else {
|
||||
preferences.remove(USER_ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,199 +1,114 @@
|
||||
package com.github.nacabaro.vbhelper.source
|
||||
|
||||
import android.content.ClipData
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.core.content.FileProvider
|
||||
import com.github.cfogrady.vitalwear.protos.Character
|
||||
import com.github.nacabaro.vbhelper.database.AppDatabase
|
||||
import com.github.nacabaro.vbhelper.domain.device_data.BECharacterData
|
||||
import com.github.nacabaro.vbhelper.utils.DeviceType
|
||||
import com.google.protobuf.ByteString
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
|
||||
internal const val DEFAULT_VITALWEAR_TRAINING_TIME_SECONDS = 100L * 60L * 60L
|
||||
|
||||
internal fun resolveTrainingSeconds(
|
||||
deviceType: DeviceType,
|
||||
beData: BECharacterData?,
|
||||
): Long {
|
||||
val remainingTrainingTimeInMinutes = beData?.remainingTrainingTimeInMinutes
|
||||
if (remainingTrainingTimeInMinutes != null) {
|
||||
return remainingTrainingTimeInMinutes.toLong() * 60L
|
||||
}
|
||||
|
||||
// VBHelper only stores the explicit training timer on BE payloads. When exporting non-BE
|
||||
// characters to VitalWear, preserve their ability to train by seeding the same default
|
||||
// window VitalWear uses for freshly created partners instead of sending an immediate zero.
|
||||
return if (deviceType == DeviceType.BEDevice) {
|
||||
0L
|
||||
} else {
|
||||
DEFAULT_VITALWEAR_TRAINING_TIME_SECONDS
|
||||
}
|
||||
}
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import java.io.File
|
||||
|
||||
class VitalWearCharacterExporter(
|
||||
private val context: Context,
|
||||
private val database: AppDatabase
|
||||
) {
|
||||
/**
|
||||
* Builds a Character proto from the stored character data.
|
||||
* Used by the HCE ISO-DEP transfer path.
|
||||
*
|
||||
* @param characterId ID of the character to export
|
||||
* @param forcedTransferProfile If specified (e.g., DeviceType.BEDevice for HCE), overrides stored device type
|
||||
* and ensures stats are serialized for the target ecosystem.
|
||||
* Used to lock VitalWear (HCE) transfers to BE profile.
|
||||
*/
|
||||
suspend fun buildCharacterProto(
|
||||
characterId: Long,
|
||||
forcedTransferProfile: DeviceType? = null,
|
||||
): Character {
|
||||
val characterWithSprites = database.userCharacterDao().getCharacterWithSprites(characterId)
|
||||
val userCharacter = database.userCharacterDao().getCharacter(characterId)
|
||||
val cardCharacter = database.characterDao().getCharacterById(userCharacter.charId)
|
||||
?: error("Card character not found for user character $characterId")
|
||||
val sprite = database.spriteDao().getSpriteById(cardCharacter.spriteId)
|
||||
?: error("Sprite not found for user character $characterId")
|
||||
val characterInfo = database.characterDao().getCharacterInfo(userCharacter.charId)
|
||||
val vwSettings = database.vitalWearSettingsDao().getByCharacterId(characterId)
|
||||
val card = database.cardDao().getCardByCharacterIdSync(characterId)
|
||||
?: error("Card not found for character $characterId")
|
||||
val cardProgress = database.cardProgressDao().getCardProgressSync(card.id) ?: 0
|
||||
val beData = database.userCharacterDao().getBeDataOrNull(characterId)
|
||||
val vbData = database.userCharacterDao().getVbDataOrNull(characterId)
|
||||
val normalizedTransformationCountdownMinutes = normalizeTransformationCountdownMinutes(
|
||||
transformationCountdownMinutes = userCharacter.transformationCountdown,
|
||||
hasPossibleTransformations = hasPossibleTransformations(userCharacter.charId),
|
||||
)
|
||||
|
||||
// Use forced profile if specified (e.g., BE for HCE route), otherwise use stored type.
|
||||
// For HCE/VitalWear routes, forcedTransferProfile = BE ensures BE stats are serialized.
|
||||
val transferDeviceType = forcedTransferProfile ?: userCharacter.characterType
|
||||
|
||||
val settingsBuilder = Character.Settings.newBuilder()
|
||||
.setTrainingInBackground(vwSettings?.trainingInBackground ?: false)
|
||||
.setAllowedBattles(resolveAllowedBattles(vwSettings?.allowedBattles))
|
||||
|
||||
// VitalWear chooses DIM-vs-BE runtime path from card type + assumedFranchise.
|
||||
// For forced BE HCE exports of DIM cards, provide a stable assumed franchise hint.
|
||||
if (forcedTransferProfile == DeviceType.BEDevice && !card.isBEm) {
|
||||
settingsBuilder.setAssumedFranchise(0)
|
||||
}
|
||||
|
||||
return Character.newBuilder()
|
||||
.setCardId(card.cardId)
|
||||
.setCardName(card.name)
|
||||
.setCharacterStats(
|
||||
Character.CharacterStats.newBuilder()
|
||||
.setSlotId(characterInfo.charId)
|
||||
.setVitals(characterWithSprites.vitalPoints)
|
||||
.setTrainingTimeRemainingInSeconds(resolveTrainingSeconds(transferDeviceType, beData))
|
||||
.setTimeUntilNextTransformation(normalizedTransformationCountdownMinutes.toLong() * 60L)
|
||||
.setTrainedBp(resolveTrainedBp(beData))
|
||||
.setTrainedHp(resolveTrainedHp(beData))
|
||||
.setTrainedAp(resolveTrainedAp(beData))
|
||||
.setTrainedPp(characterWithSprites.trophies)
|
||||
.setInjured(characterWithSprites.injuryStatus.name.lowercase().contains("inj"))
|
||||
.setAccumulatedDailyInjuries(vwSettings?.accumulatedDailyInjuries ?: 0)
|
||||
.setTotalBattles(characterWithSprites.totalBattlesWon + characterWithSprites.totalBattlesLost)
|
||||
.setCurrentPhaseBattles(characterWithSprites.currentPhaseBattlesWon + characterWithSprites.currentPhaseBattlesLost)
|
||||
.setTotalWins(characterWithSprites.totalBattlesWon)
|
||||
.setCurrentPhaseWins(characterWithSprites.currentPhaseBattlesWon)
|
||||
.setMood(characterWithSprites.mood)
|
||||
.setDeviceType(transferDeviceType.toTransferDeviceType())
|
||||
.setAgeInDays(userCharacter.ageInDays.coerceAtLeast(0))
|
||||
.setActivityLevel(userCharacter.activityLevel.coerceAtLeast(0))
|
||||
.setHeartRateCurrent(userCharacter.heartRateCurrent.coerceAtLeast(0))
|
||||
.setGeneration(vbData?.generation ?: 0)
|
||||
.setTotalTrophies(vbData?.totalTrophies ?: userCharacter.trophies.coerceAtLeast(0))
|
||||
.setNextAdventureMissionStage(characterInfo.currentStage.coerceAtLeast(0))
|
||||
.setItemEffectMentalStateValue(beData?.itemEffectMentalStateValue ?: 0)
|
||||
.setItemEffectMentalStateMinutesRemaining(beData?.itemEffectMentalStateMinutesRemaining ?: 0)
|
||||
.setItemEffectActivityLevelValue(beData?.itemEffectActivityLevelValue ?: 0)
|
||||
.setItemEffectActivityLevelMinutesRemaining(beData?.itemEffectActivityLevelMinutesRemaining ?: 0)
|
||||
.setItemEffectVitalPointsChangeValue(beData?.itemEffectVitalPointsChangeValue ?: 0)
|
||||
.setItemEffectVitalPointsChangeMinutesRemaining(beData?.itemEffectVitalPointsChangeMinutesRemaining ?: 0)
|
||||
.setAbilityRarity(beData?.abilityRarity?.ordinal ?: 0)
|
||||
.setAbilityType(beData?.abilityType ?: 0)
|
||||
.setAbilityBranch(beData?.abilityBranch ?: 0)
|
||||
.setAbilityReset(beData?.abilityReset ?: 0)
|
||||
.setRank(beData?.rank ?: 0)
|
||||
.setItemType(beData?.itemType ?: 0)
|
||||
.setItemMultiplier(beData?.itemMultiplier ?: 0)
|
||||
.setItemRemainingTime(beData?.itemRemainingTime ?: 0)
|
||||
.setFirmwareMinorVersion(beData?.minorVersion ?: 0)
|
||||
.setFirmwareMajorVersion(beData?.majorVersion ?: 0)
|
||||
.build()
|
||||
)
|
||||
.setSettings(settingsBuilder.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)
|
||||
fun buildShareIntent(characterId: Long): Intent {
|
||||
return runBlocking {
|
||||
val characterWithSprites = database.userCharacterDao().getCharacterWithSprites(characterId)
|
||||
val userCharacter = database.userCharacterDao().getCharacter(characterId)
|
||||
val card = database.cardDao().getCardByCharacterIdSync(characterId)
|
||||
?: error("Card not found for character $characterId")
|
||||
val cardProgress = database.cardProgressDao().getCardProgressSync(card.id) ?: 0
|
||||
val proto = Character.newBuilder()
|
||||
.setCardId(card.cardId)
|
||||
.setCardName(card.name)
|
||||
.setCharacterStats(
|
||||
Character.CharacterStats.newBuilder()
|
||||
.setSlotId(database.userCharacterDao().getCharacterInfo(characterId).charaIndex)
|
||||
.setVitals(characterWithSprites.vitalPoints)
|
||||
.setTrainingTimeRemainingInSeconds(resolveTrainingSeconds(characterId, userCharacter.characterType))
|
||||
.setTimeUntilNextTransformation(characterWithSprites.transformationCountdown.toLong() * 60L)
|
||||
.setTrainedBp(resolveTrainedBp(characterId, userCharacter.characterType))
|
||||
.setTrainedHp(resolveTrainedHp(characterId, userCharacter.characterType))
|
||||
.setTrainedAp(resolveTrainedAp(characterId, userCharacter.characterType))
|
||||
.setTrainedPp(characterWithSprites.trophies)
|
||||
.setInjured(characterWithSprites.injuryStatus.name.lowercase().contains("inj"))
|
||||
.setAccumulatedDailyInjuries(0)
|
||||
.setTotalBattles(characterWithSprites.totalBattlesWon + characterWithSprites.totalBattlesLost)
|
||||
.setCurrentPhaseBattles(characterWithSprites.currentPhaseBattlesWon + characterWithSprites.currentPhaseBattlesLost)
|
||||
.setTotalWins(characterWithSprites.totalBattlesWon)
|
||||
.setCurrentPhaseWins(characterWithSprites.currentPhaseBattlesWon)
|
||||
.setMood(characterWithSprites.mood)
|
||||
.build()
|
||||
}
|
||||
)
|
||||
.setTransferCard(
|
||||
Character.TransferCard.newBuilder()
|
||||
.setCardId(card.cardId)
|
||||
.setCardName(card.name)
|
||||
.setIsBem(card.isBEm)
|
||||
.setStageCount(card.stageCount)
|
||||
.setFranchise(0)
|
||||
.build()
|
||||
)
|
||||
.setTransferSpecies(
|
||||
Character.TransferSpecies.newBuilder()
|
||||
.setSlotId(characterInfo.charId)
|
||||
.setStage(cardCharacter.stage)
|
||||
.setAttribute(cardCharacter.attribute.ordinal)
|
||||
.setBaseHp(cardCharacter.baseHp)
|
||||
.setBaseBp(cardCharacter.baseBp)
|
||||
.setBaseAp(cardCharacter.baseAp)
|
||||
.setNameSprite(ByteString.copyFrom(cardCharacter.nameSprite))
|
||||
.setNameSpriteWidth(cardCharacter.nameWidth)
|
||||
.setNameSpriteHeight(cardCharacter.nameHeight)
|
||||
.setIdle1(ByteString.copyFrom(sprite.spriteIdle1))
|
||||
.setIdle2(ByteString.copyFrom(sprite.spriteIdle2))
|
||||
.setWalk1(ByteString.copyFrom(sprite.spriteWalk1))
|
||||
.setWalk2(ByteString.copyFrom(sprite.spriteWalk2))
|
||||
.setRun1(ByteString.copyFrom(sprite.spriteRun1))
|
||||
.setRun2(ByteString.copyFrom(sprite.spriteRun2))
|
||||
.setTrain1(ByteString.copyFrom(sprite.spriteTrain1))
|
||||
.setTrain2(ByteString.copyFrom(sprite.spriteTrain2))
|
||||
.setWin(ByteString.copyFrom(sprite.spriteHappy))
|
||||
.setDown(ByteString.copyFrom(sprite.spriteSleep))
|
||||
.setAttack(ByteString.copyFrom(sprite.spriteAttack))
|
||||
.setDodge(ByteString.copyFrom(sprite.spriteDodge))
|
||||
.setSplash(ByteString.copyFrom(sprite.spriteIdle1))
|
||||
.setSpriteWidth(sprite.width)
|
||||
.setSpriteHeight(sprite.height)
|
||||
.build()
|
||||
)
|
||||
.build()
|
||||
)
|
||||
.setSettings(
|
||||
Character.Settings.newBuilder()
|
||||
.setTrainingInBackground(false)
|
||||
.setAllowedBattles(Character.Settings.AllowedBattles.CARD_ONLY)
|
||||
.build()
|
||||
)
|
||||
.putMaxAdventureCompletedByCard(card.name, (cardProgress - 1).coerceAtLeast(0))
|
||||
.addAllTransformationHistory(
|
||||
database.userCharacterDao().getTransformationHistoryForExport(characterId).map {
|
||||
Character.TransformationEvent.newBuilder()
|
||||
.setCardName(it.cardName)
|
||||
.setPhase(0)
|
||||
.setSlotId(it.monIndex)
|
||||
.build()
|
||||
}
|
||||
)
|
||||
.build()
|
||||
|
||||
val exportDir = File(context.cacheDir, "exports").apply { mkdirs() }
|
||||
val exportFile = File(exportDir, "vbhelper_character_$characterId.vitalwear")
|
||||
exportFile.writeBytes(proto.toByteArray())
|
||||
val exportUri = FileProvider.getUriForFile(context, "${context.packageName}.provider", exportFile)
|
||||
|
||||
Intent(Intent.ACTION_SEND).apply {
|
||||
`package` = "com.github.cfogrady.vitalwear"
|
||||
type = VITALWEAR_CHARACTER_MIME
|
||||
putExtra(Intent.EXTRA_STREAM, exportUri)
|
||||
clipData = ClipData.newRawUri("", exportUri)
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private fun resolveTrainedBp(beData: com.github.nacabaro.vbhelper.domain.device_data.BECharacterData?): Int {
|
||||
return beData?.trainingBp ?: 0
|
||||
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 resolveTrainedHp(beData: com.github.nacabaro.vbhelper.domain.device_data.BECharacterData?): Int {
|
||||
return beData?.trainingHp ?: 0
|
||||
private fun resolveTrainedBp(characterId: Long, deviceType: DeviceType): Int {
|
||||
return if (deviceType == DeviceType.BEDevice) {
|
||||
database.userCharacterDao().getBeData(characterId).valueOrNull()?.trainingBp ?: 0
|
||||
} else 0
|
||||
}
|
||||
|
||||
private fun resolveTrainedAp(beData: com.github.nacabaro.vbhelper.domain.device_data.BECharacterData?): Int {
|
||||
return beData?.trainingAp ?: 0
|
||||
private fun resolveTrainedHp(characterId: Long, deviceType: DeviceType): Int {
|
||||
return if (deviceType == DeviceType.BEDevice) {
|
||||
database.userCharacterDao().getBeData(characterId).valueOrNull()?.trainingHp ?: 0
|
||||
} else 0
|
||||
}
|
||||
|
||||
private suspend fun hasPossibleTransformations(cardCharacterId: Long): Boolean {
|
||||
return database.characterDao().getEvolutionRequirementsForCard(cardCharacterId).firstOrNull()?.isNotEmpty() == true
|
||||
private fun resolveTrainedAp(characterId: Long, deviceType: DeviceType): Int {
|
||||
return if (deviceType == DeviceType.BEDevice) {
|
||||
database.userCharacterDao().getBeData(characterId).valueOrNull()?.trainingAp ?: 0
|
||||
} else 0
|
||||
}
|
||||
|
||||
|
||||
private fun resolveAllowedBattles(rawValue: Int?): Character.Settings.AllowedBattles {
|
||||
if (rawValue == null) return Character.Settings.AllowedBattles.CARD_ONLY
|
||||
return Character.Settings.AllowedBattles.forNumber(rawValue)
|
||||
?: Character.Settings.AllowedBattles.CARD_ONLY
|
||||
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,25 +1,15 @@
|
||||
package com.github.nacabaro.vbhelper.source
|
||||
|
||||
import com.github.cfogrady.vbnfc.data.NfcCharacter
|
||||
import com.github.cfogrady.vitalwear.common.data.SharedTransferSeenDao
|
||||
import com.github.cfogrady.vitalwear.protos.Character
|
||||
import com.github.nacabaro.vbhelper.database.AppDatabase
|
||||
import com.github.nacabaro.vbhelper.domain.card.Card
|
||||
import com.github.nacabaro.vbhelper.domain.card.CardCharacter
|
||||
import com.github.nacabaro.vbhelper.domain.card.CardProgress
|
||||
import com.github.nacabaro.vbhelper.domain.device_data.BECharacterData
|
||||
import com.github.nacabaro.vbhelper.domain.device_data.UserCharacter
|
||||
import com.github.nacabaro.vbhelper.domain.device_data.VBCharacterData
|
||||
import com.github.nacabaro.vbhelper.domain.device_data.VitalWearCharacterSettings
|
||||
import com.github.nacabaro.vbhelper.domain.characters.Sprite
|
||||
import com.github.nacabaro.vbhelper.utils.DeviceType
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlin.math.max
|
||||
|
||||
class VitalWearCharacterImporter(
|
||||
private val database: AppDatabase,
|
||||
private val transferSeenDao: SharedTransferSeenDao,
|
||||
private val database: AppDatabase
|
||||
) {
|
||||
data class ImportResult(
|
||||
val success: Boolean,
|
||||
@ -28,16 +18,15 @@ class VitalWearCharacterImporter(
|
||||
|
||||
fun importCharacter(character: Character): ImportResult {
|
||||
val importedCard = resolveCard(character)
|
||||
?: createPlaceholderCard(character)
|
||||
?: return ImportResult(
|
||||
success = false,
|
||||
message = "Matching card not found in VBHelper. Import that card first."
|
||||
)
|
||||
|
||||
val slotId = character.characterStats.slotId
|
||||
val cardCharacter = database.characterDao().getCharacterByMonIndexOrNull(slotId, importedCard.id)
|
||||
?: createPlaceholderCharacter(importedCard, character)
|
||||
?: return ImportResult(
|
||||
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}."
|
||||
)
|
||||
@ -48,110 +37,60 @@ class VitalWearCharacterImporter(
|
||||
val totalWins = character.characterStats.totalWins.coerceAtMost(totalBattles)
|
||||
val currentPhaseBattles = character.characterStats.currentPhaseBattles
|
||||
val currentPhaseWins = character.characterStats.currentPhaseWins.coerceAtMost(currentPhaseBattles)
|
||||
val normalizedTransformationCountdown = normalizeTransformationCountdownMinutes(
|
||||
transformationCountdownMinutes = secondsToMinutes(character.characterStats.timeUntilNextTransformation),
|
||||
hasPossibleTransformations = hasPossibleTransformations(cardCharacter.id),
|
||||
)
|
||||
val hasBePayloadStats = character.characterStats.trainedHp > 0 ||
|
||||
character.characterStats.trainedAp > 0 ||
|
||||
character.characterStats.trainedBp > 0 ||
|
||||
character.characterStats.trainingTimeRemainingInSeconds > 0L
|
||||
val fallbackIsBeCharacter = importedCard.isBEm || hasBePayloadStats
|
||||
val deviceType = resolveDeviceType(
|
||||
transferDeviceType = character.characterStats.deviceType,
|
||||
fallbackIsBeCharacter = fallbackIsBeCharacter,
|
||||
)
|
||||
val isBeCharacter = deviceType == DeviceType.BEDevice
|
||||
|
||||
val userCharacterId = database.userCharacterDao().insertCharacterData(
|
||||
UserCharacter(
|
||||
charId = cardCharacter.id,
|
||||
ageInDays = character.characterStats.ageInDays.takeIf { it > 0 }
|
||||
?: max(character.transformationHistoryCount - 1, 0),
|
||||
ageInDays = max(character.transformationHistoryCount - 1, 0),
|
||||
mood = character.characterStats.mood,
|
||||
vitalPoints = character.characterStats.vitals,
|
||||
transformationCountdown = normalizedTransformationCountdown,
|
||||
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 = character.characterStats.activityLevel.coerceAtLeast(0),
|
||||
heartRateCurrent = character.characterStats.heartRateCurrent.coerceAtLeast(0),
|
||||
characterType = deviceType,
|
||||
activityLevel = 0,
|
||||
heartRateCurrent = 0,
|
||||
characterType = DeviceType.BEDevice,
|
||||
isActive = true
|
||||
)
|
||||
)
|
||||
|
||||
runBlocking {
|
||||
database.vitalWearSettingsDao().upsert(
|
||||
VitalWearCharacterSettings(
|
||||
characterId = userCharacterId,
|
||||
trainingInBackground = character.settings.trainingInBackground,
|
||||
allowedBattles = character.settings.allowedBattles.number,
|
||||
accumulatedDailyInjuries = character.characterStats.accumulatedDailyInjuries,
|
||||
)
|
||||
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 vbProfile = VBCharacterData(
|
||||
id = userCharacterId,
|
||||
generation = if (character.characterStats.generation > 0) {
|
||||
character.characterStats.generation
|
||||
} else {
|
||||
max(character.transformationHistoryCount - 1, 0)
|
||||
},
|
||||
totalTrophies = if (character.characterStats.totalTrophies > 0) {
|
||||
character.characterStats.totalTrophies
|
||||
} else {
|
||||
character.characterStats.trainedPp.coerceAtLeast(0)
|
||||
}
|
||||
)
|
||||
database.userCharacterDao().insertVBCharacterData(vbProfile)
|
||||
|
||||
if (isBeCharacter) {
|
||||
val abilityRarity = resolveAbilityRarity(character.characterStats.abilityRarity)
|
||||
database.userCharacterDao().insertBECharacterData(
|
||||
BECharacterData(
|
||||
id = userCharacterId,
|
||||
trainingHp = character.characterStats.trainedHp,
|
||||
trainingAp = character.characterStats.trainedAp,
|
||||
trainingBp = character.characterStats.trainedBp,
|
||||
remainingTrainingTimeInMinutes = secondsToMinutes(character.characterStats.trainingTimeRemainingInSeconds),
|
||||
itemEffectMentalStateValue = character.characterStats.itemEffectMentalStateValue,
|
||||
itemEffectMentalStateMinutesRemaining = character.characterStats.itemEffectMentalStateMinutesRemaining,
|
||||
itemEffectActivityLevelValue = character.characterStats.itemEffectActivityLevelValue,
|
||||
itemEffectActivityLevelMinutesRemaining = character.characterStats.itemEffectActivityLevelMinutesRemaining,
|
||||
itemEffectVitalPointsChangeValue = character.characterStats.itemEffectVitalPointsChangeValue,
|
||||
itemEffectVitalPointsChangeMinutesRemaining = character.characterStats.itemEffectVitalPointsChangeMinutesRemaining,
|
||||
abilityRarity = abilityRarity,
|
||||
abilityType = character.characterStats.abilityType,
|
||||
abilityBranch = character.characterStats.abilityBranch,
|
||||
abilityReset = character.characterStats.abilityReset,
|
||||
rank = character.characterStats.rank,
|
||||
itemType = character.characterStats.itemType,
|
||||
itemMultiplier = character.characterStats.itemMultiplier,
|
||||
itemRemainingTime = character.characterStats.itemRemainingTime,
|
||||
otp0 = "",
|
||||
otp1 = "",
|
||||
minorVersion = character.characterStats.firmwareMinorVersion,
|
||||
majorVersion = character.characterStats.firmwareMajorVersion
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val now = System.currentTimeMillis()
|
||||
markSeen(importedCard.name, slotId, importedCard.id, now)
|
||||
database.dexDao().insertCharacter(slotId, importedCard.id, now)
|
||||
|
||||
var insertedTransformationCount = 0
|
||||
for (transformation in character.transformationHistoryList) {
|
||||
val transformationCard = resolveRelatedCard(
|
||||
incomingCardName = transformation.cardName,
|
||||
incomingCardId = character.cardId,
|
||||
matchedRootCard = importedCard,
|
||||
incomingRootCardName = character.cardName,
|
||||
)
|
||||
val transformationCard = resolveCard(transformation.cardName, character.cardId)
|
||||
if (transformationCard != null) {
|
||||
database.userCharacterDao().insertTransformation(
|
||||
userCharacterId,
|
||||
@ -159,28 +98,12 @@ class VitalWearCharacterImporter(
|
||||
transformationCard.id,
|
||||
now
|
||||
)
|
||||
insertedTransformationCount++
|
||||
markSeen(transformationCard.name, transformation.slotId, transformationCard.id, now)
|
||||
database.dexDao().insertCharacter(transformation.slotId, transformationCard.id, now)
|
||||
}
|
||||
}
|
||||
|
||||
if (insertedTransformationCount == 0) {
|
||||
// Keep HomeScreen renderable for freshly imported characters with empty history.
|
||||
database.userCharacterDao().insertTransformation(
|
||||
userCharacterId,
|
||||
slotId,
|
||||
importedCard.id,
|
||||
now
|
||||
)
|
||||
}
|
||||
|
||||
for ((cardName, maxAdventureCompleted) in character.maxAdventureCompletedByCardMap) {
|
||||
val adventureCard = resolveRelatedCard(
|
||||
incomingCardName = cardName,
|
||||
incomingCardId = null,
|
||||
matchedRootCard = importedCard,
|
||||
incomingRootCardName = character.cardName,
|
||||
) ?: continue
|
||||
val adventureCard = resolveCard(cardName, null) ?: continue
|
||||
val currentStage = (maxAdventureCompleted + 1).coerceAtLeast(0)
|
||||
database.cardProgressDao().updateCardProgress(
|
||||
currentStage = currentStage,
|
||||
@ -189,189 +112,6 @@ class VitalWearCharacterImporter(
|
||||
)
|
||||
}
|
||||
|
||||
val nextAdventureMissionStage = character.characterStats.nextAdventureMissionStage.coerceAtLeast(0)
|
||||
if (nextAdventureMissionStage > 0) {
|
||||
database.cardProgressDao().updateCardProgress(
|
||||
currentStage = nextAdventureMissionStage,
|
||||
cardId = importedCard.id,
|
||||
unlocked = nextAdventureMissionStage > importedCard.stageCount,
|
||||
)
|
||||
}
|
||||
|
||||
return ImportResult(
|
||||
success = true,
|
||||
message = "Imported ${importedCard.name} slot $slotId from VitalWear."
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Import a VitalWear character with a forced device type.
|
||||
* Used by HCE (VitalWear) read path to ensure imports are marked as BE.
|
||||
*
|
||||
* @param character Protobuf character from VitalWear HCE
|
||||
* @param forcedDeviceType Always use this device type for the imported character (e.g., BEDevice for HCE)
|
||||
*/
|
||||
fun importCharacter(character: Character, forcedDeviceType: DeviceType): ImportResult {
|
||||
val importedCard = resolveCard(character)
|
||||
?: createPlaceholderCard(character)
|
||||
?: return ImportResult(
|
||||
success = false,
|
||||
message = "Matching card not found in VBHelper. Import that card first."
|
||||
)
|
||||
|
||||
val slotId = character.characterStats.slotId
|
||||
val cardCharacter = database.characterDao().getCharacterByMonIndexOrNull(slotId, importedCard.id)
|
||||
?: createPlaceholderCharacter(importedCard, character)
|
||||
?: 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 normalizedTransformationCountdown = normalizeTransformationCountdownMinutes(
|
||||
transformationCountdownMinutes = secondsToMinutes(character.characterStats.timeUntilNextTransformation),
|
||||
hasPossibleTransformations = hasPossibleTransformations(cardCharacter.id),
|
||||
)
|
||||
|
||||
val userCharacterId = database.userCharacterDao().insertCharacterData(
|
||||
UserCharacter(
|
||||
charId = cardCharacter.id,
|
||||
ageInDays = character.characterStats.ageInDays.takeIf { it > 0 }
|
||||
?: max(character.transformationHistoryCount - 1, 0),
|
||||
mood = character.characterStats.mood,
|
||||
vitalPoints = character.characterStats.vitals,
|
||||
transformationCountdown = normalizedTransformationCountdown,
|
||||
injuryStatus = resolveInjuryStatus(character.characterStats.injured),
|
||||
trophies = character.characterStats.trainedPp,
|
||||
currentPhaseBattlesWon = currentPhaseWins,
|
||||
currentPhaseBattlesLost = (currentPhaseBattles - currentPhaseWins).coerceAtLeast(0),
|
||||
totalBattlesWon = totalWins,
|
||||
totalBattlesLost = (totalBattles - totalWins).coerceAtLeast(0),
|
||||
activityLevel = character.characterStats.activityLevel.coerceAtLeast(0),
|
||||
heartRateCurrent = character.characterStats.heartRateCurrent.coerceAtLeast(0),
|
||||
characterType = forcedDeviceType,
|
||||
isActive = true
|
||||
)
|
||||
)
|
||||
|
||||
runBlocking {
|
||||
database.vitalWearSettingsDao().upsert(
|
||||
VitalWearCharacterSettings(
|
||||
characterId = userCharacterId,
|
||||
trainingInBackground = character.settings.trainingInBackground,
|
||||
allowedBattles = character.settings.allowedBattles.number,
|
||||
accumulatedDailyInjuries = character.characterStats.accumulatedDailyInjuries,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val vbProfile = VBCharacterData(
|
||||
id = userCharacterId,
|
||||
generation = if (character.characterStats.generation > 0) {
|
||||
character.characterStats.generation
|
||||
} else {
|
||||
max(character.transformationHistoryCount - 1, 0)
|
||||
},
|
||||
totalTrophies = if (character.characterStats.totalTrophies > 0) {
|
||||
character.characterStats.totalTrophies
|
||||
} else {
|
||||
character.characterStats.trainedPp.coerceAtLeast(0)
|
||||
}
|
||||
)
|
||||
database.userCharacterDao().insertVBCharacterData(vbProfile)
|
||||
|
||||
if (forcedDeviceType == DeviceType.BEDevice) {
|
||||
val abilityRarity = resolveAbilityRarity(character.characterStats.abilityRarity)
|
||||
database.userCharacterDao().insertBECharacterData(
|
||||
BECharacterData(
|
||||
id = userCharacterId,
|
||||
trainingHp = character.characterStats.trainedHp,
|
||||
trainingAp = character.characterStats.trainedAp,
|
||||
trainingBp = character.characterStats.trainedBp,
|
||||
remainingTrainingTimeInMinutes = secondsToMinutes(character.characterStats.trainingTimeRemainingInSeconds),
|
||||
itemEffectMentalStateValue = character.characterStats.itemEffectMentalStateValue,
|
||||
itemEffectMentalStateMinutesRemaining = character.characterStats.itemEffectMentalStateMinutesRemaining,
|
||||
itemEffectActivityLevelValue = character.characterStats.itemEffectActivityLevelValue,
|
||||
itemEffectActivityLevelMinutesRemaining = character.characterStats.itemEffectActivityLevelMinutesRemaining,
|
||||
itemEffectVitalPointsChangeValue = character.characterStats.itemEffectVitalPointsChangeValue,
|
||||
itemEffectVitalPointsChangeMinutesRemaining = character.characterStats.itemEffectVitalPointsChangeMinutesRemaining,
|
||||
abilityRarity = abilityRarity,
|
||||
abilityType = character.characterStats.abilityType,
|
||||
abilityBranch = character.characterStats.abilityBranch,
|
||||
abilityReset = character.characterStats.abilityReset,
|
||||
rank = character.characterStats.rank,
|
||||
itemType = character.characterStats.itemType,
|
||||
itemMultiplier = character.characterStats.itemMultiplier,
|
||||
itemRemainingTime = character.characterStats.itemRemainingTime,
|
||||
otp0 = "",
|
||||
otp1 = "",
|
||||
minorVersion = character.characterStats.firmwareMinorVersion,
|
||||
majorVersion = character.characterStats.firmwareMajorVersion
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val now = System.currentTimeMillis()
|
||||
markSeen(importedCard.name, slotId, importedCard.id, now)
|
||||
|
||||
var insertedTransformationCount = 0
|
||||
for (transformation in character.transformationHistoryList) {
|
||||
val transformationCard = resolveRelatedCard(
|
||||
incomingCardName = transformation.cardName,
|
||||
incomingCardId = character.cardId,
|
||||
matchedRootCard = importedCard,
|
||||
incomingRootCardName = character.cardName,
|
||||
)
|
||||
if (transformationCard != null) {
|
||||
database.userCharacterDao().insertTransformation(
|
||||
userCharacterId,
|
||||
transformation.slotId,
|
||||
transformationCard.id,
|
||||
now
|
||||
)
|
||||
insertedTransformationCount++
|
||||
markSeen(transformationCard.name, transformation.slotId, transformationCard.id, now)
|
||||
}
|
||||
}
|
||||
|
||||
if (insertedTransformationCount == 0) {
|
||||
database.userCharacterDao().insertTransformation(
|
||||
userCharacterId,
|
||||
slotId,
|
||||
importedCard.id,
|
||||
now
|
||||
)
|
||||
}
|
||||
|
||||
for ((cardName, maxAdventureCompleted) in character.maxAdventureCompletedByCardMap) {
|
||||
val adventureCard = resolveRelatedCard(
|
||||
incomingCardName = cardName,
|
||||
incomingCardId = null,
|
||||
matchedRootCard = importedCard,
|
||||
incomingRootCardName = character.cardName,
|
||||
) ?: continue
|
||||
val currentStage = (maxAdventureCompleted + 1).coerceAtLeast(0)
|
||||
database.cardProgressDao().updateCardProgress(
|
||||
currentStage = currentStage,
|
||||
cardId = adventureCard.id,
|
||||
unlocked = currentStage > adventureCard.stageCount
|
||||
)
|
||||
}
|
||||
|
||||
val nextAdventureMissionStage = character.characterStats.nextAdventureMissionStage.coerceAtLeast(0)
|
||||
if (nextAdventureMissionStage > 0) {
|
||||
database.cardProgressDao().updateCardProgress(
|
||||
currentStage = nextAdventureMissionStage,
|
||||
cardId = importedCard.id,
|
||||
unlocked = nextAdventureMissionStage > importedCard.stageCount,
|
||||
)
|
||||
}
|
||||
|
||||
return ImportResult(
|
||||
success = true,
|
||||
message = "Imported ${importedCard.name} slot $slotId from VitalWear."
|
||||
@ -380,172 +120,26 @@ class VitalWearCharacterImporter(
|
||||
|
||||
private fun resolveCard(character: Character) = resolveCard(character.cardName, character.cardId)
|
||||
|
||||
private fun resolveCard(cardName: String?, cardId: Int?): Card? {
|
||||
return selectImportedCard(
|
||||
candidates = database.cardDao().getAllCards(),
|
||||
incomingCardName = cardName,
|
||||
incomingCardId = 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 }
|
||||
}
|
||||
|
||||
private fun resolveRelatedCard(
|
||||
incomingCardName: String?,
|
||||
incomingCardId: Int?,
|
||||
matchedRootCard: Card,
|
||||
incomingRootCardName: String,
|
||||
): Card? {
|
||||
if (incomingCardName.isNullOrBlank()) {
|
||||
return if (incomingCardId != null && incomingCardId > 0 && matchedRootCard.cardId == incomingCardId) {
|
||||
matchedRootCard
|
||||
} else {
|
||||
null
|
||||
if (cardId != null) {
|
||||
val matches = database.cardDao().getCardByCardId(cardId)
|
||||
if (matches.size == 1) {
|
||||
return matches.first()
|
||||
}
|
||||
}
|
||||
|
||||
if (cardNamesMatch(incomingCardName, incomingRootCardName)) {
|
||||
return matchedRootCard
|
||||
}
|
||||
|
||||
return resolveCard(incomingCardName, incomingCardId)
|
||||
}
|
||||
|
||||
private fun createPlaceholderCard(character: Character): Card? {
|
||||
if (!character.hasTransferCard() || !character.hasTransferSpecies()) {
|
||||
return null
|
||||
}
|
||||
|
||||
val transferCard = character.transferCard
|
||||
selectImportedCard(
|
||||
candidates = database.cardDao().getAllCards(),
|
||||
incomingCardName = transferCard.cardName,
|
||||
incomingCardId = transferCard.cardId,
|
||||
)?.let { return it }
|
||||
|
||||
val transferSpecies = character.transferSpecies
|
||||
val baseName = transferCard.cardName
|
||||
.takeIf { it.isNotBlank() }
|
||||
?: character.cardName.takeIf { it.isNotBlank() }
|
||||
?: "Transferred Card ${transferCard.cardId}"
|
||||
val placeholderName = buildPlaceholderCardName(baseName, transferCard.cardId)
|
||||
val logoBytes = when {
|
||||
!transferSpecies.nameSprite.isEmpty -> transferSpecies.nameSprite.toByteArray()
|
||||
!transferSpecies.idle1.isEmpty -> transferSpecies.idle1.toByteArray()
|
||||
else -> byteArrayOf()
|
||||
}
|
||||
val logoWidth = when {
|
||||
transferSpecies.nameSpriteWidth > 0 -> transferSpecies.nameSpriteWidth
|
||||
transferSpecies.spriteWidth > 0 -> transferSpecies.spriteWidth
|
||||
else -> 0
|
||||
}
|
||||
val logoHeight = when {
|
||||
transferSpecies.nameSpriteHeight > 0 -> transferSpecies.nameSpriteHeight
|
||||
transferSpecies.spriteHeight > 0 -> transferSpecies.spriteHeight
|
||||
else -> 0
|
||||
}
|
||||
|
||||
val insertedId = runBlocking {
|
||||
database.cardDao().insertNewCard(
|
||||
Card(
|
||||
cardId = transferCard.cardId,
|
||||
logo = logoBytes,
|
||||
logoWidth = logoWidth,
|
||||
logoHeight = logoHeight,
|
||||
name = placeholderName,
|
||||
stageCount = transferCard.stageCount.coerceAtLeast(0),
|
||||
isBEm = transferCard.isBem,
|
||||
)
|
||||
)
|
||||
}
|
||||
val resolvedCard = when {
|
||||
insertedId > 0L -> database.cardDao().getCardById(insertedId)
|
||||
else -> database.cardDao().getCardByName(placeholderName)
|
||||
} ?: return null
|
||||
|
||||
if (database.cardProgressDao().getCardProgressSync(resolvedCard.id) == null) {
|
||||
database.cardProgressDao().insertCardProgress(
|
||||
CardProgress(
|
||||
cardId = resolvedCard.id,
|
||||
currentStage = 1,
|
||||
unlocked = false,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
return resolvedCard
|
||||
}
|
||||
|
||||
private fun createPlaceholderCharacter(card: Card, character: Character): CardCharacter? {
|
||||
if (!character.hasTransferSpecies()) {
|
||||
return null
|
||||
}
|
||||
|
||||
val transferSpecies = character.transferSpecies
|
||||
database.characterDao().getCharacterByMonIndexOrNull(transferSpecies.slotId, card.id)?.let { return it }
|
||||
|
||||
val spriteId = database.spriteDao().insertSprite(
|
||||
Sprite(
|
||||
spriteIdle1 = transferSpecies.idle1.toByteArray(),
|
||||
spriteIdle2 = transferSpecies.idle2.toByteArray(),
|
||||
spriteWalk1 = transferSpecies.walk1.toByteArray(),
|
||||
spriteWalk2 = transferSpecies.walk2.toByteArray(),
|
||||
spriteRun1 = transferSpecies.run1.toByteArray(),
|
||||
spriteRun2 = transferSpecies.run2.toByteArray(),
|
||||
spriteTrain1 = transferSpecies.train1.toByteArray(),
|
||||
spriteTrain2 = transferSpecies.train2.toByteArray(),
|
||||
spriteHappy = transferSpecies.win.toByteArray(),
|
||||
spriteSleep = transferSpecies.down.toByteArray(),
|
||||
spriteAttack = transferSpecies.attack.toByteArray(),
|
||||
spriteDodge = transferSpecies.dodge.toByteArray(),
|
||||
width = transferSpecies.spriteWidth,
|
||||
height = transferSpecies.spriteHeight,
|
||||
)
|
||||
)
|
||||
|
||||
val attribute = enumValues<NfcCharacter.Attribute>().getOrElse(transferSpecies.attribute) {
|
||||
enumValues<NfcCharacter.Attribute>().first()
|
||||
}
|
||||
|
||||
runBlocking {
|
||||
database.characterDao().insertCharacter(
|
||||
CardCharacter(
|
||||
cardId = card.id,
|
||||
spriteId = spriteId,
|
||||
charaIndex = transferSpecies.slotId,
|
||||
stage = transferSpecies.stage,
|
||||
attribute = attribute,
|
||||
baseHp = transferSpecies.baseHp,
|
||||
baseBp = transferSpecies.baseBp,
|
||||
baseAp = transferSpecies.baseAp,
|
||||
nameSprite = transferSpecies.nameSprite.toByteArray(),
|
||||
nameWidth = transferSpecies.nameSpriteWidth,
|
||||
nameHeight = transferSpecies.nameSpriteHeight,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
return database.characterDao().getCharacterByMonIndexOrNull(transferSpecies.slotId, card.id)
|
||||
}
|
||||
|
||||
private fun buildPlaceholderCardName(baseName: String, cardId: Int): String {
|
||||
val trimmedBaseName = baseName.trim().ifBlank { "Transferred Card" }
|
||||
database.cardDao().getCardByName(trimmedBaseName)?.let {
|
||||
return if (cardId > 0) "$trimmedBaseName [Transfer $cardId]" else "$trimmedBaseName [Transfer]"
|
||||
}
|
||||
return trimmedBaseName
|
||||
return null
|
||||
}
|
||||
|
||||
private fun secondsToMinutes(seconds: Long): Int {
|
||||
if (seconds <= 0L) {
|
||||
return 0
|
||||
}
|
||||
// Preserve non-zero timers from HCE payloads instead of flooring 1..59s to 0.
|
||||
return ((seconds + 59L) / 60L).coerceAtMost(Int.MAX_VALUE.toLong()).toInt()
|
||||
}
|
||||
|
||||
private fun hasPossibleTransformations(cardCharacterId: Long): Boolean {
|
||||
return runBlocking {
|
||||
database.characterDao().getEvolutionRequirementsForCard(cardCharacterId).firstOrNull()?.isNotEmpty() == true
|
||||
}
|
||||
return (seconds / 60L).coerceAtMost(Int.MAX_VALUE.toLong()).toInt()
|
||||
}
|
||||
|
||||
private fun resolveInjuryStatus(injured: Boolean): NfcCharacter.InjuryStatus {
|
||||
@ -566,66 +160,4 @@ class VitalWearCharacterImporter(
|
||||
private fun resolveDefaultAbilityRarity(): NfcCharacter.AbilityRarity {
|
||||
return enumValues<NfcCharacter.AbilityRarity>().first()
|
||||
}
|
||||
|
||||
private fun resolveAbilityRarity(rawValue: Int): NfcCharacter.AbilityRarity {
|
||||
val rarities = enumValues<NfcCharacter.AbilityRarity>()
|
||||
return rarities.getOrElse(rawValue) { resolveDefaultAbilityRarity() }
|
||||
}
|
||||
|
||||
private fun markSeen(cardName: String, slotId: Int, cardId: Long, timestamp: Long) {
|
||||
database.dexDao().insertCharacter(slotId, cardId, timestamp)
|
||||
transferSeenDao.markSeen(cardName, slotId, timestamp)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun selectImportedCard(
|
||||
candidates: List<Card>,
|
||||
incomingCardName: String?,
|
||||
incomingCardId: Int?,
|
||||
): Card? {
|
||||
val requestedName = incomingCardName?.takeIf { it.isNotBlank() }
|
||||
val idMatches = if (incomingCardId != null && incomingCardId > 0) {
|
||||
candidates.filter { it.cardId == incomingCardId }
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
|
||||
requestedName?.let { requestedNameValue ->
|
||||
candidates.firstOrNull {
|
||||
it.name == requestedNameValue && (incomingCardId == null || incomingCardId <= 0 || it.cardId == incomingCardId)
|
||||
}?.let { return it }
|
||||
|
||||
val ignoreCaseMatches = candidates.filter {
|
||||
it.name.equals(requestedNameValue, ignoreCase = true) &&
|
||||
(incomingCardId == null || incomingCardId <= 0 || it.cardId == incomingCardId)
|
||||
}
|
||||
if (ignoreCaseMatches.size == 1) {
|
||||
return ignoreCaseMatches.single()
|
||||
}
|
||||
}
|
||||
|
||||
if (idMatches.size == 1) {
|
||||
return idMatches.single()
|
||||
}
|
||||
|
||||
requestedName?.let { requestedNameValue ->
|
||||
val normalizedMatches = candidates.filter {
|
||||
cardNamesMatch(it.name, requestedNameValue) &&
|
||||
(incomingCardId == null || incomingCardId <= 0 || it.cardId == incomingCardId)
|
||||
}
|
||||
if (normalizedMatches.size == 1) {
|
||||
return normalizedMatches.single()
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
internal fun cardNamesMatch(left: String, right: String): Boolean {
|
||||
return left.equals(right, ignoreCase = true) || left.toNormalizedCardLookupKey() == right.toNormalizedCardLookupKey()
|
||||
}
|
||||
|
||||
private fun String.toNormalizedCardLookupKey(): String {
|
||||
return lowercase().filter { it.isLetterOrDigit() }
|
||||
}
|
||||
|
||||
|
||||
@ -42,7 +42,7 @@ fun BitmapData.getImageBitmap(
|
||||
}
|
||||
|
||||
fun BitmapData.getBitmap(): Bitmap {
|
||||
return Bitmap.createBitmap(createARGBIntArray(), this.width, this.height, Bitmap.Config.ARGB_8888)
|
||||
return Bitmap.createBitmap(createARGBIntArray(), this.width, this.height, Bitmap.Config.HARDWARE)
|
||||
}
|
||||
|
||||
object ARGBMasks {
|
||||
@ -64,7 +64,7 @@ fun BitmapData.getObscuredBitmap(): Bitmap {
|
||||
argbPixels[i] = BLACK
|
||||
}
|
||||
}
|
||||
return Bitmap.createBitmap(argbPixels, this.width, this.height, Bitmap.Config.ARGB_8888)
|
||||
return Bitmap.createBitmap(argbPixels, this.width, this.height, Bitmap.Config.HARDWARE)
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -0,0 +1 @@
|
||||
|
||||
@ -7,48 +7,7 @@ message Character {
|
||||
string card_name = 1;
|
||||
int32 card_id = 2;
|
||||
|
||||
message TransferCard {
|
||||
int32 card_id = 1;
|
||||
string card_name = 2;
|
||||
bool is_bem = 3;
|
||||
int32 stage_count = 4;
|
||||
int32 franchise = 5;
|
||||
}
|
||||
|
||||
message TransferSpecies {
|
||||
int32 slot_id = 1;
|
||||
int32 stage = 2;
|
||||
int32 attribute = 3;
|
||||
int32 base_hp = 4;
|
||||
int32 base_bp = 5;
|
||||
int32 base_ap = 6;
|
||||
bytes name_sprite = 7;
|
||||
int32 name_sprite_width = 8;
|
||||
int32 name_sprite_height = 9;
|
||||
bytes idle1 = 10;
|
||||
bytes idle2 = 11;
|
||||
bytes walk1 = 12;
|
||||
bytes walk2 = 13;
|
||||
bytes run1 = 14;
|
||||
bytes run2 = 15;
|
||||
bytes train1 = 16;
|
||||
bytes train2 = 17;
|
||||
bytes win = 18;
|
||||
bytes down = 19;
|
||||
bytes attack = 20;
|
||||
bytes dodge = 21;
|
||||
bytes splash = 22;
|
||||
int32 sprite_width = 23;
|
||||
int32 sprite_height = 24;
|
||||
}
|
||||
|
||||
message CharacterStats {
|
||||
enum TransferDeviceType {
|
||||
TRANSFER_DEVICE_TYPE_UNSPECIFIED = 0;
|
||||
TRANSFER_DEVICE_TYPE_VB = 1;
|
||||
TRANSFER_DEVICE_TYPE_BE = 2;
|
||||
}
|
||||
|
||||
int32 slot_id = 1;
|
||||
int32 vitals = 2;
|
||||
int64 training_time_remaining_in_seconds = 3;
|
||||
@ -64,29 +23,6 @@ message Character {
|
||||
int32 total_wins = 13;
|
||||
int32 current_phase_wins = 14;
|
||||
int32 mood = 15;
|
||||
TransferDeviceType device_type = 16;
|
||||
int32 age_in_days = 17;
|
||||
int32 activity_level = 18;
|
||||
int32 heart_rate_current = 19;
|
||||
int32 generation = 20;
|
||||
int32 total_trophies = 21;
|
||||
int32 next_adventure_mission_stage = 22;
|
||||
int32 item_effect_mental_state_value = 23;
|
||||
int32 item_effect_mental_state_minutes_remaining = 24;
|
||||
int32 item_effect_activity_level_value = 25;
|
||||
int32 item_effect_activity_level_minutes_remaining = 26;
|
||||
int32 item_effect_vital_points_change_value = 27;
|
||||
int32 item_effect_vital_points_change_minutes_remaining = 28;
|
||||
int32 ability_rarity = 29;
|
||||
int32 ability_type = 30;
|
||||
int32 ability_branch = 31;
|
||||
int32 ability_reset = 32;
|
||||
int32 rank = 33;
|
||||
int32 item_type = 34;
|
||||
int32 item_multiplier = 35;
|
||||
int32 item_remaining_time = 36;
|
||||
int32 firmware_minor_version = 37;
|
||||
int32 firmware_major_version = 38;
|
||||
}
|
||||
CharacterStats character_stats = 3;
|
||||
|
||||
@ -112,8 +48,4 @@ message Character {
|
||||
repeated TransformationEvent transformation_history = 5;
|
||||
|
||||
map<string, int32> max_adventure_completed_by_card = 6;
|
||||
|
||||
optional TransferCard transfer_card = 7;
|
||||
|
||||
optional TransferSpecies transfer_species = 8;
|
||||
}
|
||||
|
||||
@ -56,14 +56,6 @@
|
||||
<string name="scan_error_generic">Ops!</string>
|
||||
<string name="scan_sent_dim_success">DIM enviado com sucesso!</string>
|
||||
<string name="scan_nfc_must_be_enabled">O NFC precisa estar ativado.</string>
|
||||
<string name="scan_target_device_full">O dispositivo de destino está cheio (ativo + backup). Remova um personagem antes de enviar.</string>
|
||||
<string name="scan_transfer_skipped_storage_state">Transferência ignorada. O envio só é permitido quando o brinquedo tem um personagem ativo e o slot de backup está vazio.</string>
|
||||
<string name="scan_transfer_waiting_tap">Pronto. Encoste e mantenha o celular e o relógio juntos até concluir.</string>
|
||||
<string name="scan_transfer_detected_keep_tap">Conexão detectada. Mantenha os dispositivos juntos...</string>
|
||||
<string name="scan_transfer_in_progress">Transferindo agora. Não afaste o celular ainda.</string>
|
||||
<string name="scan_transfer_complete_remove">Transferência concluída. Agora você pode afastar o celular.</string>
|
||||
<string name="scan_transfer_failed_try_again">Falha na transferência. Reposicione os dispositivos e tente novamente.</string>
|
||||
<string name="scan_transfer_cancelled">Transferência cancelada.</string>
|
||||
|
||||
<string name="settings_title">Configurações</string>
|
||||
|
||||
@ -178,7 +170,7 @@
|
||||
<string name="dex_chara_name_icon_description">Nome do personagem</string>
|
||||
<string name="dex_chara_stats">HP: %1$d, BP: %2$d, AP: %3$d</string>
|
||||
<string name="dex_chara_stage_attribute">Stg: %1$s, Atr: %2$s</string>
|
||||
<string name="dex_chara_unknown_name">________________</string>
|
||||
<string name="dex_chara_unknown_name">\?\?\?\?\?\?\?\?\?\?\?\?\?\?\?\?</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">
|
||||
|
||||
@ -60,25 +60,14 @@
|
||||
<string name="scan_error_generic">Whoops</string>
|
||||
<string name="scan_sent_dim_success">Sent DIM successfully!</string>
|
||||
<string name="scan_nfc_must_be_enabled">NFC must be enabled</string>
|
||||
<string name="scan_target_device_full">Target device is full (active + backup). Remove one character before sending.</string>
|
||||
<string name="scan_transfer_skipped_storage_state">Transfer skipped. Send is allowed only when the toy has an active character and an empty backup slot.</string>
|
||||
<string name="scan_transfer_waiting_tap">Ready. Tap and hold phone/watch together until complete.</string>
|
||||
<string name="scan_transfer_detected_keep_tap">Connection detected. Keep devices together...</string>
|
||||
<string name="scan_transfer_in_progress">Transferring now. Do not remove phone yet.</string>
|
||||
<string name="scan_transfer_complete_remove">Transfer complete. You can remove the phone now.</string>
|
||||
<string name="scan_transfer_failed_try_again">Transfer failed. Reposition devices and try again.</string>
|
||||
<string name="scan_transfer_cancelled">Transfer cancelled.</string>
|
||||
<string name="scan_transport_bandai_bracelet">Detected target: Bandai Vital Bracelet (NFC-A)</string>
|
||||
<string name="scan_transport_vitalwear_hce">Detected target: VitalWear (HCE)</string>
|
||||
|
||||
|
||||
<string name="settings_title">Settings</string>
|
||||
|
||||
<string name="settings_section_nfc">NFC Communication</string>
|
||||
<string name="settings_section_dim_bem">VBH-VW card management</string>
|
||||
<string name="settings_section_dim_bem">DiM/BEm management</string>
|
||||
<string name="settings_section_about">About and credits</string>
|
||||
<string name="settings_section_data">Data management</string>
|
||||
<string name="settings_section_companion_tools">VitalWear companion tools</string>
|
||||
|
||||
<string name="settings_import_apk_title">Import APK</string>
|
||||
<string name="settings_import_apk_desc">
|
||||
@ -87,12 +76,7 @@
|
||||
|
||||
<string name="settings_import_card_title">Import card</string>
|
||||
<string name="settings_import_card_desc">
|
||||
Import DIM/BEM cards into VBHelper\'s Dex.
|
||||
</string>
|
||||
|
||||
<string name="settings_enable_dim_to_bem_title">Scale DIM cards to BEM stats</string>
|
||||
<string name="settings_enable_dim_to_bem_desc">
|
||||
Automatically upscale DIM card stats to BEM level when importing
|
||||
Import DiM/BEm
|
||||
</string>
|
||||
|
||||
<string name="settings_credits_title">Credits</string>
|
||||
@ -111,27 +95,6 @@
|
||||
Import application database
|
||||
</string>
|
||||
|
||||
<string name="settings_companion_import_card_image_title">Import card to VitalWear</string>
|
||||
<string name="settings_companion_import_card_image_desc">Import Dim/Bem to Vitalwear</string>
|
||||
<string name="settings_companion_import_firmware_title">Import firmware to VitalWear</string>
|
||||
<string name="settings_companion_import_firmware_desc">Import firmware to Watch (10b for now)</string>
|
||||
<string name="settings_companion_send_watch_logs_title">Send watch logs</string>
|
||||
<string name="settings_companion_send_watch_logs_desc">Request logs from a connected VitalWear watch and share them</string>
|
||||
<string name="settings_companion_send_phone_logs_title">Send phone logs</string>
|
||||
<string name="settings_companion_send_phone_logs_desc">Share the latest VBHelper phone log file</string>
|
||||
<string name="companion_logs_warning">WARNING: Logs have personal health information such as heart rate measurements and steps per day.</string>
|
||||
<string name="companion_logs_no_files">No log files found</string>
|
||||
<string name="companion_logs_failed_request">Failed to send log request to watch</string>
|
||||
<string name="companion_logs_receive_failed">Error receiving log file</string>
|
||||
<string name="companion_firmware_no_watch">No connected watch found</string>
|
||||
<string name="companion_firmware_sent_success">Firmware sent successfully</string>
|
||||
<string name="companion_card_import_success">Imported Successfully</string>
|
||||
<string name="companion_validation_connect_vb">Connect to VB</string>
|
||||
<string name="companion_validation_insert_card">Insert Card in VB and Reconnect</string>
|
||||
<string name="companion_validation_success">Card Validated Successfully</string>
|
||||
<string name="companion_loading_select_firmware">Select firmware file</string>
|
||||
<string name="companion_transfer_disabled_message">Character transfer is disabled in companion. Use watch Transfer instead.</string>
|
||||
|
||||
<string name="credits_title">Credits</string>
|
||||
<string name="credits_section_reverse_engineering">Reverse engineering</string>
|
||||
<string name="credits_section_app_development">Application development</string>
|
||||
@ -216,7 +179,7 @@
|
||||
<string name="dex_chara_name_icon_description">Character name</string>
|
||||
<string name="dex_chara_stats">HP: %1$d, BP: %2$d, AP: %3$d</string>
|
||||
<string name="dex_chara_stage_attribute">Stg: %1$s, Atr: %2$s</string>
|
||||
<string name="dex_chara_unknown_name">________________</string>
|
||||
<string name="dex_chara_unknown_name">\?\?\?\?\?\?\?\?\?\?\?\?\?\?\?\?</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">
|
||||
|
||||
@ -1,8 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<paths xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<files-path
|
||||
name="logs"
|
||||
path="logs/" />
|
||||
<cache-path
|
||||
name="exports"
|
||||
path="exports" />
|
||||
|
||||
@ -16,13 +16,7 @@ protobufGradlePlugin = "0.9.6"
|
||||
protobufJavalite = "4.33.4"
|
||||
roomRuntime = "2.8.4"
|
||||
vbNfcReader = "0.2.0-SNAPSHOT"
|
||||
dimReader = "2.0.0"
|
||||
playServicesWearable = "19.0.0"
|
||||
kotlinxCoroutinesPlayServices = "1.4.3-native-mt"
|
||||
timber = "5.0.1"
|
||||
tinyLog = "2.4.1"
|
||||
slf4jTimber = "1.0.0"
|
||||
slf4jApi = "2.0.16"
|
||||
dimReader = "2.1.0"
|
||||
|
||||
[libraries]
|
||||
androidx-compose-material = { module = "androidx.compose.material:material" }
|
||||
@ -52,13 +46,6 @@ protobuf-gradle-plugin = { module = "com.google.protobuf:protobuf-gradle-plugin"
|
||||
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 = { module = "com.google.android.gms:play-services-wearable", version.ref = "playServicesWearable" }
|
||||
kotlinx-coroutines-play-services = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-play-services", version.ref = "kotlinxCoroutinesPlayServices" }
|
||||
timber = { module = "com.jakewharton.timber:timber", version.ref = "timber" }
|
||||
tiny-log = { module = "org.tinylog:tinylog-api", version.ref = "tinyLog" }
|
||||
tiny-log-impl = { module = "org.tinylog:tinylog-impl", version.ref = "tinyLog" }
|
||||
slf4j-timber = { module = "at.favre.lib:slf4j-timber", version.ref = "slf4jTimber" }
|
||||
slf4j-api = { module = "org.slf4j:slf4j-api", version.ref = "slf4jApi" }
|
||||
|
||||
[plugins]
|
||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||
|
||||
@ -17,16 +17,8 @@ dependencyResolutionManagement {
|
||||
mavenLocal()
|
||||
google()
|
||||
mavenCentral()
|
||||
maven { url = uri("https://jitpack.io") }
|
||||
}
|
||||
}
|
||||
|
||||
rootProject.name = "VBHelper"
|
||||
include(":app")
|
||||
|
||||
|
||||
includeBuild("../vb-nfc-reader") {
|
||||
dependencySubstitution {
|
||||
substitute(module("com.github.cfogrady:vb-nfc-reader")).using(project(":"))
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user