mirror of
https://github.com/nacabaro/vbhelper.git
synced 2026-07-30 00:31:54 +00:00
companion reup
This commit is contained in:
parent
8fd4f6c414
commit
9aa62f0d81
@ -73,8 +73,12 @@ protobuf {
|
|||||||
dependencies {
|
dependencies {
|
||||||
implementation(libs.androidx.room.runtime)
|
implementation(libs.androidx.room.runtime)
|
||||||
implementation(libs.vb.nfc.reader)
|
implementation(libs.vb.nfc.reader)
|
||||||
implementation(libs.dim.reader)
|
// Temporarily commented out due to Lombok compilation issues in VB-DIM-Reader
|
||||||
implementation(libs.androidx.core.ktx)
|
// 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.androidx.room.ktx)
|
implementation(libs.androidx.room.ktx)
|
||||||
implementation(libs.androidx.datastore.preferences)
|
implementation(libs.androidx.datastore.preferences)
|
||||||
implementation(libs.androidx.navigation.compose)
|
implementation(libs.androidx.navigation.compose)
|
||||||
@ -90,6 +94,13 @@ dependencies {
|
|||||||
implementation(libs.androidx.ui.tooling.preview)
|
implementation(libs.androidx.ui.tooling.preview)
|
||||||
implementation(libs.androidx.material3)
|
implementation(libs.androidx.material3)
|
||||||
implementation(libs.androidx.compose.material.icons.extended)
|
implementation(libs.androidx.compose.material.icons.extended)
|
||||||
|
implementation(libs.play.services.wearable)
|
||||||
|
implementation(libs.kotlinx.coroutines.play.services)
|
||||||
|
implementation(libs.timber)
|
||||||
|
implementation(libs.tiny.log)
|
||||||
|
implementation(libs.tiny.log.impl)
|
||||||
|
implementation(libs.slf4j.api)
|
||||||
|
runtimeOnly(libs.slf4j.timber)
|
||||||
|
|
||||||
debugImplementation(libs.androidx.ui.tooling)
|
debugImplementation(libs.androidx.ui.tooling)
|
||||||
debugImplementation(libs.androidx.ui.test.manifest)
|
debugImplementation(libs.androidx.ui.test.manifest)
|
||||||
@ -118,4 +129,7 @@ dependencies {
|
|||||||
|
|
||||||
// HTTP request logging
|
// HTTP request logging
|
||||||
implementation("com.squareup.okhttp3:logging-interceptor:4.11.0")
|
implementation("com.squareup.okhttp3:logging-interceptor:4.11.0")
|
||||||
|
|
||||||
|
// In-app browser for OAuth login
|
||||||
|
implementation("androidx.browser:browser:1.8.0")
|
||||||
}
|
}
|
||||||
@ -11,6 +11,7 @@
|
|||||||
|
|
||||||
<uses-permission android:name="android.permission.NFC" />
|
<uses-permission android:name="android.permission.NFC" />
|
||||||
<uses-feature android:name="android.hardware.nfc" android:required="true" />
|
<uses-feature android:name="android.hardware.nfc" android:required="true" />
|
||||||
|
<uses-permission android:name="android.permission.VIBRATE" />
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
|
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
|
||||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
|
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
|
||||||
@ -50,11 +51,17 @@
|
|||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:readPermission="com.github.nacabaro.vbhelper.permission.ACCESS_TRANSFER_SEEN"
|
android:readPermission="com.github.nacabaro.vbhelper.permission.ACCESS_TRANSFER_SEEN"
|
||||||
android:writePermission="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
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
android:launchMode="singleTask"
|
|
||||||
android:theme="@style/Theme.VBHelper">
|
android:theme="@style/Theme.VBHelper">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
@ -75,6 +82,16 @@
|
|||||||
<data android:scheme="http" android:host="127.0.0.1" android:port="8080" android:pathPrefix="/authenticate" />
|
<data android:scheme="http" android:host="127.0.0.1" android:port="8080" android:pathPrefix="/authenticate" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</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="*" />
|
||||||
|
</intent-filter>
|
||||||
|
</service>
|
||||||
</application>
|
</application>
|
||||||
|
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|||||||
@ -7,9 +7,13 @@ import androidx.activity.ComponentActivity
|
|||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import com.github.nacabaro.vbhelper.navigation.AppNavigation
|
import com.github.nacabaro.vbhelper.navigation.AppNavigation
|
||||||
import com.github.nacabaro.vbhelper.di.VBHelper
|
import com.github.nacabaro.vbhelper.di.VBHelper
|
||||||
import com.github.nacabaro.vbhelper.navigation.AppNavigationHandlers
|
import com.github.nacabaro.vbhelper.navigation.AppNavigationHandlers
|
||||||
|
import com.github.nacabaro.vbhelper.navigation.NavigationItems
|
||||||
import com.github.nacabaro.vbhelper.screens.homeScreens.HomeScreenControllerImpl
|
import com.github.nacabaro.vbhelper.screens.homeScreens.HomeScreenControllerImpl
|
||||||
import com.github.nacabaro.vbhelper.screens.itemsScreen.ItemsScreenControllerImpl
|
import com.github.nacabaro.vbhelper.screens.itemsScreen.ItemsScreenControllerImpl
|
||||||
import com.github.nacabaro.vbhelper.screens.scanScreen.ScanScreenControllerImpl
|
import com.github.nacabaro.vbhelper.screens.scanScreen.ScanScreenControllerImpl
|
||||||
@ -19,14 +23,14 @@ import com.github.nacabaro.vbhelper.screens.cardScreen.CardScreenControllerImpl
|
|||||||
import com.github.nacabaro.vbhelper.screens.spriteViewer.SpriteViewerControllerImpl
|
import com.github.nacabaro.vbhelper.screens.spriteViewer.SpriteViewerControllerImpl
|
||||||
import com.github.nacabaro.vbhelper.screens.storageScreen.StorageScreenControllerImpl
|
import com.github.nacabaro.vbhelper.screens.storageScreen.StorageScreenControllerImpl
|
||||||
import com.github.nacabaro.vbhelper.ui.theme.VBHelperTheme
|
import com.github.nacabaro.vbhelper.ui.theme.VBHelperTheme
|
||||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
import androidx.lifecycle.lifecycleScope
|
||||||
import kotlinx.coroutines.flow.asSharedFlow
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
|
||||||
|
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
private val onActivityLifecycleListeners = HashMap<String, ActivityLifecycleListener>()
|
private val onActivityLifecycleListeners = HashMap<String, ActivityLifecycleListener>()
|
||||||
private var initialRoute: String? = null
|
private var initialRoute: String? by mutableStateOf(null)
|
||||||
private val deepLinkNavigationEvents = MutableSharedFlow<String>(extraBufferCapacity = 1)
|
|
||||||
|
|
||||||
private fun registerActivityLifecycleListener(key: String, activityLifecycleListener: ActivityLifecycleListener) {
|
private fun registerActivityLifecycleListener(key: String, activityLifecycleListener: ActivityLifecycleListener) {
|
||||||
if( onActivityLifecycleListeners[key] != null) {
|
if( onActivityLifecycleListeners[key] != null) {
|
||||||
@ -60,7 +64,6 @@ class MainActivity : ComponentActivity() {
|
|||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
|
|
||||||
initialRoute = getInitialRouteFromIntent(intent)
|
initialRoute = getInitialRouteFromIntent(intent)
|
||||||
initialRoute?.let { deepLinkNavigationEvents.tryEmit(it) }
|
|
||||||
|
|
||||||
setContent {
|
setContent {
|
||||||
VBHelperTheme {
|
VBHelperTheme {
|
||||||
@ -73,8 +76,7 @@ class MainActivity : ComponentActivity() {
|
|||||||
storageScreenController = storageScreenController,
|
storageScreenController = storageScreenController,
|
||||||
spriteViewerController = spriteViewerController,
|
spriteViewerController = spriteViewerController,
|
||||||
cardScreenController = cardScreenController,
|
cardScreenController = cardScreenController,
|
||||||
initialRoute = initialRoute,
|
initialRoute = initialRoute
|
||||||
deepLinkNavigationEvents = deepLinkNavigationEvents.asSharedFlow(),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -102,15 +104,35 @@ class MainActivity : ComponentActivity() {
|
|||||||
super.onNewIntent(intent)
|
super.onNewIntent(intent)
|
||||||
setIntent(intent)
|
setIntent(intent)
|
||||||
initialRoute = getInitialRouteFromIntent(intent)
|
initialRoute = getInitialRouteFromIntent(intent)
|
||||||
initialRoute?.let { deepLinkNavigationEvents.tryEmit(it) }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getInitialRouteFromIntent(intent: Intent?): String? {
|
private fun getInitialRouteFromIntent(intent: Intent?): String? {
|
||||||
if (intent == null) return null
|
if (intent == null) return null
|
||||||
val data = intent.data
|
val data = intent.data
|
||||||
if (intent.action == Intent.ACTION_VIEW && data != null) {
|
if (intent.action == Intent.ACTION_VIEW && data != null) {
|
||||||
if (data.scheme == "vbhelper" && data.host == "auth") {
|
val isAppAuthCallback = data.scheme == "vbhelper" && data.host == "auth"
|
||||||
return "Battle"
|
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) {
|
||||||
|
if (!token.isNullOrEmpty()) {
|
||||||
|
val application = applicationContext as VBHelper
|
||||||
|
val authRepository = com.github.nacabaro.vbhelper.battle.BattleAuthContainer(this).authRepository
|
||||||
|
val userId = data.getQueryParameter("userId")?.toLongOrNull()
|
||||||
|
|
||||||
|
lifecycleScope.launch(Dispatchers.IO) {
|
||||||
|
authRepository.setAuthenticated(
|
||||||
|
isAuthenticated = true,
|
||||||
|
nacatechToken = token,
|
||||||
|
userId = userId
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return NavigationItems.Battles.route
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
@ -126,8 +148,7 @@ class MainActivity : ComponentActivity() {
|
|||||||
homeScreenController: HomeScreenControllerImpl,
|
homeScreenController: HomeScreenControllerImpl,
|
||||||
spriteViewerController: SpriteViewerControllerImpl,
|
spriteViewerController: SpriteViewerControllerImpl,
|
||||||
cardScreenController: CardScreenControllerImpl,
|
cardScreenController: CardScreenControllerImpl,
|
||||||
initialRoute: String? = null,
|
initialRoute: String? = null
|
||||||
deepLinkNavigationEvents: kotlinx.coroutines.flow.Flow<String>,
|
|
||||||
) {
|
) {
|
||||||
AppNavigation(
|
AppNavigation(
|
||||||
applicationNavigationHandlers = AppNavigationHandlers(
|
applicationNavigationHandlers = AppNavigationHandlers(
|
||||||
@ -140,8 +161,7 @@ class MainActivity : ComponentActivity() {
|
|||||||
spriteViewerController,
|
spriteViewerController,
|
||||||
cardScreenController
|
cardScreenController
|
||||||
),
|
),
|
||||||
initialRoute = initialRoute,
|
initialRoute = initialRoute
|
||||||
navigationEvents = deepLinkNavigationEvents,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,9 +5,7 @@ import androidx.compose.runtime.*
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.asImageBitmap
|
import androidx.compose.ui.graphics.asImageBitmap
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun AnimatedSpriteImage(
|
fun AnimatedSpriteImage(
|
||||||
@ -15,11 +13,9 @@ fun AnimatedSpriteImage(
|
|||||||
animationType: DigimonAnimationType = DigimonAnimationType.IDLE,
|
animationType: DigimonAnimationType = DigimonAnimationType.IDLE,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
contentScale: ContentScale = ContentScale.Fit,
|
contentScale: ContentScale = ContentScale.Fit,
|
||||||
reloadMappings: Boolean = false,
|
|
||||||
animationOffset: Long = 0L // New parameter for offsetting animation timing
|
animationOffset: Long = 0L // New parameter for offsetting animation timing
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val spriteManager = remember { IndividualSpriteManager() }
|
||||||
val spriteManager = remember { IndividualSpriteManager(context) }
|
|
||||||
|
|
||||||
// Calculate frame offset based on animation offset
|
// Calculate frame offset based on animation offset
|
||||||
// 750ms is the idle animation duration, so we calculate how many frames to offset
|
// 750ms is the idle animation duration, so we calculate how many frames to offset
|
||||||
@ -30,18 +26,11 @@ fun AnimatedSpriteImage(
|
|||||||
0
|
0
|
||||||
}
|
}
|
||||||
|
|
||||||
val animationStateMachine = remember { DigimonAnimationStateMachine(characterId, context, frameOffset, animationOffset) }
|
val animationStateMachine = remember { DigimonAnimationStateMachine(characterId, frameOffset, animationOffset) }
|
||||||
val coroutineScope = rememberCoroutineScope()
|
val coroutineScope = rememberCoroutineScope()
|
||||||
|
|
||||||
var bitmap by remember { mutableStateOf<android.graphics.Bitmap?>(null) }
|
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
|
// Start the animation when the component is first created
|
||||||
LaunchedEffect(characterId) {
|
LaunchedEffect(characterId) {
|
||||||
coroutineScope.launch {
|
coroutineScope.launch {
|
||||||
|
|||||||
@ -1,17 +1,10 @@
|
|||||||
package com.github.nacabaro.vbhelper.battle
|
package com.github.nacabaro.vbhelper.battle
|
||||||
|
|
||||||
import android.util.Log
|
|
||||||
import androidx.compose.runtime.MutableState
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.runtime.State
|
|
||||||
|
|
||||||
class ArenaBattleSystem {
|
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,
|
// 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
|
// 3=Opponent attack on opponent screen, 4=Opponent attack on player screen
|
||||||
private var _attackPhase by mutableStateOf(0)
|
private var _attackPhase by mutableStateOf(0)
|
||||||
@ -44,19 +37,6 @@ class ArenaBattleSystem {
|
|||||||
private var _critBarProgress by mutableStateOf(0)
|
private var _critBarProgress by mutableStateOf(0)
|
||||||
val critBarProgress: Int get() = _critBarProgress
|
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)
|
private var _hitProgress by mutableStateOf(0f)
|
||||||
val hitProgress: Float get() = _hitProgress
|
val hitProgress: Float get() = _hitProgress
|
||||||
|
|
||||||
@ -180,10 +160,6 @@ class ArenaBattleSystem {
|
|||||||
_isPlayerAttacking = false
|
_isPlayerAttacking = false
|
||||||
_attackIsHit = false
|
_attackIsHit = false
|
||||||
_currentView = 0
|
_currentView = 0
|
||||||
_isDodging = false
|
|
||||||
_dodgeProgress = 0f
|
|
||||||
_dodgeDirection = 1f
|
|
||||||
_isHit = false
|
|
||||||
_hitProgress = 0f
|
_hitProgress = 0f
|
||||||
_isPlayerDodging = false
|
_isPlayerDodging = false
|
||||||
_isOpponentDodging = false
|
_isOpponentDodging = false
|
||||||
@ -215,41 +191,10 @@ class ArenaBattleSystem {
|
|||||||
//Log.d(TAG, "Updated crit bar progress: $progress")
|
//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) {
|
fun setHitProgress(progress: Float) {
|
||||||
_hitProgress = progress
|
_hitProgress = progress
|
||||||
}
|
}
|
||||||
|
|
||||||
fun endHit() {
|
|
||||||
_isHit = false
|
|
||||||
_hitProgress = 0f
|
|
||||||
}
|
|
||||||
|
|
||||||
// Player-specific dodge methods
|
// Player-specific dodge methods
|
||||||
fun startPlayerDodge() {
|
fun startPlayerDodge() {
|
||||||
_isPlayerDodging = true
|
_isPlayerDodging = true
|
||||||
@ -345,17 +290,6 @@ class ArenaBattleSystem {
|
|||||||
_isOpponentShakeDelayed = false
|
_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
|
// Method to handle opponent attack result
|
||||||
fun handleOpponentAttackResult(isHit: Boolean) {
|
fun handleOpponentAttackResult(isHit: Boolean) {
|
||||||
|
|||||||
@ -20,6 +20,7 @@ class AuthInterceptor(private val token: String) : Interceptor {
|
|||||||
// Use X-Session-Token header (preferred) or Authorization: Bearer
|
// Use X-Session-Token header (preferred) or Authorization: Bearer
|
||||||
val authenticatedRequest = originalRequest.newBuilder()
|
val authenticatedRequest = originalRequest.newBuilder()
|
||||||
.header("X-Session-Token", token)
|
.header("X-Session-Token", token)
|
||||||
|
.header("Authorization", "Bearer $token")
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
// Debug: Log which header is being used (first few chars of token for security)
|
// Debug: Log which header is being used (first few chars of token for security)
|
||||||
|
|||||||
@ -1,21 +1,46 @@
|
|||||||
package com.github.nacabaro.vbhelper.battle
|
package com.github.nacabaro.vbhelper.battle
|
||||||
|
|
||||||
|
import com.google.gson.annotations.SerializedName
|
||||||
|
|
||||||
data class AdditionalInfo(
|
data class AdditionalInfo(
|
||||||
|
@SerializedName(value = "avatar", alternate = ["avatar_url"])
|
||||||
val avatar: String? = null,
|
val avatar: String? = null,
|
||||||
|
@SerializedName(value = "id", alternate = ["user_id"])
|
||||||
val id: Long? = null,
|
val id: Long? = null,
|
||||||
|
@SerializedName(value = "name", alternate = ["username", "display_name"])
|
||||||
val name: String? = null,
|
val name: String? = null,
|
||||||
val status: String? = null
|
val status: String? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
data class UserInfo(
|
data class UserInfo(
|
||||||
|
@SerializedName(value = "userId", alternate = ["user_id", "id"])
|
||||||
val userId: String? = null,
|
val userId: String? = null,
|
||||||
|
@SerializedName(value = "additionalInfo", alternate = ["additional_info"])
|
||||||
val additionalInfo: AdditionalInfo? = null
|
val additionalInfo: AdditionalInfo? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
data class AuthenticateResponse(
|
data class AuthenticateResponse(
|
||||||
|
@SerializedName(value = "success", alternate = ["ok"])
|
||||||
val success: Boolean,
|
val success: Boolean,
|
||||||
val message: String? = null,
|
val message: String? = null,
|
||||||
|
@SerializedName(value = "userInfo", alternate = ["user_info", "user"])
|
||||||
val userInfo: UserInfo? = null,
|
val userInfo: UserInfo? = null,
|
||||||
val sessionToken: String? = 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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
fun AuthenticateResponse.extractSessionToken(): String? {
|
||||||
|
return sessionToken?.takeIf { it.isNotBlank() }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun AuthenticateResponse.extractUserId(): Long? {
|
||||||
|
return userInfo?.userId?.toLongOrNull()
|
||||||
|
?: topLevelUserId?.toLongOrNull()
|
||||||
|
?: userInfo?.additionalInfo?.id
|
||||||
|
?: topLevelId
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@ -4,22 +4,13 @@ import androidx.compose.runtime.getValue
|
|||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import android.content.Context
|
|
||||||
import java.io.File
|
|
||||||
|
|
||||||
enum class DigimonAnimationType {
|
enum class DigimonAnimationType {
|
||||||
IDLE,
|
IDLE,
|
||||||
IDLE2,
|
IDLE2,
|
||||||
WALK,
|
WALK,
|
||||||
WALK2,
|
|
||||||
RUN,
|
|
||||||
RUN2,
|
|
||||||
WORKOUT,
|
|
||||||
WORKOUT2,
|
|
||||||
HAPPY,
|
|
||||||
SLEEP,
|
SLEEP,
|
||||||
ATTACK,
|
ATTACK
|
||||||
FLEE
|
|
||||||
}
|
}
|
||||||
|
|
||||||
data class AnimationState(
|
data class AnimationState(
|
||||||
@ -31,7 +22,6 @@ data class AnimationState(
|
|||||||
|
|
||||||
class DigimonAnimationStateMachine(
|
class DigimonAnimationStateMachine(
|
||||||
private val characterId: String,
|
private val characterId: String,
|
||||||
private val context: Context,
|
|
||||||
private val initialFrameOffset: Int = 0, // New parameter for offsetting the starting frame
|
private val initialFrameOffset: Int = 0, // New parameter for offsetting the starting frame
|
||||||
private val timingOffset: Long = 0L // New parameter for offsetting the timing
|
private val timingOffset: Long = 0L // New parameter for offsetting the timing
|
||||||
) {
|
) {
|
||||||
@ -44,21 +34,13 @@ class DigimonAnimationStateMachine(
|
|||||||
var isPlaying by mutableStateOf(false)
|
var isPlaying by mutableStateOf(false)
|
||||||
private set
|
private set
|
||||||
|
|
||||||
// Direct mapping of frame numbers (1-12) to animation types
|
// Direct mapping of frame numbers used by current battle animations.
|
||||||
// This is based on the standard Digimon sprite frame order
|
|
||||||
private val frameToAnimationType = mapOf(
|
private val frameToAnimationType = mapOf(
|
||||||
1 to DigimonAnimationType.IDLE,
|
1 to DigimonAnimationType.IDLE,
|
||||||
2 to DigimonAnimationType.IDLE2,
|
2 to DigimonAnimationType.IDLE2,
|
||||||
3 to DigimonAnimationType.WALK,
|
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,
|
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
|
// Reverse mapping for getting frame numbers for each animation type
|
||||||
@ -69,15 +51,8 @@ class DigimonAnimationStateMachine(
|
|||||||
DigimonAnimationType.IDLE to 750L,
|
DigimonAnimationType.IDLE to 750L,
|
||||||
DigimonAnimationType.IDLE2 to 750L,
|
DigimonAnimationType.IDLE2 to 750L,
|
||||||
DigimonAnimationType.WALK to 200L,
|
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.SLEEP to 1500L,
|
||||||
DigimonAnimationType.ATTACK to 650L,
|
DigimonAnimationType.ATTACK to 650L
|
||||||
DigimonAnimationType.FLEE to 150L
|
|
||||||
)
|
)
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@ -155,13 +130,4 @@ class DigimonAnimationStateMachine(
|
|||||||
return currentFrameNumber
|
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,7 +10,6 @@ import androidx.compose.ui.Alignment
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.asImageBitmap
|
import androidx.compose.ui.graphics.asImageBitmap
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.platform.LocalConfiguration
|
import androidx.compose.ui.platform.LocalConfiguration
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
@ -25,10 +24,9 @@ fun HitEffectOverlay(
|
|||||||
) {
|
) {
|
||||||
if (!isVisible) return
|
if (!isVisible) return
|
||||||
|
|
||||||
val context = LocalContext.current
|
|
||||||
val configuration = LocalConfiguration.current
|
val configuration = LocalConfiguration.current
|
||||||
val isLandscapeMode = configuration.orientation == android.content.res.Configuration.ORIENTATION_LANDSCAPE
|
val isLandscapeMode = configuration.orientation == android.content.res.Configuration.ORIENTATION_LANDSCAPE
|
||||||
val hitEffectManager = remember { HitEffectSpriteManager(context) }
|
val hitEffectManager = remember { HitEffectSpriteManager() }
|
||||||
val coroutineScope = rememberCoroutineScope()
|
val coroutineScope = rememberCoroutineScope()
|
||||||
|
|
||||||
var currentFrame by remember { mutableStateOf(0) }
|
var currentFrame by remember { mutableStateOf(0) }
|
||||||
|
|||||||
@ -1,13 +1,10 @@
|
|||||||
package com.github.nacabaro.vbhelper.battle
|
package com.github.nacabaro.vbhelper.battle
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
import android.graphics.BitmapFactory
|
import android.graphics.BitmapFactory
|
||||||
import android.graphics.Rect
|
|
||||||
import android.os.Environment
|
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
|
||||||
class HitEffectSpriteManager(private val context: Context) {
|
class HitEffectSpriteManager {
|
||||||
private val spriteCache = mutableMapOf<String, Bitmap>()
|
private val spriteCache = mutableMapOf<String, Bitmap>()
|
||||||
|
|
||||||
// Get the external storage directory for hit effect sprites
|
// Get the external storage directory for hit effect sprites
|
||||||
@ -56,117 +53,4 @@ class HitEffectSpriteManager(private val context: Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 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,12 +1,10 @@
|
|||||||
package com.github.nacabaro.vbhelper.battle
|
package com.github.nacabaro.vbhelper.battle
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
import android.graphics.BitmapFactory
|
import android.graphics.BitmapFactory
|
||||||
import android.os.Environment
|
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
|
||||||
class IndividualSpriteManager(private val context: Context) {
|
class IndividualSpriteManager {
|
||||||
private val spriteCache = mutableMapOf<String, Bitmap>()
|
private val spriteCache = mutableMapOf<String, Bitmap>()
|
||||||
|
|
||||||
// Get the external storage directory for sprite files
|
// Get the external storage directory for sprite files
|
||||||
@ -65,68 +63,4 @@ class IndividualSpriteManager(private val context: Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 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,7 +12,11 @@ import kotlinx.coroutines.flow.first
|
|||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withTimeoutOrNull
|
||||||
import com.github.nacabaro.vbhelper.battle.BattleAuthContainer
|
import com.github.nacabaro.vbhelper.battle.BattleAuthContainer
|
||||||
|
import java.net.SocketTimeoutException
|
||||||
|
import java.io.InterruptedIOException
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
class RetrofitHelper {
|
class RetrofitHelper {
|
||||||
|
|
||||||
@ -28,9 +32,22 @@ class RetrofitHelper {
|
|||||||
return OkHttpClient.Builder()
|
return OkHttpClient.Builder()
|
||||||
.addInterceptor(AuthInterceptor(token))
|
.addInterceptor(AuthInterceptor(token))
|
||||||
.addInterceptor(loggingInterceptor)
|
.addInterceptor(loggingInterceptor)
|
||||||
|
.connectTimeout(30, TimeUnit.SECONDS)
|
||||||
|
.readTimeout(30, TimeUnit.SECONDS)
|
||||||
|
.writeTimeout(30, TimeUnit.SECONDS)
|
||||||
|
.callTimeout(45, TimeUnit.SECONDS)
|
||||||
|
.retryOnConnectionFailure(true)
|
||||||
.build()
|
.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.
|
* Gets the session token from AuthRepository for API calls.
|
||||||
* Falls back to nacatech token if session token is not available (backward compatibility).
|
* Falls back to nacatech token if session token is not available (backward compatibility).
|
||||||
@ -38,7 +55,8 @@ class RetrofitHelper {
|
|||||||
private fun getAuthToken(context: Context): String? {
|
private fun getAuthToken(context: Context): String? {
|
||||||
return try {
|
return try {
|
||||||
val authContainer = BattleAuthContainer(context)
|
val authContainer = BattleAuthContainer(context)
|
||||||
runBlocking {
|
runBlocking(Dispatchers.IO) {
|
||||||
|
withTimeoutOrNull(5000L) {
|
||||||
// Prefer session token, fall back to nacatech token for backward compatibility
|
// Prefer session token, fall back to nacatech token for backward compatibility
|
||||||
val sessionToken = authContainer.authRepository.sessionToken.first()
|
val sessionToken = authContainer.authRepository.sessionToken.first()
|
||||||
if (!sessionToken.isNullOrEmpty()) {
|
if (!sessionToken.isNullOrEmpty()) {
|
||||||
@ -52,6 +70,10 @@ class RetrofitHelper {
|
|||||||
}
|
}
|
||||||
nacatechToken
|
nacatechToken
|
||||||
}
|
}
|
||||||
|
} ?: run {
|
||||||
|
println("RetrofitHelper: Timed out while reading auth token from DataStore")
|
||||||
|
null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
println("RetrofitHelper: Error getting auth token: ${e.message}")
|
println("RetrofitHelper: Error getting auth token: ${e.message}")
|
||||||
@ -76,6 +98,29 @@ class RetrofitHelper {
|
|||||||
.addConverterFactory(GsonConverterFactory.create())
|
.addConverterFactory(GsonConverterFactory.create())
|
||||||
.build()
|
.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).
|
* Handles HTTP error responses (401, 403, 429).
|
||||||
@ -121,16 +166,15 @@ class RetrofitHelper {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getOpponents(context: Context, stage: String, callback: (OpponentsDataModel) -> Unit) {
|
fun getOpponents(context: Context, stage: String, onComplete: (() -> Unit)? = null, callback: (OpponentsDataModel) -> Unit) {
|
||||||
//println("RetrofitHelper: Starting API call for stage: $stage")
|
//println("RetrofitHelper: Starting API call for stage: $stage")
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Create an authenticated Retrofit instance
|
// Opponents endpoint requires auth on the current backend.
|
||||||
val retrofit = createAuthenticatedRetrofit(context)
|
// Prefer authenticated Retrofit; fall back to public client only if no token is available.
|
||||||
if (retrofit == null) {
|
val retrofit = createAuthenticatedRetrofit(context) ?: run {
|
||||||
println("RetrofitHelper: Cannot create authenticated Retrofit - no token available")
|
println("RetrofitHelper: No auth token for opponents request, falling back to public Retrofit")
|
||||||
Toast.makeText(context, "Authentication required. Please log in.", Toast.LENGTH_SHORT).show()
|
createPublicRetrofit()
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create an ApiService instance from the Retrofit instance.
|
// Create an ApiService instance from the Retrofit instance.
|
||||||
@ -146,9 +190,11 @@ class RetrofitHelper {
|
|||||||
// make an asynchronous API request.
|
// make an asynchronous API request.
|
||||||
call.enqueue(object : Callback<OpponentsDataModel> {
|
call.enqueue(object : Callback<OpponentsDataModel> {
|
||||||
override fun onFailure(call: Call<OpponentsDataModel>, t: Throwable) {
|
override fun onFailure(call: Call<OpponentsDataModel>, t: Throwable) {
|
||||||
println("RetrofitHelper: API call failed: ${t.message}")
|
val classified = classifyNetworkError(t)
|
||||||
|
println("RetrofitHelper: API call failed (${t::class.java.simpleName}): $classified")
|
||||||
t.printStackTrace()
|
t.printStackTrace()
|
||||||
Toast.makeText(context, "Request Fail", Toast.LENGTH_SHORT).show()
|
Toast.makeText(context, classified, Toast.LENGTH_SHORT).show()
|
||||||
|
onComplete?.invoke()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onResponse(call: Call<OpponentsDataModel>, response: Response<OpponentsDataModel>) {
|
override fun onResponse(call: Call<OpponentsDataModel>, response: Response<OpponentsDataModel>) {
|
||||||
@ -156,13 +202,19 @@ class RetrofitHelper {
|
|||||||
println("RetrofitHelper: Response body: ${response.body()}")
|
println("RetrofitHelper: Response body: ${response.body()}")
|
||||||
|
|
||||||
if(response.isSuccessful){
|
if(response.isSuccessful){
|
||||||
//println("RetrofitHelper: Response successful, calling callback")
|
val opponentsList = response.body()
|
||||||
val opponentsList: OpponentsDataModel = response.body() as OpponentsDataModel
|
if (opponentsList != null) {
|
||||||
callback(opponentsList)
|
callback(opponentsList)
|
||||||
|
} else {
|
||||||
|
println("RetrofitHelper: Successful opponents response had empty body")
|
||||||
|
Toast.makeText(context, "Empty response from server", Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
onComplete?.invoke()
|
||||||
} else {
|
} else {
|
||||||
val errorBody = response.errorBody()?.string()
|
val errorBody = response.errorBody()?.string()
|
||||||
println("RetrofitHelper: Response not successful - Error: $errorBody")
|
println("RetrofitHelper: Opponents response not successful - Code: ${response.code()}, Error: $errorBody")
|
||||||
handleErrorResponse(context, response, errorBody ?: "Unknown error")
|
handleErrorResponse(context, response, errorBody ?: "Unable to load opponents right now.")
|
||||||
|
onComplete?.invoke()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@ -170,6 +222,7 @@ class RetrofitHelper {
|
|||||||
println("RetrofitHelper: Exception in getOpponents: ${e.message}")
|
println("RetrofitHelper: Exception in getOpponents: ${e.message}")
|
||||||
e.printStackTrace()
|
e.printStackTrace()
|
||||||
Toast.makeText(context, "Request failed: ${e.message}", Toast.LENGTH_SHORT).show()
|
Toast.makeText(context, "Request failed: ${e.message}", Toast.LENGTH_SHORT).show()
|
||||||
|
onComplete?.invoke()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -284,9 +337,10 @@ class RetrofitHelper {
|
|||||||
|
|
||||||
override fun onFailure(call: Call<PVPDataModel>, t: Throwable) {
|
override fun onFailure(call: Call<PVPDataModel>, t: Throwable) {
|
||||||
// This method is called when the API request fails.
|
// This method is called when the API request fails.
|
||||||
println("RetrofitHelper: PVP API call failed: ${t.message}")
|
val classified = classifyNetworkError(t)
|
||||||
|
println("RetrofitHelper: PVP API call failed (${t::class.java.simpleName}): $classified")
|
||||||
t.printStackTrace()
|
t.printStackTrace()
|
||||||
Toast.makeText(context, "Request Fail", Toast.LENGTH_SHORT).show()
|
Toast.makeText(context, classified, Toast.LENGTH_SHORT).show()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onResponse(call: Call<PVPDataModel>, response: Response<PVPDataModel>) {
|
override fun onResponse(call: Call<PVPDataModel>, response: Response<PVPDataModel>) {
|
||||||
@ -294,13 +348,13 @@ class RetrofitHelper {
|
|||||||
println("RetrofitHelper: PVP API response received - Code: ${response.code()}")
|
println("RetrofitHelper: PVP API response received - Code: ${response.code()}")
|
||||||
|
|
||||||
if(response.isSuccessful){
|
if(response.isSuccessful){
|
||||||
// If the response is successful, parse the
|
val apiResults = response.body()
|
||||||
// response body to a DataModel object.
|
if (apiResults != null) {
|
||||||
val apiResults: PVPDataModel = response.body() as PVPDataModel
|
|
||||||
|
|
||||||
// Call the callback function with the DataModel
|
|
||||||
// object as a parameter.
|
|
||||||
callback(apiResults)
|
callback(apiResults)
|
||||||
|
} else {
|
||||||
|
println("RetrofitHelper: Successful PVP response had empty body")
|
||||||
|
Toast.makeText(context, "Empty response from server", Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
val errorBody = response.errorBody()?.string()
|
val errorBody = response.errorBody()?.string()
|
||||||
println("RetrofitHelper: PVP API response not successful - Code: ${response.code()}, Error: $errorBody")
|
println("RetrofitHelper: PVP API response not successful - Code: ${response.code()}, Error: $errorBody")
|
||||||
@ -315,12 +369,20 @@ class RetrofitHelper {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun authenticate(context: Context, token: String, callback: (AuthenticateResponse) -> Unit) {
|
fun authenticate(
|
||||||
|
context: Context,
|
||||||
|
token: String,
|
||||||
|
showUserFacingErrors: Boolean = true,
|
||||||
|
callback: (AuthenticateResponse) -> Unit,
|
||||||
|
) {
|
||||||
//println("RetrofitHelper: Starting validate API call with token: $token")
|
//println("RetrofitHelper: Starting validate API call with token: $token")
|
||||||
|
|
||||||
if (token.isEmpty()) {
|
if (token.isEmpty()) {
|
||||||
println("RetrofitHelper: ERROR - Token is empty!")
|
println("RetrofitHelper: ERROR - Token is empty!")
|
||||||
|
if (showUserFacingErrors) {
|
||||||
Toast.makeText(context, "Authentication failed: Token is empty", Toast.LENGTH_SHORT).show()
|
Toast.makeText(context, "Authentication failed: Token is empty", Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
callback(AuthenticateResponse(success = false, message = "Token is empty"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -331,6 +393,10 @@ class RetrofitHelper {
|
|||||||
}
|
}
|
||||||
val okHttpClient = OkHttpClient.Builder()
|
val okHttpClient = OkHttpClient.Builder()
|
||||||
.addInterceptor(loggingInterceptor)
|
.addInterceptor(loggingInterceptor)
|
||||||
|
.connectTimeout(30, TimeUnit.SECONDS)
|
||||||
|
.readTimeout(30, TimeUnit.SECONDS)
|
||||||
|
.writeTimeout(30, TimeUnit.SECONDS)
|
||||||
|
.callTimeout(45, TimeUnit.SECONDS)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
val retrofit: Retrofit = Retrofit.Builder()
|
val retrofit: Retrofit = Retrofit.Builder()
|
||||||
@ -348,7 +414,11 @@ class RetrofitHelper {
|
|||||||
override fun onFailure(call: Call<AuthenticateResponse>, t: Throwable) {
|
override fun onFailure(call: Call<AuthenticateResponse>, t: Throwable) {
|
||||||
println("RetrofitHelper: Validate API call failed: ${t.message}")
|
println("RetrofitHelper: Validate API call failed: ${t.message}")
|
||||||
t.printStackTrace()
|
t.printStackTrace()
|
||||||
Toast.makeText(context, "Authentication failed: ${t.message}", Toast.LENGTH_SHORT).show()
|
val message = classifyNetworkError(t)
|
||||||
|
if (showUserFacingErrors) {
|
||||||
|
Toast.makeText(context, "Authentication failed: $message", Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
callback(AuthenticateResponse(success = false, message = message))
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onResponse(call: Call<AuthenticateResponse>, response: Response<AuthenticateResponse>) {
|
override fun onResponse(call: Call<AuthenticateResponse>, response: Response<AuthenticateResponse>) {
|
||||||
@ -360,11 +430,25 @@ class RetrofitHelper {
|
|||||||
} else {
|
} else {
|
||||||
println("RetrofitHelper: Validation failed: Invalid response body")
|
println("RetrofitHelper: Validation failed: Invalid response body")
|
||||||
Toast.makeText(context, "Authentication failed: Invalid response", Toast.LENGTH_SHORT).show()
|
Toast.makeText(context, "Authentication failed: Invalid response", Toast.LENGTH_SHORT).show()
|
||||||
|
callback(AuthenticateResponse(success = false, message = "Invalid response body"))
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
val errorBody = response.errorBody()?.string()
|
val errorBody = response.errorBody()?.string()
|
||||||
println("RetrofitHelper: Validate response not successful - Code: ${response.code()}, Error: $errorBody")
|
println("RetrofitHelper: Validate response not successful - Code: ${response.code()}, Error: $errorBody")
|
||||||
Toast.makeText(context, "Authentication failed: ${response.code()}", Toast.LENGTH_SHORT).show()
|
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"}"
|
||||||
|
)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@ -372,6 +456,7 @@ class RetrofitHelper {
|
|||||||
println("RetrofitHelper: Exception in validate: ${e.message}")
|
println("RetrofitHelper: Exception in validate: ${e.message}")
|
||||||
e.printStackTrace()
|
e.printStackTrace()
|
||||||
Toast.makeText(context, "Authentication failed: ${e.message}", Toast.LENGTH_SHORT).show()
|
Toast.makeText(context, "Authentication failed: ${e.message}", Toast.LENGTH_SHORT).show()
|
||||||
|
callback(AuthenticateResponse(success = false, message = e.message ?: "Authentication error"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1,275 +1,13 @@
|
|||||||
package com.github.nacabaro.vbhelper.battle
|
package com.github.nacabaro.vbhelper.battle
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.FileOutputStream
|
|
||||||
import java.io.IOException
|
|
||||||
import java.io.FileInputStream
|
|
||||||
|
|
||||||
class SpriteFileManager(private val context: Context) {
|
class SpriteFileManager {
|
||||||
|
private fun getExternalSpriteBaseDir(): File {
|
||||||
// Get the external storage directory where files are already located
|
|
||||||
fun getExternalSpriteBaseDir(): File {
|
|
||||||
val externalDir = android.os.Environment.getExternalStorageDirectory()
|
val externalDir = android.os.Environment.getExternalStorageDirectory()
|
||||||
return File(externalDir, "VBHelper/battle_sprites")
|
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 {
|
fun checkSpriteFilesExist(): Boolean {
|
||||||
val battleSpritesDir = getExternalSpriteBaseDir()
|
val battleSpritesDir = getExternalSpriteBaseDir()
|
||||||
val extractedAssetsDir = File(battleSpritesDir, "extracted_assets")
|
val extractedAssetsDir = File(battleSpritesDir, "extracted_assets")
|
||||||
@ -294,35 +32,4 @@ class SpriteFileManager(private val context: Context) {
|
|||||||
|
|
||||||
return battleSpritesExist && assetsExist && statsExist && atkspritesExist && battlebgsExist
|
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,7 +5,6 @@ import androidx.compose.runtime.*
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.asImageBitmap
|
import androidx.compose.ui.graphics.asImageBitmap
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun SpriteImage(
|
fun SpriteImage(
|
||||||
@ -14,8 +13,7 @@ fun SpriteImage(
|
|||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
contentScale: ContentScale = ContentScale.Fit
|
contentScale: ContentScale = ContentScale.Fit
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val spriteManager = remember { IndividualSpriteManager() }
|
||||||
val spriteManager = remember { IndividualSpriteManager(context) }
|
|
||||||
|
|
||||||
var bitmap by remember { mutableStateOf<android.graphics.Bitmap?>(null) }
|
var bitmap by remember { mutableStateOf<android.graphics.Bitmap?>(null) }
|
||||||
|
|
||||||
|
|||||||
@ -0,0 +1,46 @@
|
|||||||
|
package com.github.nacabaro.vbhelper.companion.card
|
||||||
|
|
||||||
|
enum class Endian {
|
||||||
|
Big,
|
||||||
|
Little,
|
||||||
|
}
|
||||||
|
|
||||||
|
fun ByteArray.getUInt32(index: Int = 0, endian: Endian = Endian.Little): UInt {
|
||||||
|
require(this.size >= index + 4) { "Must be 4 bytes from index to get a UInt" }
|
||||||
|
var result: UInt = 0u
|
||||||
|
for (i in 0 until 4) {
|
||||||
|
result = if (endian == Endian.Big) {
|
||||||
|
result or ((this[index + i].toUInt() and 0xFFu) shl (8 * (3 - i)))
|
||||||
|
} else {
|
||||||
|
result or ((this[index + i].toUInt() and 0xFFu) shl (8 * i))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
fun ByteArray.getUInt16(index: Int = 0, endian: Endian = Endian.Little): UShort {
|
||||||
|
require(this.size >= index + 2) { "Must be 2 bytes from index to get a UInt" }
|
||||||
|
var result: UInt = 0u
|
||||||
|
for (i in 0 until 2) {
|
||||||
|
result = if (endian == Endian.Big) {
|
||||||
|
result or ((this[index + i].toUInt() and 0xFFu) shl (8 * (1 - i)))
|
||||||
|
} else {
|
||||||
|
result or ((this[index + i].toUInt() and 0xFFu) shl (8 * i))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result.toUShort()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun UShort.toByteArray(endian: Endian = Endian.Little): ByteArray {
|
||||||
|
val byteArray = byteArrayOf(0, 0)
|
||||||
|
val asUInt = this.toUInt()
|
||||||
|
for (i in 0 until 2) {
|
||||||
|
if (endian == Endian.Little) {
|
||||||
|
byteArray[i] = ((asUInt shr (8 * i)) and 255u).toByte()
|
||||||
|
} else {
|
||||||
|
byteArray[1 - i] = ((asUInt shr (8 * i)) and 255u).toByte()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return byteArray
|
||||||
|
}
|
||||||
|
|
||||||
@ -0,0 +1,400 @@
|
|||||||
|
package com.github.nacabaro.vbhelper.companion.card
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.activity.compose.setContent
|
||||||
|
import androidx.activity.result.ActivityResultLauncher
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.Checkbox
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextField
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.github.cfogrady.vb.dim.adventure.AdventureLevels
|
||||||
|
import com.github.cfogrady.vb.dim.adventure.AdventureLevels.AdventureLevel
|
||||||
|
import com.github.cfogrady.vb.dim.card.Card
|
||||||
|
import com.github.cfogrady.vb.dim.card.DimReader
|
||||||
|
import com.github.cfogrady.vb.dim.card.DimWriter
|
||||||
|
import com.github.cfogrady.vb.dim.character.CharacterStats
|
||||||
|
import com.github.cfogrady.vb.dim.character.CharacterStats.CharacterStatsEntry
|
||||||
|
import com.github.cfogrady.vb.dim.fusion.AttributeFusions
|
||||||
|
import com.github.cfogrady.vb.dim.fusion.SpecificFusions
|
||||||
|
import com.github.cfogrady.vb.dim.fusion.SpecificFusions.SpecificFusionEntry
|
||||||
|
import com.github.cfogrady.vb.dim.header.DimHeader
|
||||||
|
import com.github.cfogrady.vb.dim.transformation.TransformationRequirements
|
||||||
|
import com.github.cfogrady.vb.dim.transformation.TransformationRequirements.TransformationRequirementsEntry
|
||||||
|
import com.github.nacabaro.vbhelper.R
|
||||||
|
import com.github.nacabaro.vbhelper.companion.card.CompanionValidateCardActivity
|
||||||
|
import com.github.nacabaro.vbhelper.companion.common.ChannelTypes
|
||||||
|
import com.github.nacabaro.vbhelper.companion.ui.CompanionLoading
|
||||||
|
import com.github.nacabaro.vbhelper.companion.validation.ValidatedCardManager
|
||||||
|
import com.github.nacabaro.vbhelper.di.VBHelper
|
||||||
|
import com.google.android.gms.wearable.Wearable
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.tasks.await
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import timber.log.Timber
|
||||||
|
import java.io.ByteArrayOutputStream
|
||||||
|
import java.io.DataOutputStream
|
||||||
|
import java.nio.charset.Charset
|
||||||
|
|
||||||
|
class CompanionImportCardActivity : ComponentActivity() {
|
||||||
|
enum class ImportState {
|
||||||
|
PickFile,
|
||||||
|
NameOrUnique,
|
||||||
|
LoadFile,
|
||||||
|
UnlockCard,
|
||||||
|
ImportCard,
|
||||||
|
Success,
|
||||||
|
}
|
||||||
|
|
||||||
|
private lateinit var filePickLauncher: ActivityResultLauncher<Array<String>>
|
||||||
|
private lateinit var validationLauncher: ActivityResultLauncher<Intent>
|
||||||
|
private var uri: Uri? = null
|
||||||
|
private val importState = MutableStateFlow(ImportState.PickFile)
|
||||||
|
private val cardName = MutableStateFlow("")
|
||||||
|
private val uniqueSprites = MutableStateFlow(false)
|
||||||
|
private val convertToBem = MutableStateFlow(false)
|
||||||
|
private val importPercent = MutableStateFlow(0)
|
||||||
|
private val importStatus = MutableStateFlow("Preparing transfer")
|
||||||
|
private lateinit var card: Card<out DimHeader, out CharacterStats<out CharacterStatsEntry>, out TransformationRequirements<out TransformationRequirementsEntry>, out AdventureLevels<out AdventureLevel>, out AttributeFusions, out SpecificFusions<out SpecificFusionEntry>>
|
||||||
|
|
||||||
|
private val validatedCardManager by lazy {
|
||||||
|
(application as VBHelper).validatedCardManager
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
filePickLauncher = buildFilePickLauncher()
|
||||||
|
validationLauncher = buildValidationLauncher()
|
||||||
|
setContent {
|
||||||
|
val state by importState.collectAsState()
|
||||||
|
when (state) {
|
||||||
|
ImportState.PickFile -> {
|
||||||
|
LaunchedEffect(true) {
|
||||||
|
filePickLauncher.launch(arrayOf("*/*"))
|
||||||
|
}
|
||||||
|
CompanionLoading(loadingText = stringResource(R.string.settings_companion_import_card_image_title))
|
||||||
|
}
|
||||||
|
ImportState.NameOrUnique -> NameOrUnique()
|
||||||
|
ImportState.LoadFile -> {
|
||||||
|
CompanionLoading(loadingText = "Loading Card Image")
|
||||||
|
LaunchedEffect(true) {
|
||||||
|
runCatching {
|
||||||
|
loadCard()
|
||||||
|
}.onFailure { error ->
|
||||||
|
Timber.e(error, "Failed to load card image")
|
||||||
|
Toast.makeText(
|
||||||
|
this@CompanionImportCardActivity,
|
||||||
|
"Card load failed: ${error.message ?: "unknown error"}",
|
||||||
|
Toast.LENGTH_LONG,
|
||||||
|
).show()
|
||||||
|
importState.value = ImportState.NameOrUnique
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ImportState.UnlockCard -> {
|
||||||
|
CompanionLoading(loadingText = stringResource(R.string.companion_validation_connect_vb))
|
||||||
|
LaunchedEffect(true) {
|
||||||
|
validationLauncher.launch(
|
||||||
|
Intent(applicationContext, CompanionValidateCardActivity::class.java).apply {
|
||||||
|
putExtra(CompanionValidateCardActivity.CARD_VALIDATED_KEY, card.header.dimId)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ImportState.ImportCard -> {
|
||||||
|
val percent by importPercent.collectAsState()
|
||||||
|
val status by importStatus.collectAsState()
|
||||||
|
CompanionLoading(loadingText = "$status ($percent%)")
|
||||||
|
LaunchedEffect(true) {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
val transferred = importCard()
|
||||||
|
importState.value = if (transferred) ImportState.Success else ImportState.NameOrUnique
|
||||||
|
} catch (error: Exception) {
|
||||||
|
Timber.e(error, "Card transfer failed")
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
Toast.makeText(
|
||||||
|
this@CompanionImportCardActivity,
|
||||||
|
"Card transfer failed: ${error.message ?: "unknown error"}",
|
||||||
|
Toast.LENGTH_LONG,
|
||||||
|
).show()
|
||||||
|
}
|
||||||
|
importState.value = ImportState.NameOrUnique
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ImportState.Success -> {
|
||||||
|
LaunchedEffect(true) {
|
||||||
|
Handler(Looper.getMainLooper()).postDelayed({
|
||||||
|
finish()
|
||||||
|
}, 1000)
|
||||||
|
}
|
||||||
|
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
|
Text(text = stringResource(R.string.companion_card_import_success))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun NameOrUnique() {
|
||||||
|
val selectedUri = uri
|
||||||
|
val filePath = selectedUri?.path ?: "No file selected"
|
||||||
|
val name by cardName.collectAsState()
|
||||||
|
val unique by uniqueSprites.collectAsState()
|
||||||
|
val convert by convertToBem.collectAsState()
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.Center,
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.Center,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Button(modifier = Modifier.padding(end = 10.dp), onClick = { importState.value = ImportState.PickFile }) {
|
||||||
|
Text(text = "File")
|
||||||
|
}
|
||||||
|
Text(text = filePath)
|
||||||
|
}
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(top = 16.dp),
|
||||||
|
horizontalArrangement = Arrangement.Center,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Text(text = "Name:", modifier = Modifier.padding(end = 10.dp))
|
||||||
|
TextField(value = name, onValueChange = { cardName.value = it })
|
||||||
|
}
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(top = 16.dp),
|
||||||
|
horizontalArrangement = Arrangement.Center,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Text(text = "Unique Sprites:")
|
||||||
|
Checkbox(checked = unique, onCheckedChange = { uniqueSprites.value = it })
|
||||||
|
}
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(top = 16.dp),
|
||||||
|
horizontalArrangement = Arrangement.Center,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Text(text = "Convert to BeM:")
|
||||||
|
Checkbox(checked = convert, onCheckedChange = { convertToBem.value = it })
|
||||||
|
}
|
||||||
|
Button(
|
||||||
|
modifier = Modifier.padding(top = 16.dp),
|
||||||
|
enabled = selectedUri != null,
|
||||||
|
onClick = { importState.value = ImportState.LoadFile },
|
||||||
|
) {
|
||||||
|
Text(text = "Import")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildFilePickLauncher(): ActivityResultLauncher<Array<String>> {
|
||||||
|
return registerForActivityResult(ActivityResultContracts.OpenDocument()) {
|
||||||
|
if (it == null) {
|
||||||
|
Toast.makeText(
|
||||||
|
this,
|
||||||
|
"Card file selection cancelled.",
|
||||||
|
Toast.LENGTH_SHORT,
|
||||||
|
).show()
|
||||||
|
importState.value = ImportState.NameOrUnique
|
||||||
|
} else {
|
||||||
|
uri = it
|
||||||
|
val path = it.path ?: ""
|
||||||
|
var name = path.substringAfterLast("/")
|
||||||
|
if (name.contains('.')) {
|
||||||
|
name = name.substringBeforeLast('.')
|
||||||
|
}
|
||||||
|
cardName.value = name
|
||||||
|
importState.value = ImportState.NameOrUnique
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildValidationLauncher(): ActivityResultLauncher<Intent> {
|
||||||
|
return registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||||
|
val validatedCardId = result.data?.extras?.getInt(CompanionValidateCardActivity.CARD_VALIDATED_KEY, -1)
|
||||||
|
if (result.resultCode != RESULT_OK || validatedCardId == null || validatedCardId == -1) {
|
||||||
|
Toast.makeText(
|
||||||
|
this,
|
||||||
|
"Card validation was cancelled.",
|
||||||
|
Toast.LENGTH_SHORT,
|
||||||
|
).show()
|
||||||
|
importState.value = ImportState.NameOrUnique
|
||||||
|
} else {
|
||||||
|
lifecycleScope.launch(Dispatchers.IO) {
|
||||||
|
validatedCardManager.addValidatedCard(validatedCardId)
|
||||||
|
importState.value = ImportState.ImportCard
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun loadCard() {
|
||||||
|
val selectedUri = uri
|
||||||
|
if (selectedUri == null) {
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
Toast.makeText(
|
||||||
|
this@CompanionImportCardActivity,
|
||||||
|
"No card file selected.",
|
||||||
|
Toast.LENGTH_SHORT,
|
||||||
|
).show()
|
||||||
|
}
|
||||||
|
importState.value = ImportState.NameOrUnique
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val dimReader = DimReader()
|
||||||
|
val inputStream = contentResolver.openInputStream(selectedUri)
|
||||||
|
if (inputStream == null) {
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
Toast.makeText(
|
||||||
|
this@CompanionImportCardActivity,
|
||||||
|
"Unable to open selected card file.",
|
||||||
|
Toast.LENGTH_LONG,
|
||||||
|
).show()
|
||||||
|
}
|
||||||
|
importState.value = ImportState.NameOrUnique
|
||||||
|
return
|
||||||
|
}
|
||||||
|
inputStream.use {
|
||||||
|
card = dimReader.readCard(it, false)
|
||||||
|
if (validatedCardManager.isValidatedCard(card.header.dimId)) {
|
||||||
|
importState.value = ImportState.ImportCard
|
||||||
|
} else {
|
||||||
|
importState.value = ImportState.UnlockCard
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun importCard(): Boolean {
|
||||||
|
importPercent.value = 0
|
||||||
|
importStatus.value = "Preparing card"
|
||||||
|
val channelClient = Wearable.getChannelClient(this)
|
||||||
|
val nodes = Wearable.getNodeClient(this).connectedNodes.await()
|
||||||
|
Timber.i("Card transfer start: connectedNodes=${nodes.size}, ids=${nodes.joinToString { it.id }}")
|
||||||
|
if (nodes.isEmpty()) {
|
||||||
|
importStatus.value = getString(R.string.companion_firmware_no_watch)
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
Toast.makeText(
|
||||||
|
this@CompanionImportCardActivity,
|
||||||
|
getString(R.string.companion_firmware_no_watch),
|
||||||
|
Toast.LENGTH_LONG,
|
||||||
|
).show()
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
val cardWriter = DimWriter()
|
||||||
|
val cardPayload = ByteArrayOutputStream().use { outputStream ->
|
||||||
|
cardWriter.writeCard(card, outputStream)
|
||||||
|
outputStream.toByteArray()
|
||||||
|
}
|
||||||
|
val cardNameBytes = cardName.value.toByteArray(Charset.defaultCharset())
|
||||||
|
val metadataLength = cardNameBytes.size + 1 + 1 + 1 + 4
|
||||||
|
val totalBytes = (metadataLength + cardPayload.size).toLong() * nodes.size
|
||||||
|
var transferredBytes = 0L
|
||||||
|
var successfulTransfers = 0
|
||||||
|
val failedNodes = mutableListOf<String>()
|
||||||
|
for (node in nodes) {
|
||||||
|
val nodeName = node.displayName.takeIf { it.isNotBlank() } ?: node.id
|
||||||
|
importStatus.value = "Connecting to $nodeName"
|
||||||
|
try {
|
||||||
|
Timber.i("Opening card channel for nodeId=${node.id}, displayName=${node.displayName}")
|
||||||
|
val channel = channelClient.openChannel(node.id, ChannelTypes.CARD_DATA).await()
|
||||||
|
channelClient.getOutputStream(channel).await().use { os ->
|
||||||
|
val output = DataOutputStream(os)
|
||||||
|
output.write(cardNameBytes)
|
||||||
|
output.writeByte(0)
|
||||||
|
output.writeByte(if (uniqueSprites.value) 1 else 0)
|
||||||
|
output.writeByte(if (convertToBem.value) 1 else 0)
|
||||||
|
output.writeInt(cardPayload.size)
|
||||||
|
transferredBytes += metadataLength
|
||||||
|
val metadataPercent = if (totalBytes == 0L) 0 else ((transferredBytes * 100) / totalBytes).toInt().coerceIn(0, 100)
|
||||||
|
importStatus.value = "Sending card to $nodeName"
|
||||||
|
importPercent.value = metadataPercent
|
||||||
|
|
||||||
|
val buffer = ByteArray(4096)
|
||||||
|
var bytesRead: Int
|
||||||
|
cardPayload.inputStream().use { payloadInput ->
|
||||||
|
while (payloadInput.read(buffer).also { bytesRead = it } >= 0) {
|
||||||
|
output.write(buffer, 0, bytesRead)
|
||||||
|
transferredBytes += bytesRead
|
||||||
|
val transferPercent = if (totalBytes == 0L) 0 else ((transferredBytes * 100) / totalBytes).toInt().coerceIn(0, 100)
|
||||||
|
importPercent.value = transferPercent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
output.flush()
|
||||||
|
}
|
||||||
|
channelClient.close(channel).await()
|
||||||
|
successfulTransfers++
|
||||||
|
Timber.i("Card transfer complete for nodeId=${node.id}, card=${cardName.value}, payloadBytes=${cardPayload.size}")
|
||||||
|
} catch (error: Exception) {
|
||||||
|
failedNodes.add("$nodeName: ${error.message ?: "unknown error"}")
|
||||||
|
Timber.e(error, "Card transfer failed for nodeId=${node.id}, displayName=${node.displayName}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (successfulTransfers == 0) {
|
||||||
|
importStatus.value = "Transfer failed"
|
||||||
|
importPercent.value = 0
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
val reason = failedNodes.firstOrNull() ?: "No watch accepted the transfer"
|
||||||
|
Toast.makeText(this@CompanionImportCardActivity, "Card transfer failed: $reason", Toast.LENGTH_LONG).show()
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
importStatus.value = "Transfer complete"
|
||||||
|
importPercent.value = 100
|
||||||
|
if (failedNodes.isNotEmpty()) {
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
Toast.makeText(
|
||||||
|
this@CompanionImportCardActivity,
|
||||||
|
"Sent to $successfulTransfers watch(es); ${failedNodes.size} failed.",
|
||||||
|
Toast.LENGTH_LONG,
|
||||||
|
).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Timber.i("Companion-style card import sent to watch for ${cardName.value}; success=$successfulTransfers failures=${failedNodes.size}")
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@ -0,0 +1,162 @@
|
|||||||
|
package com.github.nacabaro.vbhelper.companion.card
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
|
import android.nfc.NfcAdapter
|
||||||
|
import android.nfc.Tag
|
||||||
|
import android.nfc.tech.MifareUltralight
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
|
import android.provider.Settings
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.activity.compose.setContent
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import com.github.nacabaro.vbhelper.R
|
||||||
|
import com.github.nacabaro.vbhelper.companion.ui.CompanionLoading
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import timber.log.Timber
|
||||||
|
|
||||||
|
class CompanionValidateCardActivity : ComponentActivity(), NfcAdapter.ReaderCallback {
|
||||||
|
companion object {
|
||||||
|
const val CARD_VALIDATED_KEY = "CARD_VALIDATED"
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class CardValidationState {
|
||||||
|
WaitingForVBConnect,
|
||||||
|
ValidateCardOnVB,
|
||||||
|
Success,
|
||||||
|
}
|
||||||
|
|
||||||
|
private lateinit var nfcAdapter: NfcAdapter
|
||||||
|
private val validationStateFlow = MutableStateFlow(CardValidationState.WaitingForVBConnect)
|
||||||
|
private var cardIdToValidate: UShort = 0u
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
val maybeNfcAdapter = NfcAdapter.getDefaultAdapter(this)
|
||||||
|
val inputCardId = intent.getIntExtra(CARD_VALIDATED_KEY, -1)
|
||||||
|
if (inputCardId == -1) {
|
||||||
|
Timber.e("CompanionValidateCardActivity called without card number!")
|
||||||
|
finish()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cardIdToValidate = inputCardId.toUShort()
|
||||||
|
if (maybeNfcAdapter == null) {
|
||||||
|
Toast.makeText(this, getString(R.string.scan_no_nfc_on_device), Toast.LENGTH_SHORT).show()
|
||||||
|
finish()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
nfcAdapter = maybeNfcAdapter
|
||||||
|
setContent {
|
||||||
|
DisplayContent()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun DisplayContent() {
|
||||||
|
val validationState by validationStateFlow.collectAsState()
|
||||||
|
when (validationState) {
|
||||||
|
CardValidationState.WaitingForVBConnect -> {
|
||||||
|
CompanionLoading(loadingText = stringResource(R.string.companion_validation_connect_vb))
|
||||||
|
}
|
||||||
|
CardValidationState.ValidateCardOnVB -> {
|
||||||
|
CompanionLoading(loadingText = stringResource(R.string.companion_validation_insert_card))
|
||||||
|
}
|
||||||
|
CardValidationState.Success -> {
|
||||||
|
LaunchedEffect(true) {
|
||||||
|
Handler(Looper.getMainLooper()).postDelayed({
|
||||||
|
val intent = Intent().apply {
|
||||||
|
putExtra(CARD_VALIDATED_KEY, cardIdToValidate.toInt())
|
||||||
|
}
|
||||||
|
setResult(RESULT_OK, intent)
|
||||||
|
finish()
|
||||||
|
}, 1000)
|
||||||
|
}
|
||||||
|
Text(text = stringResource(R.string.companion_validation_success))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onResume() {
|
||||||
|
super.onResume()
|
||||||
|
if (!nfcAdapter.isEnabled) {
|
||||||
|
showWirelessSettings()
|
||||||
|
} else {
|
||||||
|
nfcAdapter.enableReaderMode(this, this, NfcAdapter.FLAG_READER_NFC_A, Bundle())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPause() {
|
||||||
|
super.onPause()
|
||||||
|
if (this::nfcAdapter.isInitialized) {
|
||||||
|
nfcAdapter.disableReaderMode(this)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showWirelessSettings() {
|
||||||
|
Toast.makeText(this, getString(R.string.scan_nfc_must_be_enabled), Toast.LENGTH_SHORT).show()
|
||||||
|
startActivity(Intent(Settings.ACTION_WIRELESS_SETTINGS))
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onTagDiscovered(tag: Tag?) {
|
||||||
|
if (tag == null) {
|
||||||
|
Timber.w("Tag discovery returned null tag")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
when (validationStateFlow.value) {
|
||||||
|
CardValidationState.WaitingForVBConnect -> {
|
||||||
|
val nfcData = MifareUltralight.get(tag)
|
||||||
|
if (nfcData == null) {
|
||||||
|
Timber.w("Unsupported NFC tech for validation")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val writeSuccessful = runCatching {
|
||||||
|
nfcData.connect()
|
||||||
|
nfcData.use {
|
||||||
|
val vbData = VBNfcData(nfcData)
|
||||||
|
vbData.writeCardCheck(nfcData, cardIdToValidate)
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}.onFailure {
|
||||||
|
Timber.e(it, "Failed during first validation NFC step")
|
||||||
|
runOnUiThread {
|
||||||
|
Toast.makeText(
|
||||||
|
this,
|
||||||
|
"Failed to validate card on bracelet. Please retry.",
|
||||||
|
Toast.LENGTH_SHORT,
|
||||||
|
).show()
|
||||||
|
}
|
||||||
|
}.getOrDefault(false)
|
||||||
|
if (writeSuccessful) {
|
||||||
|
validationStateFlow.value = CardValidationState.ValidateCardOnVB
|
||||||
|
}
|
||||||
|
}
|
||||||
|
CardValidationState.ValidateCardOnVB -> {
|
||||||
|
val nfcData = MifareUltralight.get(tag)
|
||||||
|
if (nfcData == null) {
|
||||||
|
Timber.w("Unsupported NFC tech for validation")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
runCatching {
|
||||||
|
nfcData.connect()
|
||||||
|
nfcData.use {
|
||||||
|
val vbData = VBNfcData(nfcData)
|
||||||
|
if (vbData.wasCardIdValidated(cardIdToValidate)) {
|
||||||
|
validationStateFlow.value = CardValidationState.Success
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.onFailure {
|
||||||
|
Timber.e(it, "Failed during second validation NFC step")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> Timber.w("Incorrect state")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@ -0,0 +1,48 @@
|
|||||||
|
package com.github.nacabaro.vbhelper.companion.card
|
||||||
|
|
||||||
|
import android.nfc.tech.MifareUltralight
|
||||||
|
import kotlin.experimental.and
|
||||||
|
import kotlin.experimental.or
|
||||||
|
|
||||||
|
class VBNfcData(nfcData: MifareUltralight) {
|
||||||
|
companion object {
|
||||||
|
private const val ITEM_ID_BE: UShort = 4u
|
||||||
|
private const val STATUS_READY_FLAG: Byte = 0b00000001
|
||||||
|
private const val STATUS_DIM_READY_FLAG: Byte = 0b00000010
|
||||||
|
private val STATUS_DIM_IS_READY = STATUS_READY_FLAG or STATUS_DIM_READY_FLAG
|
||||||
|
private const val OPERATION_READY: Byte = 1
|
||||||
|
private const val OPERATION_CHECK_DIM: Byte = 3
|
||||||
|
}
|
||||||
|
|
||||||
|
val itemId: UShort
|
||||||
|
val status: Byte
|
||||||
|
val operation: Byte
|
||||||
|
val dimId: UShort
|
||||||
|
|
||||||
|
init {
|
||||||
|
val readData = nfcData.transceive(byteArrayOf(0x30, 0x04))
|
||||||
|
itemId = readData.getUInt16(4, Endian.Big)
|
||||||
|
status = readData[8]
|
||||||
|
if (itemId == ITEM_ID_BE) {
|
||||||
|
operation = readData[9]
|
||||||
|
dimId = readData.getUInt16(10, Endian.Big)
|
||||||
|
} else {
|
||||||
|
dimId = readData[9].toUShort()
|
||||||
|
operation = readData[10]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun writeCardCheck(nfcData: MifareUltralight, dimId: UShort) {
|
||||||
|
if (itemId == ITEM_ID_BE) {
|
||||||
|
val dimData = dimId.toByteArray(endian = Endian.Big)
|
||||||
|
nfcData.writePage(6, byteArrayOf(STATUS_READY_FLAG, OPERATION_CHECK_DIM, dimData[0], dimData[1]))
|
||||||
|
} else {
|
||||||
|
nfcData.writePage(6, byteArrayOf(STATUS_READY_FLAG, dimId.toByte(), OPERATION_CHECK_DIM, 0))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun wasCardIdValidated(dimId: UShort): Boolean {
|
||||||
|
return dimId == this.dimId && operation == OPERATION_READY && (status and STATUS_DIM_IS_READY == STATUS_DIM_IS_READY)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@ -0,0 +1,10 @@
|
|||||||
|
package com.github.nacabaro.vbhelper.companion.common
|
||||||
|
|
||||||
|
object ChannelTypes {
|
||||||
|
const val CARD_DATA = "com.github.cfogrady.vitalwear/CARD_DATA"
|
||||||
|
const val FIRMWARE_DATA = "com.github.cfogrady.vitalwear/FIRMWARE_DATA"
|
||||||
|
const val CHARACTER_DATA = "com.github.cfogrady.vitalwear/CHARACTER_DATA"
|
||||||
|
const val SEND_LOGS_REQUEST = "SEND_LOGS_REQUEST"
|
||||||
|
const val LOGS_DATA = "LOG_DATA"
|
||||||
|
}
|
||||||
|
|
||||||
@ -0,0 +1,140 @@
|
|||||||
|
package com.github.nacabaro.vbhelper.companion.firmware
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.activity.compose.setContent
|
||||||
|
import androidx.activity.result.ActivityResultLauncher
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
import com.github.nacabaro.vbhelper.R
|
||||||
|
import com.github.nacabaro.vbhelper.companion.common.ChannelTypes
|
||||||
|
import com.github.nacabaro.vbhelper.companion.ui.CompanionLoading
|
||||||
|
import com.google.android.gms.wearable.Wearable
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.tasks.await
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import timber.log.Timber
|
||||||
|
import java.io.DataOutputStream
|
||||||
|
|
||||||
|
class CompanionFirmwareImportActivity : ComponentActivity() {
|
||||||
|
enum class FirmwareImportState {
|
||||||
|
PickFirmware,
|
||||||
|
LoadFirmware,
|
||||||
|
}
|
||||||
|
|
||||||
|
private val importState = MutableStateFlow(FirmwareImportState.PickFirmware)
|
||||||
|
private val transferPercent = MutableStateFlow(0)
|
||||||
|
private val transferStatus = MutableStateFlow("Preparing transfer")
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
val firmwareImportActivity = buildFirmwareFilePickLauncher()
|
||||||
|
setContent {
|
||||||
|
BuildScreen(firmwareImportActivity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun BuildScreen(firmwareImportActivity: ActivityResultLauncher<Array<String>>) {
|
||||||
|
val state by importState.collectAsState()
|
||||||
|
val percent by transferPercent.collectAsState()
|
||||||
|
val status by transferStatus.collectAsState()
|
||||||
|
if (state == FirmwareImportState.PickFirmware) {
|
||||||
|
LaunchedEffect(true) {
|
||||||
|
firmwareImportActivity.launch(arrayOf("*/*"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val loadingText = if (state == FirmwareImportState.PickFirmware) {
|
||||||
|
getString(R.string.companion_loading_select_firmware)
|
||||||
|
} else {
|
||||||
|
"$status ($percent%)"
|
||||||
|
}
|
||||||
|
CompanionLoading(loadingText = loadingText)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildFirmwareFilePickLauncher(): ActivityResultLauncher<Array<String>> {
|
||||||
|
return registerForActivityResult(ActivityResultContracts.OpenDocument()) {
|
||||||
|
importState.value = FirmwareImportState.LoadFirmware
|
||||||
|
if (it == null) {
|
||||||
|
finish()
|
||||||
|
} else {
|
||||||
|
Timber.i("Path: ${it.path}")
|
||||||
|
importFirmware(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun importFirmware(uri: Uri) {
|
||||||
|
transferPercent.value = 0
|
||||||
|
transferStatus.value = "Reading firmware file"
|
||||||
|
val channelClient = Wearable.getChannelClient(this)
|
||||||
|
val nodeListTask = Wearable.getNodeClient(this).connectedNodes
|
||||||
|
lifecycleScope.launch(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
val nodes = nodeListTask.await()
|
||||||
|
if (nodes.isEmpty()) {
|
||||||
|
transferStatus.value = getString(R.string.companion_firmware_no_watch)
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
Toast.makeText(this@CompanionFirmwareImportActivity, getString(R.string.companion_firmware_no_watch), Toast.LENGTH_LONG).show()
|
||||||
|
}
|
||||||
|
importState.value = FirmwareImportState.PickFirmware
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
|
||||||
|
val firmware = contentResolver.openInputStream(uri)?.use { input ->
|
||||||
|
input.readBytes()
|
||||||
|
} ?: throw IllegalStateException("Unable to read selected firmware file")
|
||||||
|
|
||||||
|
transferPercent.value = 5
|
||||||
|
transferStatus.value = "Sending firmware"
|
||||||
|
val totalBytes = (firmware.size + 4).toLong() * nodes.size
|
||||||
|
var transferredBytes = 0L
|
||||||
|
for (node in nodes) {
|
||||||
|
val channel = channelClient.openChannel(node.id, ChannelTypes.FIRMWARE_DATA).await()
|
||||||
|
try {
|
||||||
|
channelClient.getOutputStream(channel).await().use {
|
||||||
|
val output = DataOutputStream(it)
|
||||||
|
output.writeInt(firmware.size)
|
||||||
|
transferredBytes += 4
|
||||||
|
transferPercent.value = if (totalBytes == 0L) 0 else ((transferredBytes * 100) / totalBytes).toInt().coerceIn(0, 100)
|
||||||
|
val buffer = ByteArray(4096)
|
||||||
|
var bytesRead: Int
|
||||||
|
firmware.inputStream().use { firmwareInput ->
|
||||||
|
while (firmwareInput.read(buffer).also { bytesRead = it } >= 0) {
|
||||||
|
output.write(buffer, 0, bytesRead)
|
||||||
|
transferredBytes += bytesRead
|
||||||
|
transferPercent.value = if (totalBytes == 0L) 0 else ((transferredBytes * 100) / totalBytes).toInt().coerceIn(0, 100)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
output.flush()
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
runCatching { channelClient.close(channel).await() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
transferStatus.value = "Transfer complete"
|
||||||
|
transferPercent.value = 100
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
Toast.makeText(this@CompanionFirmwareImportActivity, getString(R.string.companion_firmware_sent_success), Toast.LENGTH_SHORT).show()
|
||||||
|
finish()
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Timber.e(e, "Failed to send firmware to watch")
|
||||||
|
transferStatus.value = "Transfer failed: ${e.message ?: "unknown"}"
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
Toast.makeText(this@CompanionFirmwareImportActivity, "Firmware transfer failed: ${e.message ?: "unknown error"}", Toast.LENGTH_LONG).show()
|
||||||
|
}
|
||||||
|
importState.value = FirmwareImportState.PickFirmware
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@ -0,0 +1,77 @@
|
|||||||
|
package com.github.nacabaro.vbhelper.companion.logging
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.content.Context
|
||||||
|
import android.util.Log
|
||||||
|
import org.tinylog.Logger
|
||||||
|
import org.tinylog.configuration.Configuration
|
||||||
|
import org.tinylog.provider.ProviderRegistry
|
||||||
|
import timber.log.Timber
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
@SuppressLint("LogNotTimber")
|
||||||
|
class TinyLogTree(context: Context) : Timber.DebugTree() {
|
||||||
|
companion object {
|
||||||
|
private val logFileRegexp = Regex("""log_(\d{4}-\d{2}-\d{2})_(\d+)\.txt""")
|
||||||
|
private const val START_DATE = "0000-00-00"
|
||||||
|
|
||||||
|
fun shutdown() {
|
||||||
|
ProviderRegistry.getLoggingProvider().shutdown()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setupRollingLogs(logsDir: String) {
|
||||||
|
Configuration.set("writer", "rolling file")
|
||||||
|
Configuration.set("writer.level", "info")
|
||||||
|
Configuration.set("writer.backups", "3")
|
||||||
|
Configuration.set("writer.format", "{date: yyyy-MM-dd HH:mm:ss.SSS}{pipe}{tag}{pipe}{level}{pipe}{message}")
|
||||||
|
Configuration.set("writer.file", "$logsDir/log_{date:yyyy-MM-dd}_{count}.txt")
|
||||||
|
Configuration.set("writer.policies", "daily, size: 10mb")
|
||||||
|
Configuration.set("writer.buffered", "true")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getLogsDir(context: Context): File = File(context.filesDir, "logs")
|
||||||
|
|
||||||
|
fun getMostRecentLogFile(context: Context): File? {
|
||||||
|
val logsDir = getLogsDir(context)
|
||||||
|
val files = logsDir.listFiles() ?: return null
|
||||||
|
return getMostRecentLogFileFromFiles(files)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun getMostRecentLogFileFromFiles(files: Array<out File>): File? {
|
||||||
|
var date = START_DATE
|
||||||
|
var count = 0
|
||||||
|
var mostRecentFile: File? = null
|
||||||
|
for (child in files) {
|
||||||
|
val matchResult = logFileRegexp.find(child.name) ?: continue
|
||||||
|
val childDate = matchResult.groups[1]?.value ?: START_DATE
|
||||||
|
val childCount = matchResult.groups[2]?.value?.toInt() ?: 0
|
||||||
|
if (childDate > date || (childDate == date && childCount > count)) {
|
||||||
|
mostRecentFile = child
|
||||||
|
date = childDate
|
||||||
|
count = childCount
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Log.i("TinyLogTree", "Most Recent Log: $mostRecentFile")
|
||||||
|
return mostRecentFile
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
init {
|
||||||
|
val logsDir = getLogsDir(context).absolutePath
|
||||||
|
setupRollingLogs(logsDir)
|
||||||
|
Configuration.set("writingthread", "true")
|
||||||
|
Log.i("TinyLogTree", "Log Dir: $logsDir")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun log(priority: Int, tag: String?, message: String, t: Throwable?) {
|
||||||
|
val taggedLogger = Logger.tag(tag)
|
||||||
|
when (priority) {
|
||||||
|
Log.VERBOSE, Log.DEBUG -> taggedLogger.debug { message }
|
||||||
|
Log.INFO -> taggedLogger.info { message }
|
||||||
|
Log.WARN -> taggedLogger.warn { message }
|
||||||
|
Log.ERROR, Log.ASSERT -> taggedLogger.error { message }
|
||||||
|
else -> taggedLogger.error { "Invalid priority $priority For Message! $message" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@ -0,0 +1,122 @@
|
|||||||
|
package com.github.nacabaro.vbhelper.companion.logs
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.app.Activity
|
||||||
|
import android.content.ClipData
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
|
import android.util.Log
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.core.content.FileProvider
|
||||||
|
import com.github.nacabaro.vbhelper.R
|
||||||
|
import com.github.nacabaro.vbhelper.companion.common.ChannelTypes
|
||||||
|
import com.google.android.gms.wearable.CapabilityClient
|
||||||
|
import com.google.android.gms.wearable.ChannelClient
|
||||||
|
import com.google.android.gms.wearable.ChannelClient.ChannelCallback
|
||||||
|
import com.google.android.gms.wearable.Wearable
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.tasks.await
|
||||||
|
import timber.log.Timber
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
class CompanionLogService {
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "CompanionLogService"
|
||||||
|
}
|
||||||
|
|
||||||
|
var initializedActivity: Activity? = null
|
||||||
|
|
||||||
|
fun sendLogFile(context: Context, file: File) {
|
||||||
|
initializedActivity?.let {
|
||||||
|
sendLogFile(context, file, it)
|
||||||
|
initializedActivity?.finish()
|
||||||
|
}
|
||||||
|
initializedActivity = null
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressLint("LogNotTimber")
|
||||||
|
fun sendLogFile(context: Context, file: File, activity: Activity) {
|
||||||
|
val emailIntent = Intent(Intent.ACTION_SENDTO).apply {
|
||||||
|
data = Uri.parse("mailto:")
|
||||||
|
putExtra(Intent.EXTRA_EMAIL, arrayOf("vitalwear-developers@googlegroups.com", "taveonnelsonn@gmail.com"))
|
||||||
|
putExtra(Intent.EXTRA_SUBJECT, "VBHelper Log")
|
||||||
|
putExtra(Intent.EXTRA_TEXT, "Please take a look at these logs to help diagnose the below issue:\n")
|
||||||
|
}
|
||||||
|
val fileUri = FileProvider.getUriForFile(context, "${context.packageName}.provider", file)
|
||||||
|
emailIntent.putExtra(Intent.EXTRA_STREAM, fileUri)
|
||||||
|
emailIntent.clipData = ClipData.newRawUri("", fileUri)
|
||||||
|
emailIntent.flags = Intent.FLAG_GRANT_READ_URI_PERMISSION
|
||||||
|
Log.i(TAG, "Starting activity to send email")
|
||||||
|
activity.startActivity(Intent.createChooser(emailIntent, "Select Email Client"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun receiveFile(context: Context, channel: ChannelClient.Channel) {
|
||||||
|
val channelClient = Wearable.getChannelClient(context)
|
||||||
|
val logs = File(context.filesDir, "logs")
|
||||||
|
if (!logs.exists()) {
|
||||||
|
logs.mkdirs()
|
||||||
|
}
|
||||||
|
val file = File(logs, "watch_log.txt")
|
||||||
|
channelClient.registerChannelCallback(channel, object : ChannelCallback() {
|
||||||
|
override fun onInputClosed(channel: ChannelClient.Channel, closeReason: Int, appErrorCode: Int) {
|
||||||
|
super.onInputClosed(channel, closeReason, appErrorCode)
|
||||||
|
if (closeReason != ChannelCallback.CLOSE_REASON_NORMAL) {
|
||||||
|
Timber.w("Failed to receive file. Close Reason: ${closeReasonString(closeReason)}")
|
||||||
|
} else {
|
||||||
|
Timber.i("Successfully downloaded log file. Input closed.")
|
||||||
|
sendLogFile(context, file)
|
||||||
|
}
|
||||||
|
channelClient.close(channel)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
channelClient.receiveFile(channel, Uri.fromFile(file), false).apply {
|
||||||
|
addOnFailureListener {
|
||||||
|
Toast.makeText(context, context.getString(R.string.companion_logs_receive_failed), Toast.LENGTH_SHORT).show()
|
||||||
|
initializedActivity?.finish()
|
||||||
|
initializedActivity = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun fetchWatchLogs(callingActivity: Activity) {
|
||||||
|
val messageClient = Wearable.getMessageClient(callingActivity)
|
||||||
|
val capabilityInfoTask = Wearable.getCapabilityClient(callingActivity)
|
||||||
|
.getCapability(ChannelTypes.SEND_LOGS_REQUEST, CapabilityClient.FILTER_REACHABLE)
|
||||||
|
CoroutineScope(Dispatchers.IO).launch {
|
||||||
|
val capabilityInfo = capabilityInfoTask.await()
|
||||||
|
if (capabilityInfo.nodes.isEmpty()) {
|
||||||
|
CoroutineScope(Dispatchers.Main).launch {
|
||||||
|
Toast.makeText(callingActivity, callingActivity.getString(R.string.companion_firmware_no_watch), Toast.LENGTH_SHORT).show()
|
||||||
|
callingActivity.finish()
|
||||||
|
}
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
for (node in capabilityInfo.nodes) {
|
||||||
|
messageClient.sendMessage(node.id, ChannelTypes.SEND_LOGS_REQUEST, null).apply {
|
||||||
|
addOnFailureListener {
|
||||||
|
Toast.makeText(callingActivity, callingActivity.getString(R.string.companion_logs_failed_request), Toast.LENGTH_SHORT).show()
|
||||||
|
callingActivity.finish()
|
||||||
|
}
|
||||||
|
addOnSuccessListener {
|
||||||
|
initializedActivity = callingActivity
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun closeReasonString(closeReason: Int): String {
|
||||||
|
return when (closeReason) {
|
||||||
|
ChannelCallback.CLOSE_REASON_NORMAL -> "Normal"
|
||||||
|
ChannelCallback.CLOSE_REASON_LOCAL_CLOSE -> "Closed Locally"
|
||||||
|
ChannelCallback.CLOSE_REASON_REMOTE_CLOSE -> "Closed Remotely"
|
||||||
|
ChannelCallback.CLOSE_REASON_DISCONNECTED -> "Disconnected"
|
||||||
|
ChannelCallback.CLOSE_REASON_CONNECTION_TIMEOUT -> "Timeout"
|
||||||
|
else -> "Unknown"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@ -0,0 +1,41 @@
|
|||||||
|
package com.github.nacabaro.vbhelper.companion.logs
|
||||||
|
|
||||||
|
import android.os.Bundle
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.activity.compose.setContent
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import com.github.nacabaro.vbhelper.R
|
||||||
|
import com.github.nacabaro.vbhelper.companion.ui.CompanionConfirmation
|
||||||
|
import com.github.nacabaro.vbhelper.companion.ui.CompanionLoading
|
||||||
|
import com.github.nacabaro.vbhelper.di.VBHelper
|
||||||
|
|
||||||
|
class CompanionWatchLogsActivity : ComponentActivity() {
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
val activity = this
|
||||||
|
setContent {
|
||||||
|
var accepted by remember { mutableStateOf(false) }
|
||||||
|
if (!accepted) {
|
||||||
|
CompanionConfirmation(prompt = stringResource(R.string.companion_logs_warning)) {
|
||||||
|
if (it) {
|
||||||
|
accepted = true
|
||||||
|
} else {
|
||||||
|
finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
CompanionLoading(loadingText = stringResource(R.string.settings_companion_send_watch_logs_title))
|
||||||
|
LaunchedEffect(true) {
|
||||||
|
(application as VBHelper).companionLogService.fetchWatchLogs(activity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -0,0 +1,21 @@
|
|||||||
|
package com.github.nacabaro.vbhelper.companion.logs
|
||||||
|
|
||||||
|
import com.github.nacabaro.vbhelper.companion.common.ChannelTypes
|
||||||
|
import com.github.nacabaro.vbhelper.di.VBHelper
|
||||||
|
import com.google.android.gms.wearable.ChannelClient
|
||||||
|
import com.google.android.gms.wearable.WearableListenerService
|
||||||
|
import timber.log.Timber
|
||||||
|
|
||||||
|
class WatchCommunicationService : WearableListenerService() {
|
||||||
|
override fun onChannelOpened(channel: ChannelClient.Channel) {
|
||||||
|
super.onChannelOpened(channel)
|
||||||
|
when (channel.path) {
|
||||||
|
ChannelTypes.LOGS_DATA -> {
|
||||||
|
val logService = (application as VBHelper).companionLogService
|
||||||
|
logService.receiveFile(this, channel)
|
||||||
|
}
|
||||||
|
else -> Timber.i("Unknown channel: ${channel.path}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@ -0,0 +1,23 @@
|
|||||||
|
package com.github.nacabaro.vbhelper.companion.transfer
|
||||||
|
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import com.github.nacabaro.vbhelper.R
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compatibility endpoint kept for legacy links/intents from older companion flows.
|
||||||
|
* Character transfer remains watch-driven and is not supported from phone companion UI.
|
||||||
|
*/
|
||||||
|
class CompanionCharacterImportActivity : ComponentActivity() {
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
Toast.makeText(
|
||||||
|
this,
|
||||||
|
getString(R.string.companion_transfer_disabled_message),
|
||||||
|
Toast.LENGTH_LONG,
|
||||||
|
).show()
|
||||||
|
finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@ -0,0 +1,42 @@
|
|||||||
|
package com.github.nacabaro.vbhelper.companion.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun CompanionConfirmation(
|
||||||
|
prompt: String,
|
||||||
|
onResult: (Boolean) -> Unit,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(24.dp),
|
||||||
|
verticalArrangement = Arrangement.Center,
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
Text(text = prompt, modifier = Modifier.padding(bottom = 24.dp))
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceEvenly,
|
||||||
|
) {
|
||||||
|
Button(onClick = { onResult(true) }) {
|
||||||
|
Text(text = "Ok")
|
||||||
|
}
|
||||||
|
Button(onClick = { onResult(false) }) {
|
||||||
|
Text(text = "Cancel")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@ -0,0 +1,32 @@
|
|||||||
|
package com.github.nacabaro.vbhelper.companion.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun CompanionLoading(
|
||||||
|
loadingText: String,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(24.dp),
|
||||||
|
verticalArrangement = Arrangement.Center,
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator()
|
||||||
|
Text(
|
||||||
|
text = loadingText,
|
||||||
|
modifier = Modifier.padding(top = 16.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@ -0,0 +1,14 @@
|
|||||||
|
package com.github.nacabaro.vbhelper.companion.validation
|
||||||
|
|
||||||
|
import androidx.room.Database
|
||||||
|
import androidx.room.RoomDatabase
|
||||||
|
|
||||||
|
@Database(
|
||||||
|
entities = [ValidatedCardEntity::class],
|
||||||
|
version = 1,
|
||||||
|
exportSchema = false,
|
||||||
|
)
|
||||||
|
abstract class CompanionValidatedDatabase : RoomDatabase() {
|
||||||
|
abstract fun validatedCardDao(): ValidatedCardDao
|
||||||
|
}
|
||||||
|
|
||||||
@ -0,0 +1,16 @@
|
|||||||
|
package com.github.nacabaro.vbhelper.companion.validation
|
||||||
|
|
||||||
|
import androidx.room.Dao
|
||||||
|
import androidx.room.Insert
|
||||||
|
import androidx.room.OnConflictStrategy
|
||||||
|
import androidx.room.Query
|
||||||
|
|
||||||
|
@Dao
|
||||||
|
interface ValidatedCardDao {
|
||||||
|
@Query("select cardId from ${ValidatedCardEntity.TABLE}")
|
||||||
|
suspend fun getIds(): List<Int>
|
||||||
|
|
||||||
|
@Insert(onConflict = OnConflictStrategy.IGNORE)
|
||||||
|
suspend fun insert(validatedCardEntity: ValidatedCardEntity)
|
||||||
|
}
|
||||||
|
|
||||||
@ -0,0 +1,13 @@
|
|||||||
|
package com.github.nacabaro.vbhelper.companion.validation
|
||||||
|
|
||||||
|
import androidx.room.Entity
|
||||||
|
|
||||||
|
@Entity(tableName = ValidatedCardEntity.TABLE, primaryKeys = ["cardId"])
|
||||||
|
data class ValidatedCardEntity(
|
||||||
|
val cardId: Int,
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
const val TABLE = "validated"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@ -0,0 +1,35 @@
|
|||||||
|
package com.github.nacabaro.vbhelper.companion.validation
|
||||||
|
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
|
||||||
|
class ValidatedCardManager(
|
||||||
|
private val validatedCardDao: ValidatedCardDao,
|
||||||
|
) {
|
||||||
|
private val mutex = Mutex()
|
||||||
|
private var validatedIds: Set<Int>? = null
|
||||||
|
|
||||||
|
private suspend fun ensureLoaded(): Set<Int> {
|
||||||
|
validatedIds?.let { return it }
|
||||||
|
return mutex.withLock {
|
||||||
|
validatedIds ?: validatedCardDao.getIds().toSet().also { validatedIds = it }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun addValidatedCard(cardId: Int) {
|
||||||
|
mutex.withLock {
|
||||||
|
val current = validatedIds ?: validatedCardDao.getIds().toSet()
|
||||||
|
if (!current.contains(cardId)) {
|
||||||
|
validatedCardDao.insert(ValidatedCardEntity(cardId))
|
||||||
|
validatedIds = current + cardId
|
||||||
|
} else {
|
||||||
|
validatedIds = current
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun isValidatedCard(cardId: Int): Boolean {
|
||||||
|
return ensureLoaded().contains(cardId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@ -2,6 +2,7 @@ package com.github.nacabaro.vbhelper.daos
|
|||||||
|
|
||||||
import androidx.room.Dao
|
import androidx.room.Dao
|
||||||
import androidx.room.Query
|
import androidx.room.Query
|
||||||
|
import androidx.room.RewriteQueriesToDropUnusedColumns
|
||||||
import com.github.nacabaro.vbhelper.dtos.CharacterDtos
|
import com.github.nacabaro.vbhelper.dtos.CharacterDtos
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
@ -19,6 +20,7 @@ interface AdventureDao {
|
|||||||
""")
|
""")
|
||||||
fun getAdventureCount(): Int
|
fun getAdventureCount(): Int
|
||||||
|
|
||||||
|
@RewriteQueriesToDropUnusedColumns
|
||||||
@Query(
|
@Query(
|
||||||
"""
|
"""
|
||||||
SELECT
|
SELECT
|
||||||
|
|||||||
@ -2,6 +2,7 @@ package com.github.nacabaro.vbhelper.daos
|
|||||||
|
|
||||||
import androidx.room.Dao
|
import androidx.room.Dao
|
||||||
import androidx.room.Query
|
import androidx.room.Query
|
||||||
|
import androidx.room.RewriteQueriesToDropUnusedColumns
|
||||||
import com.github.cfogrady.vbnfc.data.NfcCharacter
|
import com.github.cfogrady.vbnfc.data.NfcCharacter
|
||||||
import com.github.nacabaro.vbhelper.dtos.CharacterDtos
|
import com.github.nacabaro.vbhelper.dtos.CharacterDtos
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
@ -27,12 +28,12 @@ interface CardFusionsDao {
|
|||||||
toCharaId: Int
|
toCharaId: Int
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@RewriteQueriesToDropUnusedColumns
|
||||||
@Query("""
|
@Query("""
|
||||||
SELECT
|
SELECT
|
||||||
cf.toCharaId as charaId,
|
cf.toCharaId as charaId,
|
||||||
cf.fromCharaId as fromCharaId,
|
cf.fromCharaId as fromCharaId,
|
||||||
s.spriteIdle1 as spriteIdle,
|
s.spriteIdle1 as spriteIdle,
|
||||||
cc.attribute as attribute,
|
|
||||||
s.width as spriteWidth,
|
s.width as spriteWidth,
|
||||||
s.height as spriteHeight,
|
s.height as spriteHeight,
|
||||||
d.discoveredOn as discoveredOn,
|
d.discoveredOn as discoveredOn,
|
||||||
|
|||||||
@ -13,9 +13,15 @@ interface CharacterDao {
|
|||||||
@Insert
|
@Insert
|
||||||
suspend fun insertCharacter(vararg characterData: CardCharacter)
|
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")
|
@Query("SELECT * FROM CardCharacter WHERE charaIndex = :monIndex AND cardId = :dimId LIMIT 1")
|
||||||
fun getCharacterByMonIndex(monIndex: Int, dimId: Long): CardCharacter
|
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
|
@Insert
|
||||||
suspend fun insertSprite(vararg sprite: Sprite)
|
suspend fun insertSprite(vararg sprite: Sprite)
|
||||||
|
|
||||||
|
|||||||
@ -11,6 +11,9 @@ interface SpriteDao {
|
|||||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
fun insertSprite(sprite: Sprite): Long
|
fun insertSprite(sprite: Sprite): Long
|
||||||
|
|
||||||
|
@Query("SELECT * FROM Sprite WHERE id = :id LIMIT 1")
|
||||||
|
fun getSpriteById(id: Long): Sprite?
|
||||||
|
|
||||||
@Query("SELECT * FROM Sprite")
|
@Query("SELECT * FROM Sprite")
|
||||||
suspend fun getAllSprites(): List<Sprite>
|
suspend fun getAllSprites(): List<Sprite>
|
||||||
}
|
}
|
||||||
@ -4,6 +4,7 @@ import androidx.room.Dao
|
|||||||
import androidx.room.Insert
|
import androidx.room.Insert
|
||||||
import androidx.room.OnConflictStrategy
|
import androidx.room.OnConflictStrategy
|
||||||
import androidx.room.Query
|
import androidx.room.Query
|
||||||
|
import androidx.room.RewriteQueriesToDropUnusedColumns
|
||||||
import androidx.room.Upsert
|
import androidx.room.Upsert
|
||||||
import com.github.nacabaro.vbhelper.domain.card.CardCharacter
|
import com.github.nacabaro.vbhelper.domain.card.CardCharacter
|
||||||
import com.github.nacabaro.vbhelper.domain.device_data.UserCharacter
|
import com.github.nacabaro.vbhelper.domain.device_data.UserCharacter
|
||||||
@ -38,6 +39,7 @@ interface UserCharacterDao {
|
|||||||
@Upsert
|
@Upsert
|
||||||
fun insertSpecialMissions(vararg specialMissions: SpecialMissions)
|
fun insertSpecialMissions(vararg specialMissions: SpecialMissions)
|
||||||
|
|
||||||
|
@RewriteQueriesToDropUnusedColumns
|
||||||
@Query(
|
@Query(
|
||||||
"""
|
"""
|
||||||
SELECT
|
SELECT
|
||||||
@ -55,6 +57,7 @@ interface UserCharacterDao {
|
|||||||
)
|
)
|
||||||
fun getTransformationHistory(monId: Long): Flow<List<CharacterDtos.TransformationHistory>>
|
fun getTransformationHistory(monId: Long): Flow<List<CharacterDtos.TransformationHistory>>
|
||||||
|
|
||||||
|
@RewriteQueriesToDropUnusedColumns
|
||||||
@Query(
|
@Query(
|
||||||
"""
|
"""
|
||||||
SELECT
|
SELECT
|
||||||
@ -71,6 +74,7 @@ interface UserCharacterDao {
|
|||||||
)
|
)
|
||||||
suspend fun getTransformationHistoryForExport(monId: Long): List<CharacterDtos.TransformationHistoryExport>
|
suspend fun getTransformationHistoryForExport(monId: Long): List<CharacterDtos.TransformationHistoryExport>
|
||||||
|
|
||||||
|
@RewriteQueriesToDropUnusedColumns
|
||||||
@Query(
|
@Query(
|
||||||
"""
|
"""
|
||||||
SELECT
|
SELECT
|
||||||
@ -96,6 +100,7 @@ interface UserCharacterDao {
|
|||||||
)
|
)
|
||||||
fun getAllCharacters(): Flow<List<CharacterDtos.CharacterWithSprites>>
|
fun getAllCharacters(): Flow<List<CharacterDtos.CharacterWithSprites>>
|
||||||
|
|
||||||
|
@RewriteQueriesToDropUnusedColumns
|
||||||
@Query(
|
@Query(
|
||||||
"""
|
"""
|
||||||
SELECT
|
SELECT
|
||||||
@ -137,9 +142,13 @@ interface UserCharacterDao {
|
|||||||
@Query("SELECT * FROM VBCharacterData WHERE id = :id")
|
@Query("SELECT * FROM VBCharacterData WHERE id = :id")
|
||||||
suspend fun getVbDataOrNull(id: Long): VBCharacterData?
|
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")
|
@Query("SELECT * FROM SpecialMissions WHERE characterId = :id")
|
||||||
fun getSpecialMissions(id: Long): Flow<List<SpecialMissions>>
|
fun getSpecialMissions(id: Long): Flow<List<SpecialMissions>>
|
||||||
|
|
||||||
|
@RewriteQueriesToDropUnusedColumns
|
||||||
@Query(
|
@Query(
|
||||||
"""
|
"""
|
||||||
SELECT
|
SELECT
|
||||||
@ -205,6 +214,7 @@ interface UserCharacterDao {
|
|||||||
@Query("""SELECT * FROM VitalsHistory WHERE charId = :charId ORDER BY id ASC""")
|
@Query("""SELECT * FROM VitalsHistory WHERE charId = :charId ORDER BY id ASC""")
|
||||||
suspend fun getVitalsHistory(charId: Long): List<VitalsHistory>
|
suspend fun getVitalsHistory(charId: Long): List<VitalsHistory>
|
||||||
|
|
||||||
|
@RewriteQueriesToDropUnusedColumns
|
||||||
@Query(
|
@Query(
|
||||||
"""
|
"""
|
||||||
SELECT
|
SELECT
|
||||||
@ -231,6 +241,7 @@ interface UserCharacterDao {
|
|||||||
)
|
)
|
||||||
suspend fun getBECharacters(): List<CharacterDtos.CharacterWithSprites>
|
suspend fun getBECharacters(): List<CharacterDtos.CharacterWithSprites>
|
||||||
|
|
||||||
|
@RewriteQueriesToDropUnusedColumns
|
||||||
@Query(
|
@Query(
|
||||||
"""
|
"""
|
||||||
SELECT
|
SELECT
|
||||||
|
|||||||
@ -7,7 +7,6 @@ import androidx.sqlite.db.SupportSQLiteDatabase
|
|||||||
import com.github.nacabaro.vbhelper.daos.AdventureDao
|
import com.github.nacabaro.vbhelper.daos.AdventureDao
|
||||||
import com.github.nacabaro.vbhelper.daos.CardAdventureDao
|
import com.github.nacabaro.vbhelper.daos.CardAdventureDao
|
||||||
import com.github.nacabaro.vbhelper.daos.CharacterDao
|
import com.github.nacabaro.vbhelper.daos.CharacterDao
|
||||||
import com.github.nacabaro.vbhelper.daos.CharacterTransferPolicyDao
|
|
||||||
import com.github.nacabaro.vbhelper.daos.DexDao
|
import com.github.nacabaro.vbhelper.daos.DexDao
|
||||||
import com.github.nacabaro.vbhelper.daos.CardDao
|
import com.github.nacabaro.vbhelper.daos.CardDao
|
||||||
import com.github.nacabaro.vbhelper.daos.CardFusionsDao
|
import com.github.nacabaro.vbhelper.daos.CardFusionsDao
|
||||||
@ -28,7 +27,6 @@ import com.github.nacabaro.vbhelper.domain.characters.Sprite
|
|||||||
import com.github.nacabaro.vbhelper.domain.characters.Adventure
|
import com.github.nacabaro.vbhelper.domain.characters.Adventure
|
||||||
import com.github.nacabaro.vbhelper.domain.characters.Dex
|
import com.github.nacabaro.vbhelper.domain.characters.Dex
|
||||||
import com.github.nacabaro.vbhelper.domain.device_data.BECharacterData
|
import com.github.nacabaro.vbhelper.domain.device_data.BECharacterData
|
||||||
import com.github.nacabaro.vbhelper.domain.device_data.CharacterTransferPolicy
|
|
||||||
import com.github.nacabaro.vbhelper.domain.device_data.SpecialMissions
|
import com.github.nacabaro.vbhelper.domain.device_data.SpecialMissions
|
||||||
import com.github.nacabaro.vbhelper.domain.device_data.TransformationHistory
|
import com.github.nacabaro.vbhelper.domain.device_data.TransformationHistory
|
||||||
import com.github.nacabaro.vbhelper.domain.device_data.UserCharacter
|
import com.github.nacabaro.vbhelper.domain.device_data.UserCharacter
|
||||||
@ -39,6 +37,7 @@ import com.github.nacabaro.vbhelper.domain.items.Items
|
|||||||
|
|
||||||
@Database(
|
@Database(
|
||||||
version = 6,
|
version = 6,
|
||||||
|
exportSchema = false,
|
||||||
entities = [
|
entities = [
|
||||||
Card::class,
|
Card::class,
|
||||||
CardProgress::class,
|
CardProgress::class,
|
||||||
@ -52,7 +51,6 @@ import com.github.nacabaro.vbhelper.domain.items.Items
|
|||||||
SpecialMissions::class,
|
SpecialMissions::class,
|
||||||
TransformationHistory::class,
|
TransformationHistory::class,
|
||||||
VitalsHistory::class,
|
VitalsHistory::class,
|
||||||
CharacterTransferPolicy::class,
|
|
||||||
Dex::class,
|
Dex::class,
|
||||||
Items::class,
|
Items::class,
|
||||||
Adventure::class,
|
Adventure::class,
|
||||||
@ -74,7 +72,6 @@ abstract class AppDatabase : RoomDatabase() {
|
|||||||
abstract fun cardAdventureDao(): CardAdventureDao
|
abstract fun cardAdventureDao(): CardAdventureDao
|
||||||
abstract fun cardFusionsDao(): CardFusionsDao
|
abstract fun cardFusionsDao(): CardFusionsDao
|
||||||
abstract fun vitalWearSettingsDao(): VitalWearSettingsDao
|
abstract fun vitalWearSettingsDao(): VitalWearSettingsDao
|
||||||
abstract fun characterTransferPolicyDao(): CharacterTransferPolicyDao
|
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
val MIGRATION_1_2 = object : Migration(1, 2) {
|
val MIGRATION_1_2 = object : Migration(1, 2) {
|
||||||
@ -145,23 +142,19 @@ abstract class AppDatabase : RoomDatabase() {
|
|||||||
|
|
||||||
val MIGRATION_5_6 = object : Migration(5, 6) {
|
val MIGRATION_5_6 = object : Migration(5, 6) {
|
||||||
override fun migrate(db: SupportSQLiteDatabase) {
|
override fun migrate(db: SupportSQLiteDatabase) {
|
||||||
db.execSQL(
|
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`)")
|
||||||
CREATE TABLE IF NOT EXISTS `CharacterTransferPolicy` (
|
db.execSQL("CREATE INDEX IF NOT EXISTS `index_CardAdventure_characterId` ON `CardAdventure` (`characterId`)")
|
||||||
`characterId` INTEGER NOT NULL,
|
db.execSQL("CREATE INDEX IF NOT EXISTS `index_CardFusions_fromCharaId` ON `CardFusions` (`fromCharaId`)")
|
||||||
`nativeDeviceType` TEXT NOT NULL,
|
db.execSQL("CREATE INDEX IF NOT EXISTS `index_CardFusions_toCharaId` ON `CardFusions` (`toCharaId`)")
|
||||||
`preferredHceExportFormat` TEXT NOT NULL,
|
db.execSQL("CREATE INDEX IF NOT EXISTS `index_Background_cardId` ON `Background` (`cardId`)")
|
||||||
`preferredNfcaExportFormat` TEXT NOT NULL,
|
db.execSQL("CREATE INDEX IF NOT EXISTS `index_PossibleTransformations_charaId` ON `PossibleTransformations` (`charaId`)")
|
||||||
`lastObservedImportFormat` TEXT,
|
db.execSQL("CREATE INDEX IF NOT EXISTS `index_PossibleTransformations_toCharaId` ON `PossibleTransformations` (`toCharaId`)")
|
||||||
`lastTransferTransport` TEXT,
|
db.execSQL("CREATE INDEX IF NOT EXISTS `index_UserCharacter_charId` ON `UserCharacter` (`charId`)")
|
||||||
`lastTransferTarget` TEXT,
|
db.execSQL("CREATE INDEX IF NOT EXISTS `index_SpecialMissions_characterId` ON `SpecialMissions` (`characterId`)")
|
||||||
`preserveVbRoundTrip` INTEGER NOT NULL,
|
db.execSQL("CREATE INDEX IF NOT EXISTS `index_TransformationHistory_monId` ON `TransformationHistory` (`monId`)")
|
||||||
`preserveBeRoundTrip` INTEGER NOT NULL,
|
db.execSQL("CREATE INDEX IF NOT EXISTS `index_TransformationHistory_stageId` ON `TransformationHistory` (`stageId`)")
|
||||||
PRIMARY KEY(`characterId`),
|
db.execSQL("CREATE INDEX IF NOT EXISTS `index_VitalsHistory_charId` ON `VitalsHistory` (`charId`)")
|
||||||
FOREIGN KEY(`characterId`) REFERENCES `UserCharacter`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE
|
|
||||||
)
|
|
||||||
""".trimIndent()
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -2,6 +2,7 @@ package com.github.nacabaro.vbhelper.di
|
|||||||
|
|
||||||
import com.github.cfogrady.vitalwear.common.data.SharedTransferSeenDao
|
import com.github.cfogrady.vitalwear.common.data.SharedTransferSeenDao
|
||||||
import com.github.nacabaro.vbhelper.database.AppDatabase
|
import com.github.nacabaro.vbhelper.database.AppDatabase
|
||||||
|
import com.github.nacabaro.vbhelper.source.CardSettingsRepository
|
||||||
import com.github.nacabaro.vbhelper.source.CurrencyRepository
|
import com.github.nacabaro.vbhelper.source.CurrencyRepository
|
||||||
import com.github.nacabaro.vbhelper.source.DataStoreSecretsRepository
|
import com.github.nacabaro.vbhelper.source.DataStoreSecretsRepository
|
||||||
|
|
||||||
@ -10,4 +11,5 @@ interface AppContainer {
|
|||||||
val transferSeenDao: SharedTransferSeenDao
|
val transferSeenDao: SharedTransferSeenDao
|
||||||
val dataStoreSecretsRepository: DataStoreSecretsRepository
|
val dataStoreSecretsRepository: DataStoreSecretsRepository
|
||||||
val currencyRepository: CurrencyRepository
|
val currencyRepository: CurrencyRepository
|
||||||
|
val cardSettingsRepository: CardSettingsRepository
|
||||||
}
|
}
|
||||||
@ -8,6 +8,7 @@ import com.github.cfogrady.vitalwear.common.data.SharedDatabaseFactory
|
|||||||
import com.github.cfogrady.vitalwear.common.data.SharedTransferSeenDao
|
import com.github.cfogrady.vitalwear.common.data.SharedTransferSeenDao
|
||||||
import com.github.nacabaro.vbhelper.di.AppContainer
|
import com.github.nacabaro.vbhelper.di.AppContainer
|
||||||
import com.github.nacabaro.vbhelper.database.AppDatabase
|
import com.github.nacabaro.vbhelper.database.AppDatabase
|
||||||
|
import com.github.nacabaro.vbhelper.source.CardSettingsRepository
|
||||||
import com.github.nacabaro.vbhelper.source.CurrencyRepository
|
import com.github.nacabaro.vbhelper.source.CurrencyRepository
|
||||||
import com.github.nacabaro.vbhelper.source.DataStoreSecretsRepository
|
import com.github.nacabaro.vbhelper.source.DataStoreSecretsRepository
|
||||||
import com.github.nacabaro.vbhelper.source.SecretsSerializer
|
import com.github.nacabaro.vbhelper.source.SecretsSerializer
|
||||||
@ -37,6 +38,7 @@ class DefaultAppContainer(private val context: Context) : AppContainer {
|
|||||||
.addMigrations(AppDatabase.MIGRATION_2_3)
|
.addMigrations(AppDatabase.MIGRATION_2_3)
|
||||||
.addMigrations(AppDatabase.MIGRATION_3_5)
|
.addMigrations(AppDatabase.MIGRATION_3_5)
|
||||||
.addMigrations(AppDatabase.MIGRATION_4_5)
|
.addMigrations(AppDatabase.MIGRATION_4_5)
|
||||||
|
.addMigrations(AppDatabase.MIGRATION_5_6)
|
||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -47,5 +49,7 @@ class DefaultAppContainer(private val context: Context) : AppContainer {
|
|||||||
override val dataStoreSecretsRepository = DataStoreSecretsRepository(context.secretsStore)
|
override val dataStoreSecretsRepository = DataStoreSecretsRepository(context.secretsStore)
|
||||||
|
|
||||||
override val currencyRepository = CurrencyRepository(context.currencyStore)
|
override val currencyRepository = CurrencyRepository(context.currencyStore)
|
||||||
|
|
||||||
|
override val cardSettingsRepository = CardSettingsRepository(context.currencyStore)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -2,12 +2,37 @@ package com.github.nacabaro.vbhelper.di
|
|||||||
|
|
||||||
import DefaultAppContainer
|
import DefaultAppContainer
|
||||||
import android.app.Application
|
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() {
|
class VBHelper : Application() {
|
||||||
lateinit var container: DefaultAppContainer
|
lateinit var container: DefaultAppContainer
|
||||||
|
lateinit var validatedCardDatabase: CompanionValidatedDatabase
|
||||||
|
lateinit var validatedCardManager: ValidatedCardManager
|
||||||
|
val companionLogService = CompanionLogService()
|
||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
container = DefaultAppContainer(applicationContext)
|
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,9 +2,11 @@ package com.github.nacabaro.vbhelper.domain.card
|
|||||||
|
|
||||||
import androidx.room.Entity
|
import androidx.room.Entity
|
||||||
import androidx.room.ForeignKey
|
import androidx.room.ForeignKey
|
||||||
|
import androidx.room.Index
|
||||||
import androidx.room.PrimaryKey
|
import androidx.room.PrimaryKey
|
||||||
|
|
||||||
@Entity(
|
@Entity(
|
||||||
|
indices = [Index(value = ["cardId"])],
|
||||||
foreignKeys = [
|
foreignKeys = [
|
||||||
ForeignKey(
|
ForeignKey(
|
||||||
entity = Card::class,
|
entity = Card::class,
|
||||||
|
|||||||
@ -2,9 +2,14 @@ package com.github.nacabaro.vbhelper.domain.card
|
|||||||
|
|
||||||
import androidx.room.Entity
|
import androidx.room.Entity
|
||||||
import androidx.room.ForeignKey
|
import androidx.room.ForeignKey
|
||||||
|
import androidx.room.Index
|
||||||
import androidx.room.PrimaryKey
|
import androidx.room.PrimaryKey
|
||||||
|
|
||||||
@Entity(
|
@Entity(
|
||||||
|
indices = [
|
||||||
|
Index(value = ["cardId"]),
|
||||||
|
Index(value = ["characterId"])
|
||||||
|
],
|
||||||
foreignKeys = [
|
foreignKeys = [
|
||||||
ForeignKey(
|
ForeignKey(
|
||||||
entity = CardCharacter::class,
|
entity = CardCharacter::class,
|
||||||
|
|||||||
@ -9,7 +9,8 @@ import com.github.nacabaro.vbhelper.domain.characters.Sprite
|
|||||||
|
|
||||||
@Entity(
|
@Entity(
|
||||||
indices = [
|
indices = [
|
||||||
Index(value = ["cardId", "charaIndex"], unique = true)
|
Index(value = ["cardId", "charaIndex"], unique = true),
|
||||||
|
Index(value = ["spriteId"])
|
||||||
],
|
],
|
||||||
foreignKeys = [
|
foreignKeys = [
|
||||||
ForeignKey(
|
ForeignKey(
|
||||||
|
|||||||
@ -2,10 +2,15 @@ package com.github.nacabaro.vbhelper.domain.card
|
|||||||
|
|
||||||
import androidx.room.Entity
|
import androidx.room.Entity
|
||||||
import androidx.room.ForeignKey
|
import androidx.room.ForeignKey
|
||||||
|
import androidx.room.Index
|
||||||
import androidx.room.PrimaryKey
|
import androidx.room.PrimaryKey
|
||||||
import com.github.cfogrady.vbnfc.data.NfcCharacter
|
import com.github.cfogrady.vbnfc.data.NfcCharacter
|
||||||
|
|
||||||
@Entity(
|
@Entity(
|
||||||
|
indices = [
|
||||||
|
Index(value = ["fromCharaId"]),
|
||||||
|
Index(value = ["toCharaId"])
|
||||||
|
],
|
||||||
foreignKeys = [
|
foreignKeys = [
|
||||||
ForeignKey(
|
ForeignKey(
|
||||||
entity = CardCharacter::class,
|
entity = CardCharacter::class,
|
||||||
|
|||||||
@ -2,9 +2,14 @@ package com.github.nacabaro.vbhelper.domain.card
|
|||||||
|
|
||||||
import androidx.room.Entity
|
import androidx.room.Entity
|
||||||
import androidx.room.ForeignKey
|
import androidx.room.ForeignKey
|
||||||
|
import androidx.room.Index
|
||||||
import androidx.room.PrimaryKey
|
import androidx.room.PrimaryKey
|
||||||
|
|
||||||
@Entity(
|
@Entity(
|
||||||
|
indices = [
|
||||||
|
Index(value = ["charaId"]),
|
||||||
|
Index(value = ["toCharaId"])
|
||||||
|
],
|
||||||
foreignKeys = [
|
foreignKeys = [
|
||||||
ForeignKey(
|
ForeignKey(
|
||||||
entity = CardCharacter::class,
|
entity = CardCharacter::class,
|
||||||
|
|||||||
@ -2,10 +2,12 @@ package com.github.nacabaro.vbhelper.domain.device_data
|
|||||||
|
|
||||||
import androidx.room.Entity
|
import androidx.room.Entity
|
||||||
import androidx.room.ForeignKey
|
import androidx.room.ForeignKey
|
||||||
|
import androidx.room.Index
|
||||||
import androidx.room.PrimaryKey
|
import androidx.room.PrimaryKey
|
||||||
import com.github.cfogrady.vbnfc.vb.SpecialMission
|
import com.github.cfogrady.vbnfc.vb.SpecialMission
|
||||||
|
|
||||||
@Entity(
|
@Entity(
|
||||||
|
indices = [Index(value = ["characterId"])],
|
||||||
foreignKeys = [
|
foreignKeys = [
|
||||||
ForeignKey(
|
ForeignKey(
|
||||||
entity = UserCharacter::class,
|
entity = UserCharacter::class,
|
||||||
|
|||||||
@ -2,10 +2,15 @@ package com.github.nacabaro.vbhelper.domain.device_data
|
|||||||
|
|
||||||
import androidx.room.Entity
|
import androidx.room.Entity
|
||||||
import androidx.room.ForeignKey
|
import androidx.room.ForeignKey
|
||||||
|
import androidx.room.Index
|
||||||
import androidx.room.PrimaryKey
|
import androidx.room.PrimaryKey
|
||||||
import com.github.nacabaro.vbhelper.domain.card.CardCharacter
|
import com.github.nacabaro.vbhelper.domain.card.CardCharacter
|
||||||
|
|
||||||
@Entity(
|
@Entity(
|
||||||
|
indices = [
|
||||||
|
Index(value = ["monId"]),
|
||||||
|
Index(value = ["stageId"])
|
||||||
|
],
|
||||||
foreignKeys = [
|
foreignKeys = [
|
||||||
ForeignKey(
|
ForeignKey(
|
||||||
entity = UserCharacter::class,
|
entity = UserCharacter::class,
|
||||||
|
|||||||
@ -0,0 +1,31 @@
|
|||||||
|
package com.github.nacabaro.vbhelper.domain.device_data
|
||||||
|
|
||||||
|
import com.github.nacabaro.vbhelper.utils.DeviceType
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UnifiedCharacter provides a single access point for common character data
|
||||||
|
* and bridges the specific data for VB and BE characters.
|
||||||
|
*/
|
||||||
|
data class UnifiedCharacter(
|
||||||
|
val userCharacter: UserCharacter,
|
||||||
|
val vbData: VBCharacterData? = null,
|
||||||
|
val beData: BECharacterData? = null
|
||||||
|
) {
|
||||||
|
val characterType: DeviceType get() = userCharacter.characterType
|
||||||
|
|
||||||
|
fun isVB() = characterType == DeviceType.VBDevice
|
||||||
|
fun isBE() = characterType == DeviceType.BEDevice
|
||||||
|
|
||||||
|
// Unified accessors for core stats
|
||||||
|
val trophies: Int get() = userCharacter.trophies
|
||||||
|
val ageInDays: Int get() = userCharacter.ageInDays
|
||||||
|
val mood: Int get() = userCharacter.mood
|
||||||
|
val vitalPoints: Int get() = userCharacter.vitalPoints
|
||||||
|
val injuryStatus = userCharacter.injuryStatus
|
||||||
|
|
||||||
|
// BE specific accessors with defaults
|
||||||
|
val trainingHp: Int get() = beData?.trainingHp ?: 0
|
||||||
|
val trainingAp: Int get() = beData?.trainingAp ?: 0
|
||||||
|
val trainingBp: Int get() = beData?.trainingBp ?: 0
|
||||||
|
val abilityRarity = beData?.abilityRarity
|
||||||
|
}
|
||||||
@ -2,12 +2,14 @@ package com.github.nacabaro.vbhelper.domain.device_data
|
|||||||
|
|
||||||
import androidx.room.Entity
|
import androidx.room.Entity
|
||||||
import androidx.room.ForeignKey
|
import androidx.room.ForeignKey
|
||||||
|
import androidx.room.Index
|
||||||
import androidx.room.PrimaryKey
|
import androidx.room.PrimaryKey
|
||||||
import com.github.cfogrady.vbnfc.data.NfcCharacter
|
import com.github.cfogrady.vbnfc.data.NfcCharacter
|
||||||
import com.github.nacabaro.vbhelper.utils.DeviceType
|
import com.github.nacabaro.vbhelper.utils.DeviceType
|
||||||
import com.github.nacabaro.vbhelper.domain.card.CardCharacter
|
import com.github.nacabaro.vbhelper.domain.card.CardCharacter
|
||||||
|
|
||||||
@Entity(
|
@Entity(
|
||||||
|
indices = [Index(value = ["charId"])],
|
||||||
foreignKeys = [
|
foreignKeys = [
|
||||||
ForeignKey(
|
ForeignKey(
|
||||||
entity = CardCharacter::class,
|
entity = CardCharacter::class,
|
||||||
|
|||||||
@ -2,9 +2,11 @@ package com.github.nacabaro.vbhelper.domain.device_data
|
|||||||
|
|
||||||
import androidx.room.Entity
|
import androidx.room.Entity
|
||||||
import androidx.room.ForeignKey
|
import androidx.room.ForeignKey
|
||||||
|
import androidx.room.Index
|
||||||
import androidx.room.PrimaryKey
|
import androidx.room.PrimaryKey
|
||||||
|
|
||||||
@Entity(
|
@Entity(
|
||||||
|
indices = [Index(value = ["charId"])],
|
||||||
foreignKeys = [
|
foreignKeys = [
|
||||||
ForeignKey(
|
ForeignKey(
|
||||||
entity = UserCharacter::class,
|
entity = UserCharacter::class,
|
||||||
|
|||||||
@ -15,6 +15,7 @@ import androidx.compose.runtime.setValue
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.navigation.compose.NavHost
|
import androidx.navigation.compose.NavHost
|
||||||
|
import androidx.navigation.NavGraph.Companion.findStartDestination
|
||||||
import androidx.navigation.compose.composable
|
import androidx.navigation.compose.composable
|
||||||
import androidx.navigation.compose.rememberNavController
|
import androidx.navigation.compose.rememberNavController
|
||||||
import com.github.nacabaro.vbhelper.di.VBHelper
|
import com.github.nacabaro.vbhelper.di.VBHelper
|
||||||
@ -40,7 +41,6 @@ import com.github.nacabaro.vbhelper.screens.settingsScreen.CreditsScreen
|
|||||||
import com.github.nacabaro.vbhelper.screens.spriteViewer.SpriteViewerControllerImpl
|
import com.github.nacabaro.vbhelper.screens.spriteViewer.SpriteViewerControllerImpl
|
||||||
import com.github.nacabaro.vbhelper.screens.storageScreen.StorageScreenControllerImpl
|
import com.github.nacabaro.vbhelper.screens.storageScreen.StorageScreenControllerImpl
|
||||||
import com.github.nacabaro.vbhelper.source.StorageRepository
|
import com.github.nacabaro.vbhelper.source.StorageRepository
|
||||||
import kotlinx.coroutines.flow.Flow
|
|
||||||
|
|
||||||
data class AppNavigationHandlers(
|
data class AppNavigationHandlers(
|
||||||
val settingsScreenController: SettingsScreenControllerImpl,
|
val settingsScreenController: SettingsScreenControllerImpl,
|
||||||
@ -56,20 +56,22 @@ data class AppNavigationHandlers(
|
|||||||
@Composable
|
@Composable
|
||||||
fun AppNavigation(
|
fun AppNavigation(
|
||||||
applicationNavigationHandlers: AppNavigationHandlers,
|
applicationNavigationHandlers: AppNavigationHandlers,
|
||||||
initialRoute: String? = null,
|
initialRoute: String? = null
|
||||||
navigationEvents: Flow<String>? = null,
|
|
||||||
) {
|
) {
|
||||||
val navController = rememberNavController()
|
val navController = rememberNavController()
|
||||||
|
|
||||||
LaunchedEffect(navController, navigationEvents) {
|
LaunchedEffect(initialRoute) {
|
||||||
navigationEvents?.collect { route ->
|
val route = initialRoute ?: return@LaunchedEffect
|
||||||
|
if (navController.currentBackStackEntry?.destination?.route == route) {
|
||||||
|
return@LaunchedEffect
|
||||||
|
}
|
||||||
navController.navigate(route) {
|
navController.navigate(route) {
|
||||||
|
popUpTo(navController.graph.findStartDestination().id) {
|
||||||
|
inclusive = false
|
||||||
|
saveState = false
|
||||||
|
}
|
||||||
launchSingleTop = true
|
launchSingleTop = true
|
||||||
restoreState = true
|
restoreState = false
|
||||||
popUpTo(navController.graph.startDestinationId) {
|
|
||||||
saveState = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import androidx.compose.material3.Icon
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.res.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.navigation.NavController
|
import androidx.navigation.NavController
|
||||||
|
import androidx.navigation.NavGraph.Companion.findStartDestination
|
||||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
|
|
||||||
@ -30,42 +31,16 @@ fun BottomNavigationBar(navController: NavController) {
|
|||||||
label = { Text(text = stringResource(item.label)) },
|
label = { Text(text = stringResource(item.label)) },
|
||||||
selected = currentRoute == item.route,
|
selected = currentRoute == item.route,
|
||||||
onClick = {
|
onClick = {
|
||||||
if (item == NavigationItems.Home) {
|
navController.navigate(item.route) {
|
||||||
// Home should always show the Home root, not a restored nested route
|
// Always route tab clicks to each tab's root screen.
|
||||||
// like Settings that was opened from Home previously.
|
popUpTo(navController.graph.findStartDestination().id) {
|
||||||
val poppedToHome = navController.popBackStack(
|
inclusive = false
|
||||||
NavigationItems.Home.route,
|
saveState = false
|
||||||
inclusive = false,
|
}
|
||||||
)
|
|
||||||
if (!poppedToHome) {
|
|
||||||
navController.navigate(NavigationItems.Home.route) {
|
|
||||||
popUpTo(navController.graph.startDestinationId) { inclusive = false }
|
|
||||||
launchSingleTop = true
|
launchSingleTop = true
|
||||||
restoreState = false
|
restoreState = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (item == NavigationItems.Storage) {
|
|
||||||
// Adventure is launched from Storage; tapping Storage again should always
|
|
||||||
// bring the user back to the Storage root instead of appearing stuck.
|
|
||||||
val poppedToStorage = navController.popBackStack(
|
|
||||||
NavigationItems.Storage.route,
|
|
||||||
inclusive = false,
|
|
||||||
)
|
|
||||||
if (!poppedToStorage) {
|
|
||||||
navController.navigate(NavigationItems.Storage.route) {
|
|
||||||
popUpTo(navController.graph.startDestinationId) { saveState = true }
|
|
||||||
launchSingleTop = true
|
|
||||||
restoreState = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
navController.navigate(item.route) {
|
|
||||||
popUpTo(navController.graph.startDestinationId) { saveState = true }
|
|
||||||
launchSingleTop = true
|
|
||||||
restoreState = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,8 +6,8 @@ import androidx.annotation.StringRes
|
|||||||
|
|
||||||
sealed class NavigationItems(
|
sealed class NavigationItems(
|
||||||
val route: String,
|
val route: String,
|
||||||
@DrawableRes val icon: Int,
|
@param:DrawableRes val icon: Int,
|
||||||
@StringRes val label: Int
|
@param:StringRes val label: Int
|
||||||
) {
|
) {
|
||||||
object Scan : NavigationItems(
|
object Scan : NavigationItems(
|
||||||
"Scan/{characterId}",
|
"Scan/{characterId}",
|
||||||
|
|||||||
@ -51,6 +51,7 @@ import android.content.Intent
|
|||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.media.MediaPlayer
|
import android.media.MediaPlayer
|
||||||
import android.os.Environment
|
import android.os.Environment
|
||||||
|
import android.widget.Toast
|
||||||
//import androidx.compose.animation.core.animateFloatAsState
|
//import androidx.compose.animation.core.animateFloatAsState
|
||||||
//import androidx.compose.animation.core.tween
|
//import androidx.compose.animation.core.tween
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
@ -64,6 +65,8 @@ import com.github.nacabaro.vbhelper.battle.APIBattleCharacter
|
|||||||
import android.util.Log
|
import android.util.Log
|
||||||
import com.github.nacabaro.vbhelper.components.TopBanner
|
import com.github.nacabaro.vbhelper.components.TopBanner
|
||||||
import com.github.nacabaro.vbhelper.battle.RetrofitHelper
|
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.AttackSpriteImage
|
||||||
import com.github.nacabaro.vbhelper.battle.SpriteFileManager
|
import com.github.nacabaro.vbhelper.battle.SpriteFileManager
|
||||||
import com.github.nacabaro.vbhelper.battle.ArenaBattleSystem
|
import com.github.nacabaro.vbhelper.battle.ArenaBattleSystem
|
||||||
@ -71,6 +74,7 @@ import com.github.nacabaro.vbhelper.battle.DigimonAnimationType
|
|||||||
import com.github.nacabaro.vbhelper.battle.AnimatedSpriteImage
|
import com.github.nacabaro.vbhelper.battle.AnimatedSpriteImage
|
||||||
import com.github.nacabaro.vbhelper.battle.HitEffectOverlay
|
import com.github.nacabaro.vbhelper.battle.HitEffectOverlay
|
||||||
import com.github.nacabaro.vbhelper.battle.BattleAuthContainer
|
import com.github.nacabaro.vbhelper.battle.BattleAuthContainer
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.flow.collect
|
import kotlinx.coroutines.flow.collect
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
@ -899,7 +903,6 @@ fun MiddleBattleView(
|
|||||||
y = enemyVerticalOffset + 40.dp
|
y = enemyVerticalOffset + 40.dp
|
||||||
),
|
),
|
||||||
contentScale = ContentScale.Fit,
|
contentScale = ContentScale.Fit,
|
||||||
reloadMappings = false,
|
|
||||||
animationOffset = 375L // Offset enemy animation by half the idle duration
|
animationOffset = 375L // Offset enemy animation by half the idle duration
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -986,7 +989,6 @@ fun MiddleBattleView(
|
|||||||
y = playerVerticalOffset - 40.dp
|
y = playerVerticalOffset - 40.dp
|
||||||
),
|
),
|
||||||
contentScale = ContentScale.Fit,
|
contentScale = ContentScale.Fit,
|
||||||
reloadMappings = false,
|
|
||||||
animationOffset = 0L // Player animation starts immediately
|
animationOffset = 0L // Player animation starts immediately
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -1072,19 +1074,30 @@ fun MiddleBattleView(
|
|||||||
|
|
||||||
// Capture userId for use in this lambda
|
// Capture userId for use in this lambda
|
||||||
val playerUserId = userId
|
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)
|
// Get crit bar progress as float (0.0f to 100.0f)
|
||||||
val critBarProgressFloat = battleSystem.critBarProgress.toFloat()
|
val critBarProgressFloat = battleSystem.critBarProgress.toFloat()
|
||||||
|
|
||||||
// Determine player and opponent stages
|
// 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) {
|
val playerStage = when (activeCharacter?.stage) {
|
||||||
0 -> 0 // rookie
|
2 -> 0 // rookie
|
||||||
1 -> 1 // champion
|
3 -> 1 // champion
|
||||||
2 -> 2 // ultimate
|
4 -> 2 // ultimate
|
||||||
3 -> 3 // mega
|
5 -> 3 // mega
|
||||||
else -> 0
|
else -> 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// opponentCharacter.stage comes from the API and is already 0-3
|
||||||
val opponentStage = when (opponentCharacter?.stage) {
|
val opponentStage = when (opponentCharacter?.stage) {
|
||||||
0 -> 0 // rookie
|
0 -> 0 // rookie
|
||||||
1 -> 1 // champion
|
1 -> 1 // champion
|
||||||
@ -1101,11 +1114,11 @@ fun MiddleBattleView(
|
|||||||
RetrofitHelper().getPVPWinner(
|
RetrofitHelper().getPVPWinner(
|
||||||
ctx,
|
ctx,
|
||||||
1,
|
1,
|
||||||
playerUserId ?: 2L,
|
playerUserId,
|
||||||
activeCharacter?.name ?: "Player",
|
activeCharacter?.charaId ?: "dim011_mon01",
|
||||||
playerStage,
|
playerStage,
|
||||||
opponentStage,
|
battleSystem.critBarProgress,
|
||||||
opponentCharacter?.name ?: "Opponent",
|
opponentCharacter?.charaId ?: "dim011_mon01",
|
||||||
opponentStage
|
opponentStage
|
||||||
) { apiResult ->
|
) { apiResult ->
|
||||||
// Handle API response here
|
// Handle API response here
|
||||||
@ -1309,7 +1322,6 @@ fun PlayerBattleView(
|
|||||||
y = verticalOffset
|
y = verticalOffset
|
||||||
),
|
),
|
||||||
contentScale = ContentScale.Fit,
|
contentScale = ContentScale.Fit,
|
||||||
reloadMappings = false,
|
|
||||||
animationOffset = 0L // Player animation starts immediately
|
animationOffset = 0L // Player animation starts immediately
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -1491,7 +1503,6 @@ fun EnemyBattleView(
|
|||||||
y = verticalOffset
|
y = verticalOffset
|
||||||
),
|
),
|
||||||
contentScale = ContentScale.Fit,
|
contentScale = ContentScale.Fit,
|
||||||
reloadMappings = false,
|
|
||||||
animationOffset = 375L // Offset enemy animation by half the idle duration
|
animationOffset = 375L // Offset enemy animation by half the idle duration
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -1641,8 +1652,63 @@ fun BattlesScreen() {
|
|||||||
// Track processed tokens to prevent duplicate API calls
|
// Track processed tokens to prevent duplicate API calls
|
||||||
// Use rememberSaveable to persist across configuration changes (screen rotation)
|
// Use rememberSaveable to persist across configuration changes (screen rotation)
|
||||||
var processedTokens by rememberSaveable { mutableStateOf<Set<String>>(emptySet()) }
|
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 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 activeCharacter by remember { mutableStateOf<APIBattleCharacter?>(null) }
|
||||||
var selectedOpponent by remember { mutableStateOf<APIBattleCharacter?>(null) }
|
var selectedOpponent by remember { mutableStateOf<APIBattleCharacter?>(null) }
|
||||||
@ -1724,39 +1790,85 @@ fun BattlesScreen() {
|
|||||||
// Determine if player can battle based on stage (derived from activeUserCharacter)
|
// Determine if player can battle based on stage (derived from activeUserCharacter)
|
||||||
val canBattle = activeUserCharacter?.stage?.let { it >= 2 } ?: false
|
val canBattle = activeUserCharacter?.stage?.let { it >= 2 } ?: false
|
||||||
|
|
||||||
// Load opponents automatically based on player's stage
|
val loadOpponentsIfReady: () -> Unit = loadOpponents@{
|
||||||
// Only load if authenticated and character is ready
|
val currentCharacter = activeUserCharacter
|
||||||
LaunchedEffect(activeUserCharacter, isAuthenticated) {
|
val readyToken = authReadyToken
|
||||||
// Wait for authentication to complete before loading opponents
|
|
||||||
if (!isAuthenticated) {
|
if (!isAuthenticated) {
|
||||||
return@LaunchedEffect
|
if (opponentsList.isNotEmpty()) {
|
||||||
|
opponentsList = ArrayList()
|
||||||
|
}
|
||||||
|
return@loadOpponents
|
||||||
}
|
}
|
||||||
|
|
||||||
val currentCharacter = activeUserCharacter
|
if (readyToken.isNullOrBlank()) {
|
||||||
if (currentCharacter != null && canBattle && playerBattleType != null) {
|
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
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
RetrofitHelper().getOpponents(context, playerBattleType!!) { opponents ->
|
isLoadingOpponents = true
|
||||||
|
RetrofitHelper().getOpponents(context, battleType, onComplete = {
|
||||||
|
isLoadingOpponents = false
|
||||||
|
}) { opponents ->
|
||||||
try {
|
try {
|
||||||
// Create a new list to trigger UI recomposition
|
|
||||||
opponentsList = ArrayList(opponents.opponentsList)
|
opponentsList = ArrayList(opponents.opponentsList)
|
||||||
|
println("BATTLESCREEN: Loaded ${opponents.opponentsList.size} opponents for stage $battleType")
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.d(TAG, "Error processing opponents data: ${e.message}")
|
Log.d(TAG, "Error processing opponents data: ${e.message}")
|
||||||
e.printStackTrace()
|
e.printStackTrace()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.d(TAG,"Error calling getOpponents: ${e.message}")
|
isLoadingOpponents = false
|
||||||
|
Log.d(TAG, "Error calling getOpponents: ${e.message}")
|
||||||
e.printStackTrace()
|
e.printStackTrace()
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
println("BATTLESCREEN: Cannot load opponents - activeUserCharacter: $currentCharacter")
|
if (opponentsList.isNotEmpty()) {
|
||||||
println("BATTLESCREEN: canBattle: $canBattle")
|
opponentsList = ArrayList()
|
||||||
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1775,17 +1887,19 @@ fun BattlesScreen() {
|
|||||||
if (!processedTokens.contains(token)) {
|
if (!processedTokens.contains(token)) {
|
||||||
// Mark token as being processed IMMEDIATELY to prevent duplicate API calls
|
// Mark token as being processed IMMEDIATELY to prevent duplicate API calls
|
||||||
processedTokens = processedTokens + token
|
processedTokens = processedTokens + token
|
||||||
|
isProcessingAuthCallback = true
|
||||||
|
authBrowserLaunchInFlight = false
|
||||||
|
|
||||||
// Exchange token with battle server
|
// Exchange token with battle server
|
||||||
RetrofitHelper().authenticate(context, token) { response ->
|
RetrofitHelper().authenticate(context, token) { response ->
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
// Extract userId and sessionToken from response
|
// Extract userId and sessionToken from response
|
||||||
val extractedUserId = response.userInfo?.userId?.toLongOrNull()
|
val extractedUserId = response.extractUserId()
|
||||||
val sessionToken = response.sessionToken
|
val sessionToken = response.extractSessionToken()
|
||||||
|
|
||||||
println("BATTLESCREEN: Authentication successful, userId: $extractedUserId, sessionToken: ${if (sessionToken != null) "present" else "missing"}")
|
println("BATTLESCREEN: Authentication successful, userId: $extractedUserId, sessionToken: ${if (sessionToken != null) "present" else "missing"}")
|
||||||
|
|
||||||
// Store both nacatech token (for re-auth) and sessionToken (for API calls)
|
// Persist auth first so follow-up API calls can immediately use the token.
|
||||||
kotlinx.coroutines.CoroutineScope(Dispatchers.IO).launch {
|
kotlinx.coroutines.CoroutineScope(Dispatchers.IO).launch {
|
||||||
battleAuthContainer.authRepository.setAuthenticated(
|
battleAuthContainer.authRepository.setAuthenticated(
|
||||||
isAuthenticated = true,
|
isAuthenticated = true,
|
||||||
@ -1793,15 +1907,17 @@ fun BattlesScreen() {
|
|||||||
sessionToken = sessionToken,
|
sessionToken = sessionToken,
|
||||||
userId = extractedUserId
|
userId = extractedUserId
|
||||||
)
|
)
|
||||||
}
|
|
||||||
// Update UI state on main thread
|
withContext(Dispatchers.Main) {
|
||||||
kotlinx.coroutines.CoroutineScope(Dispatchers.Main).launch {
|
|
||||||
isAuthenticated = true
|
isAuthenticated = true
|
||||||
isCheckingAuth = false
|
isCheckingAuth = false
|
||||||
userId = extractedUserId
|
userId = extractedUserId
|
||||||
|
isProcessingAuthCallback = false
|
||||||
|
authBrowserLaunchInFlight = false
|
||||||
println("BATTLESCREEN: Authentication successful, userId: $extractedUserId")
|
println("BATTLESCREEN: Authentication successful, userId: $extractedUserId")
|
||||||
android.widget.Toast.makeText(context, "Authentication successful!", android.widget.Toast.LENGTH_SHORT).show()
|
android.widget.Toast.makeText(context, "Authentication successful!", android.widget.Toast.LENGTH_SHORT).show()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
println("BATTLESCREEN: Authentication failed: ${response.message}")
|
println("BATTLESCREEN: Authentication failed: ${response.message}")
|
||||||
// If it's an "Invalid user nonce" error, the token was already used - keep it marked to prevent retries
|
// If it's an "Invalid user nonce" error, the token was already used - keep it marked to prevent retries
|
||||||
@ -1815,23 +1931,16 @@ fun BattlesScreen() {
|
|||||||
kotlinx.coroutines.CoroutineScope(Dispatchers.Main).launch {
|
kotlinx.coroutines.CoroutineScope(Dispatchers.Main).launch {
|
||||||
isAuthenticated = false
|
isAuthenticated = false
|
||||||
isCheckingAuth = false
|
isCheckingAuth = false
|
||||||
// Small delay to ensure state is updated
|
isProcessingAuthCallback = false
|
||||||
kotlinx.coroutines.delay(100)
|
authBrowserLaunchInFlight = false
|
||||||
// Open auth URL to get a fresh token
|
println("BATTLESCREEN: Auth callback token was already used; avoiding automatic browser relaunch loop")
|
||||||
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 {
|
} else {
|
||||||
// For other errors, remove from processed set to allow retry with a new token
|
// 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")
|
println("BATTLESCREEN: Authentication failed, removing token from processed set to allow retry")
|
||||||
processedTokens = processedTokens - token
|
processedTokens = processedTokens - token
|
||||||
|
isProcessingAuthCallback = false
|
||||||
|
authBrowserLaunchInFlight = false
|
||||||
}
|
}
|
||||||
// Show toast on main thread
|
// Show toast on main thread
|
||||||
kotlinx.coroutines.CoroutineScope(Dispatchers.Main).launch {
|
kotlinx.coroutines.CoroutineScope(Dispatchers.Main).launch {
|
||||||
@ -1839,12 +1948,47 @@ fun BattlesScreen() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
println("BATTLESCREEN: Ignoring already processed auth token")
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
println("BATTLESCREEN: No token found in URI: $uri (checked 'c' and 'token' parameters)")
|
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
|
// Check authentication status on load
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
try {
|
try {
|
||||||
@ -1852,6 +1996,7 @@ fun BattlesScreen() {
|
|||||||
val localAuthState = authRepository.isAuthenticated.first()
|
val localAuthState = authRepository.isAuthenticated.first()
|
||||||
val storedToken = authRepository.authToken.first()
|
val storedToken = authRepository.authToken.first()
|
||||||
val storedUserId = authRepository.userId.first()
|
val storedUserId = authRepository.userId.first()
|
||||||
|
val storedSessionToken = authRepository.sessionToken.first()
|
||||||
|
|
||||||
// Load stored userId if available
|
// Load stored userId if available
|
||||||
if (storedUserId != null) {
|
if (storedUserId != null) {
|
||||||
@ -1867,40 +2012,35 @@ fun BattlesScreen() {
|
|||||||
userId = storedUserId
|
userId = storedUserId
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only check for token in intent if it's a fresh deep link (ACTION_VIEW intent)
|
if (consumeAuthCallbackIntent()) {
|
||||||
// This prevents processing stale tokens from previous sessions
|
return@LaunchedEffect
|
||||||
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
|
// 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
|
// 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
|
// We'll only re-authenticate if API calls fail with authentication errors
|
||||||
if (localAuthState && storedToken != null && storedToken.isNotEmpty() && storedUserId != null) {
|
if (localAuthState && storedToken != null && storedToken.isNotEmpty() && (storedUserId != null || !storedSessionToken.isNullOrEmpty())) {
|
||||||
// Session appears to be active - user is already authenticated
|
// Session appears to be active - user is already authenticated
|
||||||
// No need to re-validate the single-use token, just restore the session
|
// No need to re-validate the single-use token, just restore the session
|
||||||
println("BATTLESCREEN: Restoring active session (userId: $storedUserId)")
|
println("BATTLESCREEN: Restoring active session (userId: $storedUserId, sessionTokenPresent=${!storedSessionToken.isNullOrEmpty()})")
|
||||||
isAuthenticated = true
|
isAuthenticated = true
|
||||||
isCheckingAuth = false
|
isCheckingAuth = false
|
||||||
userId = storedUserId
|
userId = storedUserId
|
||||||
// Session is restored, no need to validate token again
|
// Session is restored, no need to validate token again
|
||||||
} else if (localAuthState && storedToken != null && storedToken.isNotEmpty()) {
|
} else if (localAuthState && storedToken != null && storedToken.isNotEmpty() && storedSessionToken.isNullOrEmpty()) {
|
||||||
// We have a token but no userId - try to validate once to get userId
|
// 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
|
// This should only happen on first login or if userId was lost
|
||||||
println("BATTLESCREEN: Have token but no userId, validating once to get userId...")
|
println("BATTLESCREEN: Have token but no userId, validating once to get userId...")
|
||||||
RetrofitHelper().authenticate(context, storedToken) { response ->
|
RetrofitHelper().authenticate(
|
||||||
|
context = context,
|
||||||
|
token = storedToken,
|
||||||
|
showUserFacingErrors = false,
|
||||||
|
) { response ->
|
||||||
// Update UI on main thread
|
// Update UI on main thread
|
||||||
kotlinx.coroutines.CoroutineScope(Dispatchers.Main).launch {
|
kotlinx.coroutines.CoroutineScope(Dispatchers.Main).launch {
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
val extractedUserId = response.userInfo?.userId?.toLongOrNull()
|
val extractedUserId = response.extractUserId()
|
||||||
val sessionToken = response.sessionToken
|
val sessionToken = response.extractSessionToken()
|
||||||
|
|
||||||
// Update stored userId and sessionToken
|
// Update stored userId and sessionToken
|
||||||
if (extractedUserId != null) {
|
if (extractedUserId != null) {
|
||||||
@ -1924,8 +2064,22 @@ fun BattlesScreen() {
|
|||||||
response.message?.contains("nonce") == true ||
|
response.message?.contains("nonce") == true ||
|
||||||
response.message?.contains("invalid") == true ||
|
response.message?.contains("invalid") == true ||
|
||||||
response.message?.contains("expired") == true
|
response.message?.contains("expired") == true
|
||||||
|
val latestSessionToken = withContext(Dispatchers.IO) {
|
||||||
|
authRepository.sessionToken.first()
|
||||||
|
}
|
||||||
|
val latestUserId = withContext(Dispatchers.IO) {
|
||||||
|
authRepository.userId.first()
|
||||||
|
}
|
||||||
|
|
||||||
if (isCriticalError) {
|
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
|
// Critical error - token is invalid, need to re-authenticate
|
||||||
println("BATTLESCREEN: Critical authentication error, clearing state and redirecting")
|
println("BATTLESCREEN: Critical authentication error, clearing state and redirecting")
|
||||||
kotlinx.coroutines.CoroutineScope(Dispatchers.IO).launch {
|
kotlinx.coroutines.CoroutineScope(Dispatchers.IO).launch {
|
||||||
@ -1933,11 +2087,7 @@ fun BattlesScreen() {
|
|||||||
}
|
}
|
||||||
isAuthenticated = false
|
isAuthenticated = false
|
||||||
isCheckingAuth = false
|
isCheckingAuth = false
|
||||||
// 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 after critical validation failure: $authUrl")
|
|
||||||
} else {
|
} else {
|
||||||
// Non-critical error (e.g., network issue) - keep authenticated state
|
// Non-critical error (e.g., network issue) - keep authenticated state
|
||||||
println("BATTLESCREEN: Non-critical validation error, keeping authenticated state")
|
println("BATTLESCREEN: Non-critical validation error, keeping authenticated state")
|
||||||
@ -1952,10 +2102,7 @@ fun BattlesScreen() {
|
|||||||
isAuthenticated = false
|
isAuthenticated = false
|
||||||
isCheckingAuth = false
|
isCheckingAuth = false
|
||||||
// If not authenticated and no fresh token in intent, open auth URL
|
// If not authenticated and no fresh token in intent, open auth URL
|
||||||
val authUrl = "http://auth.nacatech.es/begin?app=443654920&redirect_uri=vbhelper://auth?token="
|
openBattleAuthPage()
|
||||||
val authIntent = Intent(Intent.ACTION_VIEW, Uri.parse(authUrl))
|
|
||||||
context.startActivity(authIntent)
|
|
||||||
println("BATTLESCREEN: Opened auth URL: $authUrl")
|
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
println("BATTLESCREEN: Error checking authentication status: ${e.message}")
|
println("BATTLESCREEN: Error checking authentication status: ${e.message}")
|
||||||
@ -1964,42 +2111,11 @@ fun BattlesScreen() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle deep link callback to get token
|
// Handle deep link callback once on initial load and consume it.
|
||||||
// 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) {
|
LaunchedEffect(Unit) {
|
||||||
// Small delay to ensure activity is fully initialized
|
// Small delay to ensure activity is fully initialized
|
||||||
kotlinx.coroutines.delay(100)
|
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
|
// Check intent when screen becomes visible or when authentication state changes
|
||||||
@ -2009,15 +2125,10 @@ fun BattlesScreen() {
|
|||||||
val lifecycleOwner = activity as? LifecycleOwner
|
val lifecycleOwner = activity as? LifecycleOwner
|
||||||
|
|
||||||
val observer = LifecycleEventObserver { _, event ->
|
val observer = LifecycleEventObserver { _, event ->
|
||||||
if (event == Lifecycle.Event.ON_RESUME && !isAuthenticated) {
|
if (event == Lifecycle.Event.ON_RESUME) {
|
||||||
// Check intent data when activity resumes - only if it's a fresh ACTION_VIEW intent
|
authBrowserLaunchInFlight = false
|
||||||
val intent = activity?.intent
|
if (!isAuthenticated) {
|
||||||
if (intent?.action == Intent.ACTION_VIEW) {
|
consumeAuthCallbackIntent()
|
||||||
intent.data?.let { uri ->
|
|
||||||
if (uri.getQueryParameter("c") != null || uri.getQueryParameter("token") != null) {
|
|
||||||
handleTokenFromUri(uri)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -2038,45 +2149,16 @@ fun BattlesScreen() {
|
|||||||
isAuthenticated = false
|
isAuthenticated = false
|
||||||
isCheckingAuth = false
|
isCheckingAuth = false
|
||||||
// Open auth URL to get a fresh token
|
// Open auth URL to get a fresh token
|
||||||
val authUrl = "http://auth.nacatech.es/begin?app=443654920&redirect_uri=vbhelper://auth?token="
|
openBattleAuthPage()
|
||||||
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 intent when authentication state changes
|
// Also check for callback when auth state changes.
|
||||||
// Only process if it's a fresh ACTION_VIEW intent (deep link)
|
|
||||||
LaunchedEffect(isAuthenticated) {
|
LaunchedEffect(isAuthenticated) {
|
||||||
if (!isAuthenticated) {
|
if (!isAuthenticated) {
|
||||||
kotlinx.coroutines.delay(200) // Small delay to ensure intent is available
|
kotlinx.coroutines.delay(200) // Small delay to ensure intent is available
|
||||||
val activity = context as? ComponentActivity
|
consumeAuthCallbackIntent()
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -2084,9 +2166,8 @@ fun BattlesScreen() {
|
|||||||
// Only check if permission is granted
|
// Only check if permission is granted
|
||||||
LaunchedEffect(hasStoragePermission) {
|
LaunchedEffect(hasStoragePermission) {
|
||||||
if (hasStoragePermission) {
|
if (hasStoragePermission) {
|
||||||
val spriteFileManager = SpriteFileManager(context)
|
val spriteFileManager = SpriteFileManager()
|
||||||
if (spriteFileManager.checkSpriteFilesExist()) {
|
if (!spriteFileManager.checkSpriteFilesExist()) {
|
||||||
} else {
|
|
||||||
println("BATTLESCREEN: Sprite files not found in external storage")
|
println("BATTLESCREEN: Sprite files not found in external storage")
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@ -2233,6 +2314,10 @@ fun BattlesScreen() {
|
|||||||
color = Color.Gray,
|
color = Color.Gray,
|
||||||
textAlign = TextAlign.Center
|
textAlign = TextAlign.Center
|
||||||
)
|
)
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
Button(onClick = { openBattleAuthPage() }) {
|
||||||
|
Text("Open Login")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -2267,6 +2352,12 @@ fun BattlesScreen() {
|
|||||||
items(opponentsList) { opponent ->
|
items(opponentsList) { opponent ->
|
||||||
Button(
|
Button(
|
||||||
onClick = {
|
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 ->
|
activeCardId?.let { cardId ->
|
||||||
selectedOpponent = opponent
|
selectedOpponent = opponent
|
||||||
// Randomly select background set (0, 1, or 2)
|
// Randomly select background set (0, 1, or 2)
|
||||||
@ -2281,7 +2372,7 @@ fun BattlesScreen() {
|
|||||||
else -> 0
|
else -> 0
|
||||||
}
|
}
|
||||||
|
|
||||||
RetrofitHelper().getPVPWinner(context, 0, userId ?: 2L, cardId, apiStage, 0, opponent.charaId, apiStage) { apiResult ->
|
RetrofitHelper().getPVPWinner(context, 0, currentUserId, cardId, apiStage, 0, opponent.charaId, apiStage) { apiResult ->
|
||||||
// Check if there's an existing match
|
// Check if there's an existing match
|
||||||
when {
|
when {
|
||||||
apiResult.status.contains("Existing match found", ignoreCase = true) -> {
|
apiResult.status.contains("Existing match found", ignoreCase = true) -> {
|
||||||
@ -2382,14 +2473,17 @@ fun BattlesScreen() {
|
|||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
// Determine player and opponent stages
|
// 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) {
|
val playerStage = when (activeCharacter?.stage) {
|
||||||
0 -> 0 // rookie
|
2 -> 0 // rookie
|
||||||
1 -> 1 // champion
|
3 -> 1 // champion
|
||||||
2 -> 2 // ultimate
|
4 -> 2 // ultimate
|
||||||
3 -> 3 // mega
|
5 -> 3 // mega
|
||||||
else -> 0
|
else -> 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// selectedOpponent.stage comes from the API and is already 0-3
|
||||||
val opponentStage = when (selectedOpponent?.stage) {
|
val opponentStage = when (selectedOpponent?.stage) {
|
||||||
0 -> 0 // rookie
|
0 -> 0 // rookie
|
||||||
1 -> 1 // champion
|
1 -> 1 // champion
|
||||||
@ -2402,11 +2496,16 @@ fun BattlesScreen() {
|
|||||||
RetrofitHelper().getPVPWinner(
|
RetrofitHelper().getPVPWinner(
|
||||||
context,
|
context,
|
||||||
1,
|
1,
|
||||||
userId ?: 2L,
|
userId ?: run {
|
||||||
activeCharacter?.name ?: "Player",
|
Toast.makeText(context, "Session missing user id. Please re-authenticate.", Toast.LENGTH_SHORT).show()
|
||||||
|
isWinnerLoaded = true
|
||||||
|
winnerName = "Unknown"
|
||||||
|
return@LaunchedEffect
|
||||||
|
},
|
||||||
|
activeCharacter?.charaId ?: "dim011_mon01",
|
||||||
playerStage,
|
playerStage,
|
||||||
opponentStage,
|
0,
|
||||||
selectedOpponent?.name ?: "Opponent",
|
selectedOpponent?.charaId ?: "dim011_mon01",
|
||||||
opponentStage
|
opponentStage
|
||||||
) { apiResult ->
|
) { apiResult ->
|
||||||
// Winner might be empty in first call, but we can check HP values
|
// Winner might be empty in first call, but we can check HP values
|
||||||
@ -2415,7 +2514,7 @@ fun BattlesScreen() {
|
|||||||
|
|
||||||
// Also check winner field if it's not empty
|
// Also check winner field if it's not empty
|
||||||
val playerWonFromWinner = activeCardId?.let { cardId ->
|
val playerWonFromWinner = activeCardId?.let { cardId ->
|
||||||
val winner = apiResult.winner ?: ""
|
val winner = apiResult.winner
|
||||||
if (winner.isNotEmpty()) {
|
if (winner.isNotEmpty()) {
|
||||||
if (winner.contains("|")) {
|
if (winner.contains("|")) {
|
||||||
// Pipe-separated format: first part is the winner
|
// Pipe-separated format: first part is the winner
|
||||||
@ -2442,18 +2541,21 @@ fun BattlesScreen() {
|
|||||||
println("BATTLESCREEN: Battle result (first call) - winner: '${apiResult.winner}', playerHP: ${apiResult.playerHP}, opponentHP: ${apiResult.opponentHP}, playerWonFromHP: $playerWonFromHP, playerWonFromWinner: $playerWonFromWinner, final playerWon: $playerWon")
|
println("BATTLESCREEN: Battle result (first call) - winner: '${apiResult.winner}', playerHP: ${apiResult.playerHP}, opponentHP: ${apiResult.opponentHP}, playerWonFromHP: $playerWonFromHP, playerWonFromWinner: $playerWonFromWinner, final playerWon: $playerWon")
|
||||||
|
|
||||||
// Store winner name for display (will be updated in cleanup call if available)
|
// Store winner name for display (will be updated in cleanup call if available)
|
||||||
winnerName = apiResult.winner ?: if (playerWon) "You" else "Opponent"
|
winnerName = if (apiResult.winner.isNotEmpty()) apiResult.winner else if (playerWon) "You" else "Opponent"
|
||||||
isWinnerLoaded = true
|
isWinnerLoaded = true
|
||||||
|
|
||||||
// Then send the cleanup call - this will have the actual winner name
|
// Then send the cleanup call - this will have the actual winner name
|
||||||
RetrofitHelper().getPVPWinner(
|
RetrofitHelper().getPVPWinner(
|
||||||
context,
|
context,
|
||||||
2,
|
2,
|
||||||
userId ?: 2L,
|
userId ?: run {
|
||||||
activeCharacter?.name ?: "Player",
|
Toast.makeText(context, "Session missing user id. Please re-authenticate.", Toast.LENGTH_SHORT).show()
|
||||||
|
return@getPVPWinner
|
||||||
|
},
|
||||||
|
activeCharacter?.charaId ?: "dim011_mon01",
|
||||||
playerStage,
|
playerStage,
|
||||||
opponentStage,
|
0,
|
||||||
selectedOpponent?.name ?: "Opponent",
|
selectedOpponent?.charaId ?: "dim011_mon01",
|
||||||
opponentStage
|
opponentStage
|
||||||
) { cleanupResult ->
|
) { cleanupResult ->
|
||||||
// Update winner name from cleanup call if available
|
// Update winner name from cleanup call if available
|
||||||
@ -2465,7 +2567,7 @@ fun BattlesScreen() {
|
|||||||
// Primary method: Check HP values (opponentHP <= 0 means opponent lost = player won)
|
// Primary method: Check HP values (opponentHP <= 0 means opponent lost = player won)
|
||||||
// Secondary method: Check winner name (if winner doesn't match opponent, player won)
|
// Secondary method: Check winner name (if winner doesn't match opponent, player won)
|
||||||
val opponentName = selectedOpponent?.name ?: ""
|
val opponentName = selectedOpponent?.name ?: ""
|
||||||
val winner = cleanupResult.winner ?: ""
|
val winner = cleanupResult.winner
|
||||||
|
|
||||||
// Primary: HP-based determination (most reliable)
|
// Primary: HP-based determination (most reliable)
|
||||||
// If opponentHP <= 0, opponent is dead = player won
|
// If opponentHP <= 0, opponent is dead = player won
|
||||||
@ -2602,7 +2704,12 @@ fun BattlesScreen() {
|
|||||||
RetrofitHelper().getPVPWinner(
|
RetrofitHelper().getPVPWinner(
|
||||||
context,
|
context,
|
||||||
0,
|
0,
|
||||||
userId ?: 2L,
|
userId ?: run {
|
||||||
|
Toast.makeText(context, "Session missing user id. Please re-authenticate.", Toast.LENGTH_SHORT).show()
|
||||||
|
showResumeDialog = false
|
||||||
|
existingMatchState = null
|
||||||
|
return@let
|
||||||
|
},
|
||||||
cardId,
|
cardId,
|
||||||
apiStage,
|
apiStage,
|
||||||
0,
|
0,
|
||||||
@ -2727,7 +2834,12 @@ fun BattlesScreen() {
|
|||||||
RetrofitHelper().getPVPWinner(
|
RetrofitHelper().getPVPWinner(
|
||||||
context,
|
context,
|
||||||
0,
|
0,
|
||||||
userId ?: 2L,
|
userId ?: run {
|
||||||
|
Toast.makeText(context, "Session missing user id. Please re-authenticate.", Toast.LENGTH_SHORT).show()
|
||||||
|
showResumeDialog = false
|
||||||
|
existingMatchState = null
|
||||||
|
return@let
|
||||||
|
},
|
||||||
cardId,
|
cardId,
|
||||||
apiStage,
|
apiStage,
|
||||||
0,
|
0,
|
||||||
|
|||||||
@ -33,8 +33,6 @@ import com.github.nacabaro.vbhelper.domain.device_data.BECharacterData
|
|||||||
import com.github.nacabaro.vbhelper.domain.device_data.VBCharacterData
|
import com.github.nacabaro.vbhelper.domain.device_data.VBCharacterData
|
||||||
import com.github.nacabaro.vbhelper.dtos.ItemDtos
|
import com.github.nacabaro.vbhelper.dtos.ItemDtos
|
||||||
import com.github.nacabaro.vbhelper.navigation.NavigationItems
|
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.homeScreens.screens.VBDiMHomeScreen
|
||||||
import com.github.nacabaro.vbhelper.screens.itemsScreen.ObtainedItemDialog
|
import com.github.nacabaro.vbhelper.screens.itemsScreen.ObtainedItemDialog
|
||||||
import com.github.nacabaro.vbhelper.source.StorageRepository
|
import com.github.nacabaro.vbhelper.source.StorageRepository
|
||||||
@ -74,9 +72,8 @@ fun HomeScreen(
|
|||||||
?: flowOf(emptyList())
|
?: flowOf(emptyList())
|
||||||
).collectAsState(initial = emptyList())
|
).collectAsState(initial = emptyList())
|
||||||
|
|
||||||
val vbSpecialMissions by (
|
val specialMissions by (
|
||||||
activeMon
|
activeMon
|
||||||
?.takeIf { it.characterType == DeviceType.VBDevice }
|
|
||||||
?.let { chara ->
|
?.let { chara ->
|
||||||
storageRepository.getSpecialMissions(chara.id)
|
storageRepository.getSpecialMissions(chara.id)
|
||||||
}
|
}
|
||||||
@ -144,29 +141,20 @@ fun HomeScreen(
|
|||||||
height = cardIconData!!.cardIconHeight
|
height = cardIconData!!.cardIconHeight
|
||||||
)
|
)
|
||||||
|
|
||||||
if (activeMon!!.isBemCard && beData != null) {
|
val activeCharacter = activeMon!!
|
||||||
BEBEmHomeScreen(
|
val displaySpecialMissions = specialMissions
|
||||||
activeMon = activeMon!!,
|
|
||||||
beData = beData!!,
|
if (activeCharacter.characterType == DeviceType.BEDevice || vbData != null) {
|
||||||
transformationHistory = transformationHistory,
|
|
||||||
contentPadding = PaddingValues(0.dp),
|
|
||||||
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(
|
VBDiMHomeScreen(
|
||||||
activeMon = activeMon!!,
|
activeMon = activeCharacter,
|
||||||
vbData = vbData!!,
|
vbData = vbData ?: VBCharacterData(
|
||||||
|
id = activeCharacter.id,
|
||||||
|
generation = 0,
|
||||||
|
totalTrophies = activeCharacter.trophies,
|
||||||
|
),
|
||||||
transformationHistory = transformationHistory,
|
transformationHistory = transformationHistory,
|
||||||
contentPadding = PaddingValues(0.dp),
|
contentPadding = PaddingValues(0.dp),
|
||||||
specialMissions = vbSpecialMissions,
|
specialMissions = displaySpecialMissions,
|
||||||
homeScreenController = homeScreenController,
|
homeScreenController = homeScreenController,
|
||||||
onClickCollect = { item, currency ->
|
onClickCollect = { item, currency ->
|
||||||
collectedItem = item
|
collectedItem = item
|
||||||
@ -225,3 +213,5 @@ fun HomeScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -0,0 +1,241 @@
|
|||||||
|
package com.github.nacabaro.vbhelper.screens.homeScreens.screens
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.aspectRatio
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import com.github.nacabaro.vbhelper.R
|
||||||
|
import com.github.nacabaro.vbhelper.screens.homeScreens.dialogs.DeleteSpecialMissionDialog
|
||||||
|
import com.github.nacabaro.vbhelper.components.CharacterEntry
|
||||||
|
import com.github.nacabaro.vbhelper.components.ItemDisplay
|
||||||
|
import com.github.nacabaro.vbhelper.components.SpecialMissionsEntry
|
||||||
|
import com.github.nacabaro.vbhelper.components.TransformationHistoryCard
|
||||||
|
import com.github.nacabaro.vbhelper.domain.device_data.SpecialMissions
|
||||||
|
import com.github.nacabaro.vbhelper.domain.device_data.UnifiedCharacter
|
||||||
|
import com.github.nacabaro.vbhelper.dtos.CharacterDtos
|
||||||
|
import com.github.nacabaro.vbhelper.dtos.ItemDtos
|
||||||
|
import com.github.nacabaro.vbhelper.screens.homeScreens.HomeScreenControllerImpl
|
||||||
|
import com.github.nacabaro.vbhelper.screens.itemsScreen.getIconResource
|
||||||
|
import com.github.nacabaro.vbhelper.screens.itemsScreen.ItemsScreenControllerImpl
|
||||||
|
import com.github.nacabaro.vbhelper.utils.BitmapData
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun UnifiedHomeScreen(
|
||||||
|
activeMon: CharacterDtos.CharacterWithSprites,
|
||||||
|
cardIcon: BitmapData,
|
||||||
|
unifiedChar: UnifiedCharacter,
|
||||||
|
specialMissions: List<SpecialMissions>,
|
||||||
|
transformationHistory: List<CharacterDtos.TransformationHistory>,
|
||||||
|
contentPadding: PaddingValues,
|
||||||
|
homeScreenController: HomeScreenControllerImpl,
|
||||||
|
onClickCollect: (ItemDtos.PurchasedItem?, Int?) -> Unit
|
||||||
|
) {
|
||||||
|
var selectedSpecialMissionId by remember { mutableStateOf<Long>(-1) }
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(top = contentPadding.calculateTopPadding())
|
||||||
|
.verticalScroll(state = rememberScrollState())
|
||||||
|
) {
|
||||||
|
// CORE SLOT: Always the same for VB and BE
|
||||||
|
Row(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
CharacterEntry(
|
||||||
|
icon = BitmapData(
|
||||||
|
bitmap = activeMon.spriteIdle,
|
||||||
|
width = activeMon.spriteWidth,
|
||||||
|
height = activeMon.spriteHeight
|
||||||
|
),
|
||||||
|
cardIcon = cardIcon,
|
||||||
|
multiplier = 8,
|
||||||
|
shape = androidx.compose.material.MaterialTheme.shapes.small,
|
||||||
|
modifier = Modifier.weight(1f).aspectRatio(1f)
|
||||||
|
)
|
||||||
|
Column(modifier = Modifier.weight(0.5f).aspectRatio(0.5f)) {
|
||||||
|
ItemDisplay(
|
||||||
|
icon = R.drawable.baseline_vitals_24,
|
||||||
|
textValue = activeMon.vitalPoints.toString(),
|
||||||
|
definition = stringResource(R.string.home_vbdim_vitals),
|
||||||
|
modifier = Modifier.weight(0.5f).aspectRatio(1f).padding(8.dp)
|
||||||
|
)
|
||||||
|
ItemDisplay(
|
||||||
|
icon = R.drawable.baseline_trophy_24,
|
||||||
|
textValue = activeMon.trophies.toString(),
|
||||||
|
definition = stringResource(R.string.home_vbdim_trophies),
|
||||||
|
modifier = Modifier.weight(0.5f).aspectRatio(1f).padding(8.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// STATS ROW 1: mood and timing (VB timer vs BE training limit)
|
||||||
|
Row(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
ItemDisplay(
|
||||||
|
icon = R.drawable.baseline_mood_24,
|
||||||
|
textValue = activeMon.mood.toString(),
|
||||||
|
definition = stringResource(R.string.home_vbdim_mood),
|
||||||
|
modifier = Modifier.weight(1f).aspectRatio(1f).padding(8.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
if (unifiedChar.isVB()) {
|
||||||
|
val transformationCountdownInHours = activeMon.transformationCountdown / 60
|
||||||
|
ItemDisplay(
|
||||||
|
icon = R.drawable.baseline_next_24,
|
||||||
|
textValue = when (transformationCountdownInHours) {
|
||||||
|
0 -> "${activeMon.transformationCountdown} m"
|
||||||
|
else -> "$transformationCountdownInHours h"
|
||||||
|
},
|
||||||
|
definition = stringResource(R.string.home_vbdim_next_timer),
|
||||||
|
modifier = Modifier.weight(1f).aspectRatio(1f).padding(8.dp)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
val timeInHours = (unifiedChar.beData?.remainingTrainingTimeInMinutes ?: 0) / 60
|
||||||
|
ItemDisplay(
|
||||||
|
icon = R.drawable.baseline_timer_24,
|
||||||
|
textValue = "$timeInHours h",
|
||||||
|
definition = "Training limit",
|
||||||
|
modifier = Modifier.weight(1f).aspectRatio(1f).padding(8.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (unifiedChar.isBE()) {
|
||||||
|
ItemDisplay(
|
||||||
|
icon = R.drawable.baseline_rank_24,
|
||||||
|
textValue = (unifiedChar.beData?.rank ?: 0).toString(),
|
||||||
|
definition = "Rank",
|
||||||
|
modifier = Modifier.weight(1f).aspectRatio(1f).padding(8.dp)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
// For VB, maybe put a spacer or other stat here to keep row balance
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// STATS ROW 2: Battle stats and Items
|
||||||
|
Row(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
val transformationCountdownInHours = activeMon.transformationCountdown / 60
|
||||||
|
ItemDisplay(
|
||||||
|
icon = R.drawable.baseline_next_24,
|
||||||
|
textValue = when (transformationCountdownInHours) {
|
||||||
|
0 -> "${activeMon.transformationCountdown} m"
|
||||||
|
else -> "$transformationCountdownInHours h"
|
||||||
|
},
|
||||||
|
definition = "Next timer",
|
||||||
|
modifier = Modifier.weight(1f).aspectRatio(1f).padding(8.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Battle stats (Common)
|
||||||
|
ItemDisplay(
|
||||||
|
icon = R.drawable.baseline_swords_24,
|
||||||
|
textValue = calculateWinPercent(activeMon.totalBattlesWon, activeMon.totalBattlesLost),
|
||||||
|
definition = "Total battle win %",
|
||||||
|
modifier = Modifier.weight(1f).aspectRatio(1f).padding(8.dp)
|
||||||
|
)
|
||||||
|
ItemDisplay(
|
||||||
|
icon = R.drawable.baseline_swords_24,
|
||||||
|
textValue = calculateWinPercent(activeMon.currentPhaseBattlesWon, activeMon.currentPhaseBattlesLost),
|
||||||
|
definition = "Current phase win %",
|
||||||
|
modifier = Modifier.weight(1f).aspectRatio(1f).padding(8.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
if (unifiedChar.isBE()) {
|
||||||
|
val beData = unifiedChar.beData
|
||||||
|
if (beData != null && beData.itemRemainingTime != 0) {
|
||||||
|
ItemDisplay(
|
||||||
|
icon = getIconResource(beData.itemType),
|
||||||
|
textValue = "${beData.itemRemainingTime} m",
|
||||||
|
definition = getItemTypeName(beData.itemType),
|
||||||
|
modifier = Modifier.weight(1f).aspectRatio(1f).padding(8.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HISTORY SLOT: Common
|
||||||
|
Row(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
TransformationHistoryCard(
|
||||||
|
transformationHistory = transformationHistory,
|
||||||
|
modifier = Modifier.weight(1f).padding(8.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CONTEXTUAL BOTTOM SLOT
|
||||||
|
if (unifiedChar.isVB()) {
|
||||||
|
Row(modifier = Modifier.padding(16.dp)) {
|
||||||
|
Text(text = stringResource(R.string.home_vbdim_special_missions), fontSize = 24.sp)
|
||||||
|
}
|
||||||
|
for (mission in specialMissions) {
|
||||||
|
Row(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
SpecialMissionsEntry(
|
||||||
|
specialMission = mission,
|
||||||
|
modifier = Modifier.weight(1f).padding(8.dp),
|
||||||
|
onClickMission = { missionId -> selectedSpecialMissionId = missionId },
|
||||||
|
onClickCollect = { homeScreenController.clearSpecialMission(selectedSpecialMissionId, onClickCollect) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (unifiedChar.isBE()) {
|
||||||
|
Row(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
val beData = unifiedChar.beData ?: return@Column
|
||||||
|
ItemDisplay(
|
||||||
|
icon = R.drawable.baseline_health_24,
|
||||||
|
textValue = "+${beData.trainingHp}",
|
||||||
|
definition = "Training HP",
|
||||||
|
modifier = Modifier.weight(1f).aspectRatio(1f).padding(8.dp)
|
||||||
|
)
|
||||||
|
ItemDisplay(
|
||||||
|
icon = R.drawable.baseline_agility_24,
|
||||||
|
textValue = "+${beData.trainingBp}",
|
||||||
|
definition = "Training BP",
|
||||||
|
modifier = Modifier.weight(1f).aspectRatio(1f).padding(8.dp)
|
||||||
|
)
|
||||||
|
ItemDisplay(
|
||||||
|
icon = R.drawable.baseline_attack_24,
|
||||||
|
textValue = "+${beData.trainingAp}",
|
||||||
|
definition = "Training AP",
|
||||||
|
modifier = Modifier.weight(1f).aspectRatio(1f).padding(8.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedSpecialMissionId.toInt() != -1 && unifiedChar.isVB()) {
|
||||||
|
DeleteSpecialMissionDialog(
|
||||||
|
onClickDismiss = { selectedSpecialMissionId = -1 },
|
||||||
|
onClickDelete = {
|
||||||
|
homeScreenController.clearSpecialMission(selectedSpecialMissionId, onClickCollect)
|
||||||
|
selectedSpecialMissionId = -1
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun calculateWinPercent(won: Int, lost: Int): String {
|
||||||
|
return if (lost == 0) "0.00 %" else {
|
||||||
|
val percentage = won.toFloat() / (won + lost).toFloat()
|
||||||
|
String.format(Locale.getDefault(), "%.2f", percentage * 100) + " %"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getItemTypeName(itemId: Int): String {
|
||||||
|
return when (itemId) {
|
||||||
|
ItemsScreenControllerImpl.ItemTypes.PPTraining.id -> "PP Training"
|
||||||
|
ItemsScreenControllerImpl.ItemTypes.HPTraining.id -> "HP Training"
|
||||||
|
ItemsScreenControllerImpl.ItemTypes.APTraining.id -> "AP Training"
|
||||||
|
ItemsScreenControllerImpl.ItemTypes.BPTraining.id -> "BP Training"
|
||||||
|
ItemsScreenControllerImpl.ItemTypes.AllTraining.id -> "All Training"
|
||||||
|
else -> ""
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -8,12 +8,17 @@ import androidx.compose.foundation.layout.size
|
|||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.ui.res.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.github.nacabaro.vbhelper.dtos.ItemDtos
|
import com.github.nacabaro.vbhelper.dtos.ItemDtos
|
||||||
|
import com.github.nacabaro.vbhelper.domain.items.ItemType
|
||||||
import com.github.nacabaro.vbhelper.R
|
import com.github.nacabaro.vbhelper.R
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@ -28,6 +33,12 @@ fun ItemElement(
|
|||||||
.aspectRatio(1f)
|
.aspectRatio(1f)
|
||||||
) {
|
) {
|
||||||
Box(modifier = Modifier.fillMaxSize()) {
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
|
ItemCompatibilityBadge(
|
||||||
|
itemType = item.itemType,
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.TopEnd)
|
||||||
|
.padding(8.dp)
|
||||||
|
)
|
||||||
Icon(
|
Icon(
|
||||||
painter = painterResource(id = getIconResource(item.itemIcon)),
|
painter = painterResource(id = getIconResource(item.itemIcon)),
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
@ -48,3 +59,48 @@ 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.foundation.layout.padding
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.Tab
|
import androidx.compose.material3.Tab
|
||||||
import androidx.compose.material3.TabRow
|
import androidx.compose.material3.PrimaryTabRow
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
@ -34,7 +34,7 @@ fun ItemsScreen(
|
|||||||
topBar = {
|
topBar = {
|
||||||
Column {
|
Column {
|
||||||
TopBanner(text = stringResource(R.string.items_title))
|
TopBanner(text = stringResource(R.string.items_title))
|
||||||
TabRow(
|
PrimaryTabRow(
|
||||||
selectedTabIndex = selectedTabItem,
|
selectedTabIndex = selectedTabItem,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
) {
|
) {
|
||||||
|
|||||||
@ -7,7 +7,6 @@ import com.github.nacabaro.vbhelper.domain.device_data.SpecialMissions
|
|||||||
import com.github.nacabaro.vbhelper.database.AppDatabase
|
import com.github.nacabaro.vbhelper.database.AppDatabase
|
||||||
import com.github.nacabaro.vbhelper.di.VBHelper
|
import com.github.nacabaro.vbhelper.di.VBHelper
|
||||||
import com.github.nacabaro.vbhelper.domain.device_data.BECharacterData
|
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.dtos.ItemDtos
|
||||||
import com.github.nacabaro.vbhelper.utils.DeviceType
|
import com.github.nacabaro.vbhelper.utils.DeviceType
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
@ -51,19 +50,12 @@ class ItemsScreenControllerImpl (
|
|||||||
val item = getItem(itemId)
|
val item = getItem(itemId)
|
||||||
val characterData = database.userCharacterDao().getCharacter(characterId)
|
val characterData = database.userCharacterDao().getCharacter(characterId)
|
||||||
var beCharacterData: BECharacterData? = null
|
var beCharacterData: BECharacterData? = null
|
||||||
var vbCharacterData: VBCharacterData? = null
|
|
||||||
|
|
||||||
if (characterData.characterType == DeviceType.BEDevice) {
|
if (characterData.characterType == DeviceType.BEDevice) {
|
||||||
beCharacterData = database
|
beCharacterData = database
|
||||||
.userCharacterDao()
|
.userCharacterDao()
|
||||||
.getBeData(characterId)
|
.getBeData(characterId)
|
||||||
.firstOrNull()
|
.firstOrNull()
|
||||||
|
|
||||||
} else if (characterData.characterType == DeviceType.VBDevice) {
|
|
||||||
vbCharacterData = database
|
|
||||||
.userCharacterDao()
|
|
||||||
.getVbData(characterId)
|
|
||||||
.firstOrNull()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@ -122,8 +114,7 @@ class ItemsScreenControllerImpl (
|
|||||||
.updateCharacter(characterData)
|
.updateCharacter(characterData)
|
||||||
|
|
||||||
} else if (item.itemIcon in ItemTypes.Step8k.id .. ItemTypes.Win4.id &&
|
} else if (item.itemIcon in ItemTypes.Step8k.id .. ItemTypes.Win4.id &&
|
||||||
characterData.characterType == DeviceType.VBDevice &&
|
(characterData.characterType == DeviceType.VBDevice || characterData.characterType == DeviceType.BEDevice)
|
||||||
vbCharacterData != null
|
|
||||||
) {
|
) {
|
||||||
applySpecialMission(item.itemIcon, item.itemLength, characterId)
|
applySpecialMission(item.itemIcon, item.itemLength, characterId)
|
||||||
}
|
}
|
||||||
@ -180,12 +171,12 @@ class ItemsScreenControllerImpl (
|
|||||||
.getSpecialMissions(characterId)
|
.getSpecialMissions(characterId)
|
||||||
.first()
|
.first()
|
||||||
|
|
||||||
var newSpecialMission = availableSpecialMissions[specialMissionSlot]
|
val existingMission = availableSpecialMissions.firstOrNull { it.watchId == specialMissionSlot }
|
||||||
newSpecialMission = SpecialMissions(
|
val newSpecialMission = SpecialMissions(
|
||||||
id = newSpecialMission.id,
|
id = existingMission?.id ?: 0,
|
||||||
characterId = newSpecialMission.characterId,
|
characterId = characterId,
|
||||||
goal = specialMissionGoal,
|
goal = specialMissionGoal,
|
||||||
watchId = newSpecialMission.watchId,
|
watchId = specialMissionSlot,
|
||||||
progress = 0,
|
progress = 0,
|
||||||
status = SpecialMission.Status.AVAILABLE,
|
status = SpecialMission.Status.AVAILABLE,
|
||||||
timeElapsedInMinutes = 0,
|
timeElapsedInMinutes = 0,
|
||||||
|
|||||||
@ -43,7 +43,7 @@ fun ChooseConnectOption(
|
|||||||
.padding(contentPadding)
|
.padding(contentPadding)
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = "Bandai Toys = Vital Bracelet, Vital Hero, BE Bracelet",
|
text = "VitalWear/VBH",
|
||||||
fontSize = 14.sp,
|
fontSize = 14.sp,
|
||||||
modifier = Modifier.padding(bottom = 24.dp)
|
modifier = Modifier.padding(bottom = 24.dp)
|
||||||
)
|
)
|
||||||
|
|||||||
@ -144,7 +144,12 @@ fun ScanScreenPreview() {
|
|||||||
override fun flushCharacter(cardId: Long) {}
|
override fun flushCharacter(cardId: Long) {}
|
||||||
override fun onClickRead(secrets: Secrets, onComplete: ()->Unit, onMultipleCards: (List<Card>) -> Unit) {}
|
override fun onClickRead(secrets: Secrets, onComplete: ()->Unit, onMultipleCards: (List<Card>) -> Unit) {}
|
||||||
override fun onClickCheckCard(secrets: Secrets, nfcCharacter: NfcCharacter, onComplete: () -> Unit) {}
|
override fun onClickCheckCard(secrets: Secrets, nfcCharacter: NfcCharacter, onComplete: () -> Unit) {}
|
||||||
override fun onClickWrite(secrets: Secrets, nfcCharacter: NfcCharacter, onComplete: (ScanScreenController.WriteResult) -> Unit) {}
|
override fun onClickWrite(
|
||||||
|
secrets: Secrets,
|
||||||
|
nfcCharacter: NfcCharacter,
|
||||||
|
characterId: Long?,
|
||||||
|
onComplete: (ScanScreenController.WriteResult) -> Unit
|
||||||
|
) {}
|
||||||
override fun cancelRead() {}
|
override fun cancelRead() {}
|
||||||
override fun characterFromNfc(nfcCharacter: NfcCharacter, onMultipleCards: (List<Card>, NfcCharacter) -> Unit): String { return "" }
|
override fun characterFromNfc(nfcCharacter: NfcCharacter, onMultipleCards: (List<Card>, NfcCharacter) -> Unit): String { return "" }
|
||||||
override suspend fun characterToNfc(characterId: Long): NfcCharacter? { return null }
|
override suspend fun characterToNfc(characterId: Long): NfcCharacter? { return null }
|
||||||
|
|||||||
@ -20,7 +20,12 @@ interface ScanScreenController {
|
|||||||
|
|
||||||
fun onClickRead(secrets: Secrets, onComplete: ()->Unit, onMultipleCards: (List<Card>) -> Unit)
|
fun onClickRead(secrets: Secrets, onComplete: ()->Unit, onMultipleCards: (List<Card>) -> Unit)
|
||||||
fun onClickCheckCard(secrets: Secrets, nfcCharacter: NfcCharacter, onComplete: () -> Unit)
|
fun onClickCheckCard(secrets: Secrets, nfcCharacter: NfcCharacter, onComplete: () -> Unit)
|
||||||
fun onClickWrite(secrets: Secrets, nfcCharacter: NfcCharacter, onComplete: (WriteResult) -> Unit)
|
fun onClickWrite(
|
||||||
|
secrets: Secrets,
|
||||||
|
nfcCharacter: NfcCharacter,
|
||||||
|
characterId: Long? = null,
|
||||||
|
onComplete: (WriteResult) -> Unit
|
||||||
|
)
|
||||||
|
|
||||||
fun cancelRead()
|
fun cancelRead()
|
||||||
|
|
||||||
|
|||||||
@ -12,8 +12,10 @@ import android.widget.Toast
|
|||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import com.github.cfogrady.vbnfc.TagCommunicator
|
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.be.BENfcCharacter
|
||||||
import com.github.cfogrady.vbnfc.data.NfcCharacter
|
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.cfogrady.vbnfc.vb.VBNfcCharacter
|
||||||
import com.github.nacabaro.vbhelper.ActivityLifecycleListener
|
import com.github.nacabaro.vbhelper.ActivityLifecycleListener
|
||||||
import com.github.nacabaro.vbhelper.domain.card.Card
|
import com.github.nacabaro.vbhelper.domain.card.Card
|
||||||
@ -22,13 +24,11 @@ import com.github.nacabaro.vbhelper.screens.scanScreen.converters.FromNfcConvert
|
|||||||
import com.github.nacabaro.vbhelper.screens.scanScreen.converters.ToNfcConverter
|
import com.github.nacabaro.vbhelper.screens.scanScreen.converters.ToNfcConverter
|
||||||
import com.github.nacabaro.vbhelper.source.VitalWearCharacterExporter
|
import com.github.nacabaro.vbhelper.source.VitalWearCharacterExporter
|
||||||
import com.github.nacabaro.vbhelper.source.VitalWearCharacterImporter
|
import com.github.nacabaro.vbhelper.source.VitalWearCharacterImporter
|
||||||
import com.github.nacabaro.vbhelper.transfer.ExportFormat
|
|
||||||
import com.github.nacabaro.vbhelper.source.getCryptographicTransformerMap
|
import com.github.nacabaro.vbhelper.source.getCryptographicTransformerMap
|
||||||
import com.github.nacabaro.vbhelper.source.isMissingSecrets
|
import com.github.nacabaro.vbhelper.source.isMissingSecrets
|
||||||
import com.github.nacabaro.vbhelper.source.proto.Secrets
|
import com.github.nacabaro.vbhelper.source.proto.Secrets
|
||||||
import com.github.nacabaro.vbhelper.transfer.TransferTarget
|
|
||||||
import com.github.nacabaro.vbhelper.transfer.TransferTransport
|
|
||||||
import com.github.nacabaro.vbhelper.transfer.hce.VitalWearHceReaderClient
|
import com.github.nacabaro.vbhelper.transfer.hce.VitalWearHceReaderClient
|
||||||
|
import com.github.nacabaro.vbhelper.utils.DeviceType
|
||||||
import com.github.nacabaro.vbhelper.R
|
import com.github.nacabaro.vbhelper.R
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
@ -57,6 +57,7 @@ class ScanScreenControllerImpl(
|
|||||||
private val _transferStatusFlow = MutableStateFlow<String?>(null)
|
private val _transferStatusFlow = MutableStateFlow<String?>(null)
|
||||||
private var lastScannedCharacter: NfcCharacter? = null
|
private var lastScannedCharacter: NfcCharacter? = null
|
||||||
private var lastRequestedCharacterId: Long? = null
|
private var lastRequestedCharacterId: Long? = null
|
||||||
|
private var lastWriteCharacterId: Long? = null
|
||||||
private val nfcAdapter: NfcAdapter
|
private val nfcAdapter: NfcAdapter
|
||||||
private val isHandlingTag = AtomicBoolean(false)
|
private val isHandlingTag = AtomicBoolean(false)
|
||||||
private var lastTagId: ByteArray? = null
|
private var lastTagId: ByteArray? = null
|
||||||
@ -122,7 +123,6 @@ class ScanScreenControllerImpl(
|
|||||||
val options = Bundle()
|
val options = Bundle()
|
||||||
options.putInt(NfcAdapter.EXTRA_READER_PRESENCE_CHECK_DELAY, 250)
|
options.putInt(NfcAdapter.EXTRA_READER_PRESENCE_CHECK_DELAY, 250)
|
||||||
val flags = NfcAdapter.FLAG_READER_NFC_A or
|
val flags = NfcAdapter.FLAG_READER_NFC_A or
|
||||||
NfcAdapter.FLAG_READER_NFC_B or
|
|
||||||
NfcAdapter.FLAG_READER_NO_PLATFORM_SOUNDS or
|
NfcAdapter.FLAG_READER_NO_PLATFORM_SOUNDS or
|
||||||
NfcAdapter.FLAG_READER_SKIP_NDEF_CHECK
|
NfcAdapter.FLAG_READER_SKIP_NDEF_CHECK
|
||||||
runCatching {
|
runCatching {
|
||||||
@ -166,9 +166,11 @@ class ScanScreenControllerImpl(
|
|||||||
val application = componentActivity.applicationContext as VBHelper
|
val application = componentActivity.applicationContext as VBHelper
|
||||||
val importer = VitalWearCharacterImporter(application.container.db, application.container.transferSeenDao)
|
val importer = VitalWearCharacterImporter(application.container.db, application.container.transferSeenDao)
|
||||||
try {
|
try {
|
||||||
|
// ISO-DEP/HCE route: VitalWear characters are always imported as BE device type.
|
||||||
var importResult: VitalWearCharacterImporter.ImportResult? = null
|
var importResult: VitalWearCharacterImporter.ImportResult? = null
|
||||||
val moved = VitalWearHceReaderClient(isoDep).moveCharacterFromWatch { character ->
|
val moved = VitalWearHceReaderClient(isoDep).moveCharacterFromWatch { character ->
|
||||||
val result = importer.importCharacter(character)
|
// Force imported device type to BE since source is VitalWear HCE (BE only).
|
||||||
|
val result = importer.importCharacter(character, forcedDeviceType = DeviceType.BEDevice)
|
||||||
importResult = result
|
importResult = result
|
||||||
result.success
|
result.success
|
||||||
}
|
}
|
||||||
@ -190,13 +192,46 @@ class ScanScreenControllerImpl(
|
|||||||
|
|
||||||
// ---- Write (phone sends character TO watch or bracelet) ------------------------
|
// ---- Write (phone sends character TO watch or bracelet) ------------------------
|
||||||
|
|
||||||
override fun onClickWrite(secrets: Secrets, nfcCharacter: NfcCharacter, onComplete: (ScanScreenController.WriteResult) -> Unit) {
|
override fun onClickWrite(
|
||||||
|
secrets: Secrets,
|
||||||
|
nfcCharacter: NfcCharacter,
|
||||||
|
characterId: Long?,
|
||||||
|
onComplete: (ScanScreenController.WriteResult) -> Unit
|
||||||
|
) {
|
||||||
_detectedTransportFlow.value = DetectedTransport.UNKNOWN
|
_detectedTransportFlow.value = DetectedTransport.UNKNOWN
|
||||||
setTransferStatus(R.string.scan_transfer_waiting_tap)
|
setTransferStatus(R.string.scan_transfer_waiting_tap)
|
||||||
handleTag(
|
handleTag(
|
||||||
secrets,
|
secrets,
|
||||||
nfcAHandler = { tagCommunicator ->
|
nfcAHandler = { tagCommunicator ->
|
||||||
try {
|
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)
|
val initialSlotState = readNfcASlotState(tagCommunicator)
|
||||||
if (initialSlotState.isFull()) {
|
if (initialSlotState.isFull()) {
|
||||||
onComplete.invoke(ScanScreenController.WriteResult.BLOCKED_DEVICE_FULL)
|
onComplete.invoke(ScanScreenController.WriteResult.BLOCKED_DEVICE_FULL)
|
||||||
@ -209,59 +244,34 @@ class ScanScreenControllerImpl(
|
|||||||
return@handleTag migrationCheck.message
|
return@handleTag migrationCheck.message
|
||||||
}
|
}
|
||||||
|
|
||||||
when (nfcCharacter) {
|
// Send with VB profile (which was forced above). Real bracelets only accept VBNfcCharacter.
|
||||||
is VBNfcCharacter -> tagCommunicator.sendCharacter(nfcCharacter)
|
return@handleTag try {
|
||||||
is BENfcCharacter -> tagCommunicator.sendCharacter(nfcCharacter)
|
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)
|
onComplete.invoke(ScanScreenController.WriteResult.MOVE_CONFIRMED)
|
||||||
componentActivity.getString(R.string.scan_sent_character_success)
|
componentActivity.getString(R.string.scan_sent_character_success)
|
||||||
} catch (writeError: Throwable) {
|
} catch (deviceTypeMismatch: Exception) {
|
||||||
Log.e("NFC_WRITE_A", "NFC-A write failed; trying alternate payload format", writeError)
|
// Device type mismatch on NFC-A is terminal for this tap.
|
||||||
val characterId = lastRequestedCharacterId
|
if (deviceTypeMismatch.message?.contains("Character doesn't match device type") == true) {
|
||||||
val alternateFormat = when (nfcCharacter) {
|
Log.e("NFC_WRITE_A", "Device type mismatch on NFC-A with selected transfer profile", deviceTypeMismatch)
|
||||||
is BENfcCharacter -> ExportFormat.VB
|
|
||||||
is VBNfcCharacter -> ExportFormat.BE
|
|
||||||
else -> null
|
|
||||||
}
|
|
||||||
|
|
||||||
val alternateSent = if (characterId != null && alternateFormat != null) {
|
|
||||||
runCatching {
|
|
||||||
val alternateCharacter = runBlocking {
|
|
||||||
ToNfcConverter(componentActivity = componentActivity).characterToNfc(
|
|
||||||
characterId = characterId,
|
|
||||||
target = TransferTarget.REAL_BRACELET,
|
|
||||||
transport = TransferTransport.NFCA,
|
|
||||||
forcedFormat = alternateFormat,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
when (alternateCharacter) {
|
|
||||||
is VBNfcCharacter -> tagCommunicator.sendCharacter(alternateCharacter)
|
|
||||||
is BENfcCharacter -> tagCommunicator.sendCharacter(alternateCharacter)
|
|
||||||
}
|
|
||||||
true
|
|
||||||
}.getOrElse { alternateError ->
|
|
||||||
Log.e("NFC_WRITE_A", "Alternate payload write failed", alternateError)
|
|
||||||
false
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
|
|
||||||
if (alternateSent) {
|
|
||||||
onComplete.invoke(ScanScreenController.WriteResult.MOVE_CONFIRMED)
|
|
||||||
"Sent character after converting payload for the tapped bracelet."
|
|
||||||
} else {
|
|
||||||
Log.e("NFC_WRITE_A", "NFC-A write failed; attempting opposite direction fallback", writeError)
|
|
||||||
runCatching {
|
|
||||||
val receivedCharacter = tagCommunicator.receiveCharacter()
|
|
||||||
val fallbackMessage = importCharacterFromNfcAFallback(receivedCharacter)
|
|
||||||
onComplete.invoke(ScanScreenController.WriteResult.COPIED)
|
onComplete.invoke(ScanScreenController.WriteResult.COPIED)
|
||||||
fallbackMessage
|
"Transfer failed: bracelet rejected character profile for this tap. Retry and keep bracelet steady."
|
||||||
}.getOrElse { readFallbackError ->
|
} else {
|
||||||
Log.e("NFC_WRITE_A", "NFC-A opposite-direction fallback failed", readFallbackError)
|
Log.e("NFC_WRITE_A", "NFC-A write failed", deviceTypeMismatch)
|
||||||
componentActivity.getString(R.string.scan_error_generic)
|
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 ->
|
isoDepHandler = { isoDep ->
|
||||||
@ -270,69 +280,35 @@ class ScanScreenControllerImpl(
|
|||||||
val application = componentActivity.applicationContext as VBHelper
|
val application = componentActivity.applicationContext as VBHelper
|
||||||
val hceClient = VitalWearHceReaderClient(isoDep)
|
val hceClient = VitalWearHceReaderClient(isoDep)
|
||||||
try {
|
try {
|
||||||
|
// ISO-DEP/HCE route: VitalWear always uses BE profile (enforced).
|
||||||
|
// Create protobuf with forced BE device type for correct serialization.
|
||||||
val proto = runBlocking {
|
val proto = runBlocking {
|
||||||
VitalWearCharacterExporter(application.container.db)
|
VitalWearCharacterExporter(application.container.db)
|
||||||
.buildCharacterProto(characterId)
|
.buildCharacterProto(characterId, forcedTransferProfile = DeviceType.BEDevice)
|
||||||
}
|
}
|
||||||
hceClient.sendCharacterToWatch(proto)
|
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)
|
onComplete.invoke(ScanScreenController.WriteResult.MOVE_CONFIRMED)
|
||||||
componentActivity.getString(R.string.scan_sent_character_success)
|
componentActivity.getString(R.string.scan_sent_character_success)
|
||||||
} catch (writeError: Throwable) {
|
|
||||||
Log.e("NFC_WRITE_HCE", "HCE write failed; attempting opposite direction", writeError)
|
|
||||||
runCatching {
|
|
||||||
var importResult: VitalWearCharacterImporter.ImportResult? = null
|
|
||||||
val moved = hceClient.moveCharacterFromWatch { character ->
|
|
||||||
val result = VitalWearCharacterImporter(application.container.db, application.container.transferSeenDao).importCharacter(character)
|
|
||||||
importResult = result
|
|
||||||
result.success
|
|
||||||
}
|
|
||||||
onComplete.invoke(ScanScreenController.WriteResult.COPIED)
|
|
||||||
if (moved) {
|
|
||||||
importResult?.message
|
|
||||||
?: "Detected source on watch and imported it instead of sending."
|
|
||||||
} else {
|
} else {
|
||||||
importResult?.message
|
// Transfer acknowledging but watch import failed or did not confirm.
|
||||||
?: "Watch is in source mode, but import was rejected."
|
onComplete.invoke(ScanScreenController.WriteResult.COPIED)
|
||||||
}
|
"Transfer sent but watch did not confirm import. Source was kept in VBH."
|
||||||
}.getOrElse { readFallbackError ->
|
|
||||||
Log.e("NFC_WRITE_HCE", "HCE opposite-direction fallback failed", readFallbackError)
|
|
||||||
componentActivity.getString(R.string.scan_error_generic)
|
|
||||||
}
|
}
|
||||||
|
} catch (writeError: Throwable) {
|
||||||
|
Log.e("NFC_WRITE_HCE", "HCE write failed", writeError)
|
||||||
|
onComplete.invoke(ScanScreenController.WriteResult.COPIED)
|
||||||
|
throw writeError
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun importCharacterFromNfcAFallback(receivedCharacter: NfcCharacter): String {
|
|
||||||
val application = componentActivity.applicationContext as VBHelper
|
|
||||||
val fromNfcConverter = FromNfcConverter(componentActivity)
|
|
||||||
val candidateCards = application.container.db
|
|
||||||
.cardDao()
|
|
||||||
.getCardByCardId(receivedCharacter.dimId.toInt())
|
|
||||||
|
|
||||||
if (candidateCards.isEmpty()) {
|
|
||||||
return "Detected source character on bracelet, but matching card is not imported in VBHelper."
|
|
||||||
}
|
|
||||||
|
|
||||||
val preferredCardId = receivedCharacter.appReserved2
|
|
||||||
.firstOrNull()
|
|
||||||
?.toLong()
|
|
||||||
?.takeIf { it > 0L }
|
|
||||||
val preferredCard = preferredCardId?.let { id ->
|
|
||||||
candidateCards.firstOrNull { it.id == id }
|
|
||||||
}
|
|
||||||
|
|
||||||
val selectedCard = when {
|
|
||||||
preferredCard != null -> preferredCard
|
|
||||||
candidateCards.size == 1 -> candidateCards.single()
|
|
||||||
else -> {
|
|
||||||
lastScannedCharacter = receivedCharacter
|
|
||||||
return "Detected source character, but multiple matching cards exist. Use Read and select the card."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return fromNfcConverter.addCharacterUsingCard(receivedCharacter, selectedCard.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Check card (NFC-A only; ISO-DEP watches don't need a DIM prep) -----------
|
// ---- Check card (NFC-A only; ISO-DEP watches don't need a DIM prep) -----------
|
||||||
|
|
||||||
@ -407,7 +383,6 @@ class ScanScreenControllerImpl(
|
|||||||
// Work around for some broken Nfc firmware implementations that poll the card too fast
|
// Work around for some broken Nfc firmware implementations that poll the card too fast
|
||||||
options.putInt(NfcAdapter.EXTRA_READER_PRESENCE_CHECK_DELAY, 250)
|
options.putInt(NfcAdapter.EXTRA_READER_PRESENCE_CHECK_DELAY, 250)
|
||||||
val flags = NfcAdapter.FLAG_READER_NFC_A or
|
val flags = NfcAdapter.FLAG_READER_NFC_A or
|
||||||
NfcAdapter.FLAG_READER_NFC_B or
|
|
||||||
NfcAdapter.FLAG_READER_NO_PLATFORM_SOUNDS or
|
NfcAdapter.FLAG_READER_NO_PLATFORM_SOUNDS or
|
||||||
NfcAdapter.FLAG_READER_SKIP_NDEF_CHECK
|
NfcAdapter.FLAG_READER_SKIP_NDEF_CHECK
|
||||||
val sessionId = readerSessionCounter.incrementAndGet()
|
val sessionId = readerSessionCounter.incrementAndGet()
|
||||||
@ -450,18 +425,19 @@ class ScanScreenControllerImpl(
|
|||||||
setTransferStatus(R.string.scan_transfer_detected_keep_tap)
|
setTransferStatus(R.string.scan_transfer_detected_keep_tap)
|
||||||
|
|
||||||
// Detect transport once per tap and lock to that route for this transfer.
|
// 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 isoDep = IsoDep.get(tag)
|
||||||
val hasIsoDepRoute = isoDep != null && isoDepHandler != null
|
|
||||||
val hasNfcARoute = NfcA.get(tag) != null
|
val hasNfcARoute = NfcA.get(tag) != null
|
||||||
|
val hasIsoDepHandler = isoDep != null && isoDepHandler != null
|
||||||
|
val confirmedVitalWear = if (hasIsoDepHandler) {
|
||||||
|
runCatching { isVitalWearHceTarget(isoDep) }.getOrDefault(false)
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
// Prefer ISO-DEP whenever present so HCE sessions do not accidentally fall through to NFC-A.
|
if (hasIsoDepHandler && confirmedVitalWear) {
|
||||||
if (hasIsoDepRoute) {
|
|
||||||
val isoDepTarget = isoDep
|
val isoDepTarget = isoDep
|
||||||
val isoDepAction = isoDepHandler
|
val isoDepAction = isoDepHandler
|
||||||
val confirmedVitalWear = runCatching { isVitalWearHceTarget(isoDepTarget) }.getOrDefault(false)
|
|
||||||
if (!confirmedVitalWear) {
|
|
||||||
Log.w("NFC_HCE", "IsoDep tag did not confirm VitalWear AID; attempting ISO-DEP handler without NFC-A fallback")
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
_detectedTransportFlow.value = DetectedTransport.ISO_DEP
|
_detectedTransportFlow.value = DetectedTransport.ISO_DEP
|
||||||
isoDepTarget.connect()
|
isoDepTarget.connect()
|
||||||
@ -480,7 +456,19 @@ class ScanScreenControllerImpl(
|
|||||||
setTransferStatus(R.string.scan_transfer_failed_try_again)
|
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) {
|
} 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)) {
|
if (!handleNfcATag(tag, secrets, nfcAHandler)) {
|
||||||
componentActivity.runOnUiThread {
|
componentActivity.runOnUiThread {
|
||||||
Toast.makeText(componentActivity, componentActivity.getString(R.string.scan_tag_not_vb), Toast.LENGTH_SHORT).show()
|
Toast.makeText(componentActivity, componentActivity.getString(R.string.scan_tag_not_vb), Toast.LENGTH_SHORT).show()
|
||||||
@ -488,6 +476,9 @@ class ScanScreenControllerImpl(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} 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
|
_detectedTransportFlow.value = DetectedTransport.UNKNOWN
|
||||||
componentActivity.runOnUiThread {
|
componentActivity.runOnUiThread {
|
||||||
Toast.makeText(componentActivity, componentActivity.getString(R.string.scan_tag_not_vb), Toast.LENGTH_SHORT).show()
|
Toast.makeText(componentActivity, componentActivity.getString(R.string.scan_tag_not_vb), Toast.LENGTH_SHORT).show()
|
||||||
@ -677,10 +668,10 @@ class ScanScreenControllerImpl(
|
|||||||
val migrationVerified = afterState.backupPresent == true ||
|
val migrationVerified = afterState.backupPresent == true ||
|
||||||
(beforeState.count != null && afterState.count != null && afterState.count >= beforeState.count)
|
(beforeState.count != null && afterState.count != null && afterState.count >= beforeState.count)
|
||||||
if (!migrationVerified) {
|
if (!migrationVerified) {
|
||||||
return SlotMigrationCheck(
|
// Some real bracelets do not expose stable occupancy signals through the current
|
||||||
canProceed = false,
|
// reflection-based probing. In that case, prefer compatibility over false blocks.
|
||||||
message = "Transfer blocked. Backup slot could not be verified after migration."
|
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 = "")
|
return SlotMigrationCheck(canProceed = true, message = "")
|
||||||
@ -746,11 +737,7 @@ class ScanScreenControllerImpl(
|
|||||||
|
|
||||||
override suspend fun characterToNfc(characterId: Long): NfcCharacter {
|
override suspend fun characterToNfc(characterId: Long): NfcCharacter {
|
||||||
lastRequestedCharacterId = characterId
|
lastRequestedCharacterId = characterId
|
||||||
val character = ToNfcConverter(componentActivity = componentActivity).characterToNfc(
|
val character = ToNfcConverter(componentActivity = componentActivity).characterToNfc(characterId)
|
||||||
characterId = characterId,
|
|
||||||
target = TransferTarget.REAL_BRACELET,
|
|
||||||
transport = TransferTransport.NFCA,
|
|
||||||
)
|
|
||||||
Log.d("CharacterType", character.toString())
|
Log.d("CharacterType", character.toString())
|
||||||
return character
|
return character
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,51 @@
|
|||||||
|
package com.github.nacabaro.vbhelper.screens.scanScreen
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.VibrationEffect
|
||||||
|
import android.os.Vibrator
|
||||||
|
import android.os.VibratorManager
|
||||||
|
import timber.log.Timber
|
||||||
|
|
||||||
|
class TransferHaptics(context: Context) {
|
||||||
|
private val vibrator: Vibrator? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||||
|
val manager = context.getSystemService(VibratorManager::class.java)
|
||||||
|
manager?.defaultVibrator
|
||||||
|
} else {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
context.getSystemService(Context.VIBRATOR_SERVICE) as? Vibrator
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onTransferStart() {
|
||||||
|
vibrate(VibrationEffect.createOneShot(100L, VibrationEffect.DEFAULT_AMPLITUDE))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onTransferProgress() {
|
||||||
|
vibrate(VibrationEffect.createOneShot(50L, VibrationEffect.DEFAULT_AMPLITUDE))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onTransferComplete() {
|
||||||
|
vibrate(VibrationEffect.createWaveform(longArrayOf(0L, 100L, 80L, 150L), -1))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onTransferFailed() {
|
||||||
|
vibrate(VibrationEffect.createOneShot(250L, VibrationEffect.DEFAULT_AMPLITUDE))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onDeviceDetected() {
|
||||||
|
vibrate(VibrationEffect.createOneShot(80L, VibrationEffect.DEFAULT_AMPLITUDE))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun vibrate(effect: VibrationEffect) {
|
||||||
|
val deviceVibrator = vibrator ?: return
|
||||||
|
if (!deviceVibrator.hasVibrator()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
runCatching {
|
||||||
|
deviceVibrator.vibrate(effect)
|
||||||
|
}.onFailure { error ->
|
||||||
|
Timber.w(error, "Transfer haptics vibration skipped")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@ -11,8 +11,6 @@ import com.github.nacabaro.vbhelper.domain.device_data.SpecialMissions
|
|||||||
import com.github.nacabaro.vbhelper.domain.device_data.UserCharacter
|
import com.github.nacabaro.vbhelper.domain.device_data.UserCharacter
|
||||||
import com.github.nacabaro.vbhelper.domain.device_data.VBCharacterData
|
import com.github.nacabaro.vbhelper.domain.device_data.VBCharacterData
|
||||||
import com.github.nacabaro.vbhelper.domain.device_data.VitalsHistory
|
import com.github.nacabaro.vbhelper.domain.device_data.VitalsHistory
|
||||||
import com.github.nacabaro.vbhelper.transfer.CharacterTransferPolicyResolver
|
|
||||||
import com.github.nacabaro.vbhelper.transfer.ExportFormat
|
|
||||||
import com.github.nacabaro.vbhelper.utils.DeviceType
|
import com.github.nacabaro.vbhelper.utils.DeviceType
|
||||||
import java.util.GregorianCalendar
|
import java.util.GregorianCalendar
|
||||||
|
|
||||||
@ -22,7 +20,6 @@ class FromNfcConverter (
|
|||||||
private val application = componentActivity.applicationContext as VBHelper
|
private val application = componentActivity.applicationContext as VBHelper
|
||||||
private val database = application.container.db
|
private val database = application.container.db
|
||||||
private val transferSeenDao = application.container.transferSeenDao
|
private val transferSeenDao = application.container.transferSeenDao
|
||||||
private val transferPolicyResolver = CharacterTransferPolicyResolver(database.characterTransferPolicyDao())
|
|
||||||
|
|
||||||
|
|
||||||
fun addCharacterUsingCard(
|
fun addCharacterUsingCard(
|
||||||
@ -130,6 +127,8 @@ class FromNfcConverter (
|
|||||||
characterId = characterId,
|
characterId = characterId,
|
||||||
nfcCharacter = nfcCharacter
|
nfcCharacter = nfcCharacter
|
||||||
)
|
)
|
||||||
|
// Keep a VB profile alongside BE stats for later VB-target exports.
|
||||||
|
addVbCharacterProfileFromBe(characterId, nfcCharacter)
|
||||||
} else if (nfcCharacter is VBNfcCharacter) {
|
} else if (nfcCharacter is VBNfcCharacter) {
|
||||||
addVbCharacterToDatabase(
|
addVbCharacterToDatabase(
|
||||||
characterId = characterId,
|
characterId = characterId,
|
||||||
@ -137,18 +136,6 @@ class FromNfcConverter (
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
database
|
|
||||||
.characterTransferPolicyDao()
|
|
||||||
.upsert(
|
|
||||||
transferPolicyResolver.policyForNfcaImport(
|
|
||||||
characterId = characterId,
|
|
||||||
observedFormat = when (nfcCharacter) {
|
|
||||||
is BENfcCharacter -> ExportFormat.BE
|
|
||||||
else -> ExportFormat.VB
|
|
||||||
}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
addTransformationHistoryToDatabase(
|
addTransformationHistoryToDatabase(
|
||||||
characterId = characterId,
|
characterId = characterId,
|
||||||
nfcCharacter = nfcCharacter,
|
nfcCharacter = nfcCharacter,
|
||||||
@ -248,8 +235,8 @@ class FromNfcConverter (
|
|||||||
itemType = nfcCharacter.itemType.toInt(),
|
itemType = nfcCharacter.itemType.toInt(),
|
||||||
itemMultiplier = nfcCharacter.itemMultiplier.toInt(),
|
itemMultiplier = nfcCharacter.itemMultiplier.toInt(),
|
||||||
itemRemainingTime = nfcCharacter.itemRemainingTime.toInt(),
|
itemRemainingTime = nfcCharacter.itemRemainingTime.toInt(),
|
||||||
otp0 = "", //nfcCharacter.value!!.otp0.toString(),
|
otp0 = nfcCharacter.getOtp0().joinToString("") { "%02x".format(it) },
|
||||||
otp1 = "", //nfcCharacter.value!!.otp1.toString(),
|
otp1 = nfcCharacter.getOtp1().joinToString("") { "%02x".format(it) },
|
||||||
minorVersion = nfcCharacter.characterCreationFirmwareVersion.minorVersion.toInt(),
|
minorVersion = nfcCharacter.characterCreationFirmwareVersion.minorVersion.toInt(),
|
||||||
majorVersion = nfcCharacter.characterCreationFirmwareVersion.majorVersion.toInt(),
|
majorVersion = nfcCharacter.characterCreationFirmwareVersion.majorVersion.toInt(),
|
||||||
)
|
)
|
||||||
@ -259,6 +246,42 @@ class FromNfcConverter (
|
|||||||
.insertBECharacterData(extraCharacterData)
|
.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 = com.github.cfogrady.vbnfc.data.MissionType.NONE,
|
||||||
|
status = com.github.cfogrady.vbnfc.data.MissionStatus.UNAVAILABLE,
|
||||||
|
goal = 0,
|
||||||
|
progress = 0,
|
||||||
|
timeElapsedInMinutes = 0,
|
||||||
|
timeLimitInMinutes = 0,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
database
|
||||||
|
.userCharacterDao()
|
||||||
|
.insertSpecialMissions(*emptyMissions.toTypedArray())
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
private fun addVitalsHistoryToDatabase(
|
private fun addVitalsHistoryToDatabase(
|
||||||
|
|||||||
@ -15,10 +15,6 @@ 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.UserCharacter
|
||||||
import com.github.nacabaro.vbhelper.domain.device_data.VBCharacterData
|
import com.github.nacabaro.vbhelper.domain.device_data.VBCharacterData
|
||||||
import com.github.nacabaro.vbhelper.dtos.CharacterDtos
|
import com.github.nacabaro.vbhelper.dtos.CharacterDtos
|
||||||
import com.github.nacabaro.vbhelper.transfer.CharacterTransferPolicyResolver
|
|
||||||
import com.github.nacabaro.vbhelper.transfer.ExportFormat
|
|
||||||
import com.github.nacabaro.vbhelper.transfer.TransferTarget
|
|
||||||
import com.github.nacabaro.vbhelper.transfer.TransferTransport
|
|
||||||
import com.github.nacabaro.vbhelper.utils.DeviceType
|
import com.github.nacabaro.vbhelper.utils.DeviceType
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.flow.firstOrNull
|
import kotlinx.coroutines.flow.firstOrNull
|
||||||
@ -30,19 +26,27 @@ class ToNfcConverter(
|
|||||||
companion object {
|
companion object {
|
||||||
private const val MIN_NFC_TRANSFORMATION_YEAR = 2021
|
private const val MIN_NFC_TRANSFORMATION_YEAR = 2021
|
||||||
private const val MAX_NFC_TRANSFORMATION_YEAR = 2035
|
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 application: VBHelper = componentActivity.applicationContext as VBHelper
|
||||||
private val database: AppDatabase = application.container.db
|
private val database: AppDatabase = application.container.db
|
||||||
private val transferPolicyResolver = CharacterTransferPolicyResolver(database.characterTransferPolicyDao())
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
suspend fun characterToNfc(
|
suspend fun characterToNfc(
|
||||||
characterId: Long,
|
characterId: Long,
|
||||||
target: TransferTarget = TransferTarget.REAL_BRACELET,
|
forcedProfile: DeviceType? = null,
|
||||||
transport: TransferTransport = TransferTransport.NFCA,
|
|
||||||
forcedFormat: ExportFormat? = null,
|
|
||||||
): NfcCharacter {
|
): NfcCharacter {
|
||||||
val app = componentActivity.applicationContext as VBHelper
|
val app = componentActivity.applicationContext as VBHelper
|
||||||
val database = app.container.db
|
val database = app.container.db
|
||||||
@ -54,20 +58,14 @@ class ToNfcConverter(
|
|||||||
val characterInfo = database
|
val characterInfo = database
|
||||||
.characterDao()
|
.characterDao()
|
||||||
.getCharacterInfo(userCharacter.charId)
|
.getCharacterInfo(userCharacter.charId)
|
||||||
val card = database
|
|
||||||
.cardDao()
|
|
||||||
.getCardByCharacterIdSync(characterId)
|
|
||||||
?: error("Card not found for character $characterId")
|
|
||||||
|
|
||||||
val exportFormat = forcedFormat ?: transferPolicyResolver.resolveExportFormat(
|
// Forced profile overrides stored device type for transfer semantics:
|
||||||
characterId = characterId,
|
// - NFC-A route forces VBDevice (real bracelets only understand VB)
|
||||||
characterType = userCharacter.characterType,
|
// - HCE route forces BEDevice (VitalWear only sends/accepts BE)
|
||||||
transport = transport,
|
// Otherwise, use the character's own stored type.
|
||||||
target = target,
|
val shouldEncodeAsBem = shouldEncodeAsBem(forcedProfile, userCharacter.characterType)
|
||||||
isBemCard = card.isBEm,
|
|
||||||
)
|
|
||||||
|
|
||||||
return if (exportFormat == ExportFormat.BE)
|
return if (shouldEncodeAsBem)
|
||||||
nfcToBENfc(characterId, characterInfo, userCharacter)
|
nfcToBENfc(characterId, characterInfo, userCharacter)
|
||||||
else
|
else
|
||||||
nfcToVBNfc(characterId, characterInfo, userCharacter)
|
nfcToVBNfc(characterId, characterInfo, userCharacter)
|
||||||
@ -238,6 +236,18 @@ class ToNfcConverter(
|
|||||||
|
|
||||||
val paddedTransformationArray = generateTransformationHistory(characterId)
|
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(
|
val nfcData = BENfcCharacter(
|
||||||
dimId = characterInfo.cardId.toUShort(),
|
dimId = characterInfo.cardId.toUShort(),
|
||||||
charIndex = characterInfo.charId.toUShort(),
|
charIndex = characterInfo.charId.toUShort(),
|
||||||
@ -278,8 +288,8 @@ class ToNfcConverter(
|
|||||||
itemType = beData.itemType.toByte(),
|
itemType = beData.itemType.toByte(),
|
||||||
itemMultiplier = beData.itemMultiplier.toByte(),
|
itemMultiplier = beData.itemMultiplier.toByte(),
|
||||||
itemRemainingTime = beData.itemRemainingTime.toByte(),
|
itemRemainingTime = beData.itemRemainingTime.toByte(),
|
||||||
otp0 = byteArrayOf(8),
|
otp0 = otp0ByteArray,
|
||||||
otp1 = byteArrayOf(8),
|
otp1 = otp1ByteArray,
|
||||||
characterCreationFirmwareVersion = FirmwareVersion(
|
characterCreationFirmwareVersion = FirmwareVersion(
|
||||||
minorVersion = beData.minorVersion.toByte(),
|
minorVersion = beData.minorVersion.toByte(),
|
||||||
majorVersion = beData.majorVersion.toByte()
|
majorVersion = beData.majorVersion.toByte()
|
||||||
|
|||||||
@ -1,54 +1,54 @@
|
|||||||
package com.github.nacabaro.vbhelper.screens.scanScreen.screens
|
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.Composable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.unit.dp
|
import com.github.nacabaro.vbhelper.screens.scanScreen.TransferHaptics
|
||||||
import com.github.nacabaro.vbhelper.components.TopBanner
|
import com.github.nacabaro.vbhelper.utils.ImageBitmapData
|
||||||
import com.github.nacabaro.vbhelper.R
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ActionScreen(
|
fun ActionScreen(
|
||||||
topBannerText: String,
|
topBannerText: String,
|
||||||
|
detectedTransportMessage: String?,
|
||||||
transferStatusMessage: String?,
|
transferStatusMessage: String?,
|
||||||
|
characterPreview: ImageBitmapData? = null,
|
||||||
onClickCancel: () -> Unit,
|
onClickCancel: () -> Unit,
|
||||||
|
isTransferring: Boolean = true,
|
||||||
) {
|
) {
|
||||||
Scaffold (
|
val context = LocalContext.current
|
||||||
topBar = {
|
val transferHaptics = remember(context) { TransferHaptics(context) }
|
||||||
TopBanner(
|
|
||||||
text = topBannerText,
|
LaunchedEffect(transferStatusMessage) {
|
||||||
onBackClick = onClickCancel
|
if (transferStatusMessage != null && transferStatusMessage.contains("progress", ignoreCase = true)) {
|
||||||
|
transferHaptics.onTransferProgress()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (transferStatusMessage?.contains("complete", ignoreCase = true) == true) {
|
||||||
|
TransferCompleteScreen(
|
||||||
|
topBannerText = topBannerText,
|
||||||
|
resultMessage = transferStatusMessage,
|
||||||
|
onClickOk = onClickCancel,
|
||||||
|
isSuccess = !transferStatusMessage.contains("fail", ignoreCase = true)
|
||||||
)
|
)
|
||||||
}
|
} else if (isTransferring) {
|
||||||
) { innerPadding ->
|
TransferAnimationScreen(
|
||||||
Column (
|
topBannerText = topBannerText,
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
detectedTransportMessage = detectedTransportMessage,
|
||||||
verticalArrangement = Arrangement.Center,
|
transferStatusMessage = transferStatusMessage,
|
||||||
modifier = Modifier
|
characterPreview = characterPreview,
|
||||||
.padding(innerPadding)
|
onClickCancel = onClickCancel,
|
||||||
.fillMaxSize()
|
isTransferring = true
|
||||||
) {
|
)
|
||||||
Text(stringResource(R.string.action_place_near_reader))
|
} else {
|
||||||
if (!transferStatusMessage.isNullOrBlank()) {
|
TransferAnimationScreen(
|
||||||
Text(
|
topBannerText = topBannerText,
|
||||||
text = transferStatusMessage,
|
detectedTransportMessage = detectedTransportMessage,
|
||||||
modifier = Modifier.padding(top = 8.dp, start = 16.dp, end = 16.dp)
|
transferStatusMessage = transferStatusMessage,
|
||||||
|
characterPreview = characterPreview,
|
||||||
|
onClickCancel = onClickCancel,
|
||||||
|
isTransferring = false
|
||||||
)
|
)
|
||||||
}
|
|
||||||
Button(
|
|
||||||
onClick = onClickCancel,
|
|
||||||
modifier = Modifier.padding(16.dp)
|
|
||||||
) {
|
|
||||||
Text(stringResource(R.string.action_cancel))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -3,17 +3,21 @@ package com.github.nacabaro.vbhelper.screens.scanScreen.screens
|
|||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.DisposableEffect
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import com.github.nacabaro.vbhelper.ActivityLifecycleListener
|
import com.github.nacabaro.vbhelper.ActivityLifecycleListener
|
||||||
import com.github.nacabaro.vbhelper.domain.card.Card
|
import com.github.nacabaro.vbhelper.domain.card.Card
|
||||||
import com.github.nacabaro.vbhelper.screens.cardScreen.ChooseCard
|
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.SCAN_SCREEN_ACTIVITY_LIFECYCLE_LISTENER
|
||||||
import com.github.nacabaro.vbhelper.screens.scanScreen.ScanScreenController
|
import com.github.nacabaro.vbhelper.screens.scanScreen.ScanScreenController
|
||||||
|
import com.github.nacabaro.vbhelper.screens.scanScreen.TransferHaptics
|
||||||
import com.github.nacabaro.vbhelper.R
|
import com.github.nacabaro.vbhelper.R
|
||||||
|
|
||||||
private const val TAG = "ReadingScreen"
|
private const val TAG = "ReadingScreen"
|
||||||
@ -24,8 +28,17 @@ fun ReadingScreen(
|
|||||||
onCancel: () -> Unit,
|
onCancel: () -> Unit,
|
||||||
onComplete: () -> Unit
|
onComplete: () -> Unit
|
||||||
) {
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val transferHaptics = remember(context) { TransferHaptics(context) }
|
||||||
val secrets by scanScreenController.secretsFlow.collectAsState(null)
|
val secrets by scanScreenController.secretsFlow.collectAsState(null)
|
||||||
val transferStatus by scanScreenController.transferStatusFlow.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) }
|
var cardsRead by remember { mutableStateOf<List<Card>?>(null) }
|
||||||
|
|
||||||
@ -33,6 +46,12 @@ fun ReadingScreen(
|
|||||||
var isDoneReadingCharacter by remember { mutableStateOf(false) }
|
var isDoneReadingCharacter by remember { mutableStateOf(false) }
|
||||||
var cardSelectScreen by remember { mutableStateOf(false) }
|
var cardSelectScreen by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
LaunchedEffect(readingScreen) {
|
||||||
|
if (readingScreen) {
|
||||||
|
transferHaptics.onTransferStart()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun startReadIfNeeded() {
|
fun startReadIfNeeded() {
|
||||||
val availableSecrets = secrets
|
val availableSecrets = secrets
|
||||||
if (availableSecrets == null) {
|
if (availableSecrets == null) {
|
||||||
@ -52,11 +71,13 @@ fun ReadingScreen(
|
|||||||
secrets = availableSecrets,
|
secrets = availableSecrets,
|
||||||
onComplete = {
|
onComplete = {
|
||||||
Log.d(TAG, "onClickRead.onComplete: marking read complete")
|
Log.d(TAG, "onClickRead.onComplete: marking read complete")
|
||||||
|
transferHaptics.onTransferComplete()
|
||||||
readingScreen = false
|
readingScreen = false
|
||||||
isDoneReadingCharacter = true
|
isDoneReadingCharacter = true
|
||||||
},
|
},
|
||||||
onMultipleCards = { cards ->
|
onMultipleCards = { cards ->
|
||||||
Log.d(TAG, "onClickRead.onMultipleCards: showing card select (count=${cards.size})")
|
Log.d(TAG, "onClickRead.onMultipleCards: showing card select (count=${cards.size})")
|
||||||
|
transferHaptics.onTransferProgress()
|
||||||
cardsRead = cards
|
cardsRead = cards
|
||||||
readingScreen = false
|
readingScreen = false
|
||||||
cardSelectScreen = true
|
cardSelectScreen = true
|
||||||
@ -119,13 +140,15 @@ fun ReadingScreen(
|
|||||||
if (readingScreen) {
|
if (readingScreen) {
|
||||||
ActionScreen(
|
ActionScreen(
|
||||||
topBannerText = stringResource(R.string.reading_character_title),
|
topBannerText = stringResource(R.string.reading_character_title),
|
||||||
|
detectedTransportMessage = transportMessage,
|
||||||
transferStatusMessage = transferStatus,
|
transferStatusMessage = transferStatus,
|
||||||
) {
|
onClickCancel = {
|
||||||
Log.d(TAG, "ActionScreen.onCancel: cancelRead + onCancel")
|
Log.d(TAG, "ActionScreen.onCancel: cancelRead + onCancel")
|
||||||
readingScreen = false
|
readingScreen = false
|
||||||
scanScreenController.cancelRead()
|
scanScreenController.cancelRead()
|
||||||
onCancel()
|
onCancel()
|
||||||
}
|
}
|
||||||
|
)
|
||||||
} else if (cardSelectScreen) {
|
} else if (cardSelectScreen) {
|
||||||
ChooseCard(
|
ChooseCard(
|
||||||
cards = cardsRead!!,
|
cards = cardsRead!!,
|
||||||
|
|||||||
@ -0,0 +1,267 @@
|
|||||||
|
package com.github.nacabaro.vbhelper.screens.scanScreen.screens
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.FastOutLinearInEasing
|
||||||
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
|
import androidx.compose.animation.core.animateIntAsState
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.scale
|
||||||
|
import androidx.compose.ui.graphics.FilterQuality
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import com.github.nacabaro.vbhelper.components.TopBanner
|
||||||
|
import com.github.nacabaro.vbhelper.R
|
||||||
|
import com.github.nacabaro.vbhelper.utils.ImageBitmapData
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun TransferAnimationScreen(
|
||||||
|
topBannerText: String,
|
||||||
|
detectedTransportMessage: String?,
|
||||||
|
transferStatusMessage: String?,
|
||||||
|
characterPreview: ImageBitmapData? = null,
|
||||||
|
onClickCancel: () -> Unit,
|
||||||
|
isTransferring: Boolean = true,
|
||||||
|
) {
|
||||||
|
var pulseScale by remember { mutableStateOf(1f) }
|
||||||
|
var animationProgress by remember { mutableStateOf(0) }
|
||||||
|
|
||||||
|
val scale by animateFloatAsState(
|
||||||
|
targetValue = pulseScale,
|
||||||
|
animationSpec = tween(durationMillis = 800),
|
||||||
|
label = "pulse_scale"
|
||||||
|
)
|
||||||
|
|
||||||
|
val progress by animateIntAsState(
|
||||||
|
targetValue = animationProgress,
|
||||||
|
animationSpec = tween(durationMillis = 100, easing = FastOutLinearInEasing),
|
||||||
|
label = "progress"
|
||||||
|
)
|
||||||
|
|
||||||
|
LaunchedEffect(isTransferring) {
|
||||||
|
if (isTransferring) {
|
||||||
|
while (true) {
|
||||||
|
pulseScale = 1.2f
|
||||||
|
delay(400)
|
||||||
|
pulseScale = 1f
|
||||||
|
delay(400)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(isTransferring) {
|
||||||
|
if (isTransferring) {
|
||||||
|
while (animationProgress < 100) {
|
||||||
|
delay(50)
|
||||||
|
animationProgress += 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
TopBanner(
|
||||||
|
text = topBannerText,
|
||||||
|
onBackClick = onClickCancel
|
||||||
|
)
|
||||||
|
}
|
||||||
|
) { innerPadding ->
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.Center,
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(innerPadding)
|
||||||
|
.fillMaxSize()
|
||||||
|
) {
|
||||||
|
characterPreview?.let { preview ->
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.padding(bottom = 24.dp)
|
||||||
|
) {
|
||||||
|
Image(
|
||||||
|
bitmap = preview.imageBitmap,
|
||||||
|
contentDescription = "Transferred Digimon sprite",
|
||||||
|
modifier = Modifier
|
||||||
|
.size(preview.dpWidth, preview.dpHeight)
|
||||||
|
.padding(8.dp),
|
||||||
|
filterQuality = FilterQuality.None
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Animated pulsing circle indicator
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(120.dp)
|
||||||
|
.scale(scale)
|
||||||
|
.background(
|
||||||
|
color = Color(0xFF6366F1).copy(alpha = 0.2f),
|
||||||
|
shape = CircleShape
|
||||||
|
),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(80.dp)
|
||||||
|
.background(
|
||||||
|
color = Color(0xFF6366F1),
|
||||||
|
shape = CircleShape
|
||||||
|
),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "🔄",
|
||||||
|
fontSize = 40.sp,
|
||||||
|
modifier = Modifier.scale(if (scale > 1f) 1.1f else 1f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.action_place_near_reader),
|
||||||
|
fontSize = 16.sp,
|
||||||
|
modifier = Modifier.padding(top = 24.dp),
|
||||||
|
fontWeight = FontWeight.Medium
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!detectedTransportMessage.isNullOrBlank()) {
|
||||||
|
Text(
|
||||||
|
text = detectedTransportMessage,
|
||||||
|
modifier = Modifier.padding(top = 12.dp, start = 16.dp, end = 16.dp),
|
||||||
|
fontSize = 14.sp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!transferStatusMessage.isNullOrBlank()) {
|
||||||
|
Text(
|
||||||
|
text = transferStatusMessage,
|
||||||
|
modifier = Modifier.padding(top = 12.dp, start = 16.dp, end = 16.dp),
|
||||||
|
fontSize = 14.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = Color(0xFF6366F1)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Progress ring
|
||||||
|
if (isTransferring) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
progress = { progress / 100f },
|
||||||
|
modifier = Modifier
|
||||||
|
.size(100.dp)
|
||||||
|
.padding(top = 24.dp),
|
||||||
|
strokeWidth = 4.dp
|
||||||
|
)
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = "$progress%",
|
||||||
|
modifier = Modifier.padding(top = 8.dp),
|
||||||
|
fontSize = 12.sp,
|
||||||
|
color = Color.Gray
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Button(
|
||||||
|
onClick = onClickCancel,
|
||||||
|
modifier = Modifier.padding(top = 32.dp, bottom = 16.dp)
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.action_cancel))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun TransferCompleteScreen(
|
||||||
|
topBannerText: String,
|
||||||
|
resultMessage: String,
|
||||||
|
onClickOk: () -> Unit,
|
||||||
|
isSuccess: Boolean = true,
|
||||||
|
) {
|
||||||
|
var showCheckmark by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
val scale by animateFloatAsState(
|
||||||
|
targetValue = if (showCheckmark) 1f else 0f,
|
||||||
|
animationSpec = tween(durationMillis = 600, easing = FastOutLinearInEasing),
|
||||||
|
label = "checkmark_scale"
|
||||||
|
)
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
delay(300)
|
||||||
|
showCheckmark = true
|
||||||
|
}
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
TopBanner(
|
||||||
|
text = topBannerText,
|
||||||
|
onBackClick = {}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
) { innerPadding ->
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.Center,
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(innerPadding)
|
||||||
|
.fillMaxSize()
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(120.dp)
|
||||||
|
.scale(scale)
|
||||||
|
.background(
|
||||||
|
color = if (isSuccess) Color(0xFF10B981) else Color(0xFFEF4444),
|
||||||
|
shape = CircleShape
|
||||||
|
),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = if (isSuccess) "✓" else "✕",
|
||||||
|
fontSize = 60.sp,
|
||||||
|
color = Color.White,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = resultMessage,
|
||||||
|
fontSize = 16.sp,
|
||||||
|
modifier = Modifier.padding(top = 24.dp, start = 16.dp, end = 16.dp),
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
)
|
||||||
|
|
||||||
|
Button(
|
||||||
|
onClick = onClickOk,
|
||||||
|
modifier = Modifier.padding(top = 32.dp, bottom = 16.dp)
|
||||||
|
) {
|
||||||
|
Text("OK")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@ -7,15 +7,21 @@ import androidx.compose.runtime.collectAsState
|
|||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.produceState
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import com.github.cfogrady.vbnfc.data.NfcCharacter
|
import com.github.cfogrady.vbnfc.data.NfcCharacter
|
||||||
import com.github.nacabaro.vbhelper.ActivityLifecycleListener
|
import com.github.nacabaro.vbhelper.ActivityLifecycleListener
|
||||||
import com.github.nacabaro.vbhelper.di.VBHelper
|
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.SCAN_SCREEN_ACTIVITY_LIFECYCLE_LISTENER
|
||||||
import com.github.nacabaro.vbhelper.screens.scanScreen.ScanScreenController
|
import com.github.nacabaro.vbhelper.screens.scanScreen.ScanScreenController
|
||||||
import com.github.nacabaro.vbhelper.screens.scanScreen.ScanScreenController.WriteResult
|
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.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.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
@ -31,9 +37,39 @@ fun WritingScreen(
|
|||||||
) {
|
) {
|
||||||
val secrets by scanScreenController.secretsFlow.collectAsState(null)
|
val secrets by scanScreenController.secretsFlow.collectAsState(null)
|
||||||
val transferStatus by scanScreenController.transferStatusFlow.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 application = LocalContext.current.applicationContext as VBHelper
|
||||||
val storageRepository = StorageRepository(application.container.db)
|
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 writing by remember { mutableStateOf(false) }
|
||||||
var writingScreen by remember { mutableStateOf(false) }
|
var writingScreen by remember { mutableStateOf(false) }
|
||||||
@ -44,6 +80,9 @@ fun WritingScreen(
|
|||||||
|
|
||||||
DisposableEffect(writing) {
|
DisposableEffect(writing) {
|
||||||
if (writing) {
|
if (writing) {
|
||||||
|
val transferHaptics = TransferHaptics(application)
|
||||||
|
transferHaptics.onTransferStart()
|
||||||
|
|
||||||
scanScreenController.registerActivityLifecycleListener(
|
scanScreenController.registerActivityLifecycleListener(
|
||||||
SCAN_SCREEN_ACTIVITY_LIFECYCLE_LISTENER,
|
SCAN_SCREEN_ACTIVITY_LIFECYCLE_LISTENER,
|
||||||
object : ActivityLifecycleListener {
|
object : ActivityLifecycleListener {
|
||||||
@ -55,11 +94,13 @@ fun WritingScreen(
|
|||||||
if (!isDoneSendingCard) {
|
if (!isDoneSendingCard) {
|
||||||
scanScreenController.onClickCheckCard(secrets!!, nfcCharacter) {
|
scanScreenController.onClickCheckCard(secrets!!, nfcCharacter) {
|
||||||
isDoneSendingCard = true
|
isDoneSendingCard = true
|
||||||
|
transferHaptics.onTransferProgress()
|
||||||
}
|
}
|
||||||
} else if (!isDoneWritingCharacter) {
|
} else if (!isDoneWritingCharacter) {
|
||||||
scanScreenController.onClickWrite(secrets!!, nfcCharacter) { result ->
|
scanScreenController.onClickWrite(secrets!!, nfcCharacter, characterId) { result ->
|
||||||
writeResult = result
|
writeResult = result
|
||||||
isDoneWritingCharacter = true
|
isDoneWritingCharacter = true
|
||||||
|
transferHaptics.onTransferProgress()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -70,11 +111,13 @@ fun WritingScreen(
|
|||||||
if (!isDoneSendingCard) {
|
if (!isDoneSendingCard) {
|
||||||
scanScreenController.onClickCheckCard(secrets!!, nfcCharacter) {
|
scanScreenController.onClickCheckCard(secrets!!, nfcCharacter) {
|
||||||
isDoneSendingCard = true
|
isDoneSendingCard = true
|
||||||
|
transferHaptics.onTransferProgress()
|
||||||
}
|
}
|
||||||
} else if (!isDoneWritingCharacter) {
|
} else if (!isDoneWritingCharacter) {
|
||||||
scanScreenController.onClickWrite(secrets!!, nfcCharacter) { result ->
|
scanScreenController.onClickWrite(secrets!!, nfcCharacter, characterId) { result ->
|
||||||
writeResult = result
|
writeResult = result
|
||||||
isDoneWritingCharacter = true
|
isDoneWritingCharacter = true
|
||||||
|
transferHaptics.onTransferProgress()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -106,11 +149,14 @@ fun WritingScreen(
|
|||||||
writing = true
|
writing = true
|
||||||
ActionScreen(
|
ActionScreen(
|
||||||
topBannerText = stringResource(R.string.sending_card_title),
|
topBannerText = stringResource(R.string.sending_card_title),
|
||||||
|
detectedTransportMessage = transportMessage,
|
||||||
transferStatusMessage = transferStatus,
|
transferStatusMessage = transferStatus,
|
||||||
) {
|
characterPreview = characterPreview,
|
||||||
|
onClickCancel = {
|
||||||
scanScreenController.cancelRead()
|
scanScreenController.cancelRead()
|
||||||
onCancel()
|
onCancel()
|
||||||
}
|
}
|
||||||
|
)
|
||||||
} else if (!writingConfirmScreen) {
|
} else if (!writingConfirmScreen) {
|
||||||
writing = false
|
writing = false
|
||||||
WriteCharacterScreen (
|
WriteCharacterScreen (
|
||||||
@ -127,12 +173,15 @@ fun WritingScreen(
|
|||||||
writing = true
|
writing = true
|
||||||
ActionScreen(
|
ActionScreen(
|
||||||
topBannerText = stringResource(R.string.writing_character_action_title),
|
topBannerText = stringResource(R.string.writing_character_action_title),
|
||||||
|
detectedTransportMessage = transportMessage,
|
||||||
transferStatusMessage = transferStatus,
|
transferStatusMessage = transferStatus,
|
||||||
) {
|
characterPreview = characterPreview,
|
||||||
|
onClickCancel = {
|
||||||
isDoneSendingCard = false
|
isDoneSendingCard = false
|
||||||
scanScreenController.cancelRead()
|
scanScreenController.cancelRead()
|
||||||
onCancel()
|
onCancel()
|
||||||
}
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
var completedWriting by remember { mutableStateOf(false) }
|
var completedWriting by remember { mutableStateOf(false) }
|
||||||
@ -140,6 +189,8 @@ fun WritingScreen(
|
|||||||
LaunchedEffect(isDoneSendingCard, isDoneWritingCharacter) {
|
LaunchedEffect(isDoneSendingCard, isDoneWritingCharacter) {
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
if (isDoneSendingCard && isDoneWritingCharacter) {
|
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) {
|
if (writeResult == WriteResult.MOVE_CONFIRMED) {
|
||||||
storageRepository.deleteCharacter(characterId)
|
storageRepository.deleteCharacter(characterId)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import android.net.Uri
|
|||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
@ -12,8 +13,12 @@ import androidx.compose.foundation.rememberScrollState
|
|||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Switch
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
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.Modifier
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
@ -67,6 +72,7 @@ fun SettingsScreen(
|
|||||||
settingsScreenController.onClickImportCard()
|
settingsScreenController.onClickImportCard()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
SettingsSection(title = stringResource(R.string.settings_section_about))
|
SettingsSection(title = stringResource(R.string.settings_section_about))
|
||||||
SettingsEntry(
|
SettingsEntry(
|
||||||
title = stringResource(R.string.settings_credits_title),
|
title = stringResource(R.string.settings_credits_title),
|
||||||
@ -98,6 +104,38 @@ fun SettingsScreen(
|
|||||||
) {
|
) {
|
||||||
settingsScreenController.onClickImportDatabase()
|
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) }
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -123,6 +161,40 @@ 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
|
@Composable
|
||||||
fun SettingsSection(
|
fun SettingsSection(
|
||||||
title: String
|
title: String
|
||||||
|
|||||||
@ -5,4 +5,8 @@ interface SettingsScreenController {
|
|||||||
fun onClickImportDatabase()
|
fun onClickImportDatabase()
|
||||||
fun onClickImportApk()
|
fun onClickImportApk()
|
||||||
fun onClickImportCard()
|
fun onClickImportCard()
|
||||||
|
fun onClickCompanionImportCardImage()
|
||||||
|
fun onClickCompanionImportFirmware()
|
||||||
|
fun onClickCompanionSendWatchLogs()
|
||||||
|
fun onClickCompanionSendPhoneLogs()
|
||||||
}
|
}
|
||||||
@ -1,5 +1,6 @@
|
|||||||
package com.github.nacabaro.vbhelper.screens.settingsScreen
|
package com.github.nacabaro.vbhelper.screens.settingsScreen
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
@ -7,8 +8,13 @@ import android.widget.Toast
|
|||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.result.ActivityResultLauncher
|
import androidx.activity.result.ActivityResultLauncher
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
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.database.AppDatabase
|
||||||
import com.github.nacabaro.vbhelper.di.VBHelper
|
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.CardImportController
|
||||||
import com.github.nacabaro.vbhelper.screens.settingsScreen.controllers.DatabaseManagementController
|
import com.github.nacabaro.vbhelper.screens.settingsScreen.controllers.DatabaseManagementController
|
||||||
import com.github.nacabaro.vbhelper.source.ApkSecretsImporter
|
import com.github.nacabaro.vbhelper.source.ApkSecretsImporter
|
||||||
@ -16,6 +22,7 @@ import com.github.nacabaro.vbhelper.source.SecretsImporter
|
|||||||
import com.github.nacabaro.vbhelper.source.SecretsRepository
|
import com.github.nacabaro.vbhelper.source.SecretsRepository
|
||||||
import com.github.nacabaro.vbhelper.source.proto.Secrets
|
import com.github.nacabaro.vbhelper.source.proto.Secrets
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
|
||||||
|
|
||||||
class SettingsScreenControllerImpl(
|
class SettingsScreenControllerImpl(
|
||||||
@ -29,11 +36,14 @@ class SettingsScreenControllerImpl(
|
|||||||
private val application = context.applicationContext as VBHelper
|
private val application = context.applicationContext as VBHelper
|
||||||
private val secretsRepository: SecretsRepository = application.container.dataStoreSecretsRepository
|
private val secretsRepository: SecretsRepository = application.container.dataStoreSecretsRepository
|
||||||
private val database: AppDatabase = application.container.db
|
private val database: AppDatabase = application.container.db
|
||||||
|
private val cardSettingsRepository = application.container.cardSettingsRepository
|
||||||
private val databaseManagementController = DatabaseManagementController(
|
private val databaseManagementController = DatabaseManagementController(
|
||||||
componentActivity = context,
|
componentActivity = context,
|
||||||
application = application
|
application = application
|
||||||
)
|
)
|
||||||
|
|
||||||
|
val dimToBemConversionEnabled = cardSettingsRepository.enableDimToBemConversion
|
||||||
|
|
||||||
init {
|
init {
|
||||||
filePickerLauncher = context.registerForActivityResult(
|
filePickerLauncher = context.registerForActivityResult(
|
||||||
ActivityResultContracts.CreateDocument("application/octet-stream")
|
ActivityResultContracts.CreateDocument("application/octet-stream")
|
||||||
@ -101,13 +111,41 @@ class SettingsScreenControllerImpl(
|
|||||||
filePickerCard.launch(arrayOf("*/*"))
|
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) {
|
private fun importCard(uri: Uri) {
|
||||||
context.lifecycleScope.launch(Dispatchers.IO) {
|
context.lifecycleScope.launch(Dispatchers.IO) {
|
||||||
val contentResolver = context.contentResolver
|
val contentResolver = context.contentResolver
|
||||||
val inputStream = contentResolver.openInputStream(uri)
|
val inputStream = contentResolver.openInputStream(uri)
|
||||||
|
|
||||||
inputStream.use { fileReader ->
|
inputStream.use { fileReader ->
|
||||||
val cardImportController = CardImportController(database)
|
val dimToBemEnabled = cardSettingsRepository.enableDimToBemConversion.first()
|
||||||
|
val cardImportController = CardImportController(database, dimToBemEnabled)
|
||||||
cardImportController.importCard(fileReader)
|
cardImportController.importCard(fileReader)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -13,7 +13,8 @@ import com.github.nacabaro.vbhelper.domain.characters.Sprite
|
|||||||
import java.io.InputStream
|
import java.io.InputStream
|
||||||
|
|
||||||
class CardImportController(
|
class CardImportController(
|
||||||
private val database: AppDatabase
|
private val database: AppDatabase,
|
||||||
|
private val enableDimToBemConversion: Boolean = false
|
||||||
) {
|
) {
|
||||||
suspend fun importCard(
|
suspend fun importCard(
|
||||||
fileReader: InputStream?
|
fileReader: InputStream?
|
||||||
@ -117,6 +118,16 @@ class CardImportController(
|
|||||||
.spriteDao()
|
.spriteDao()
|
||||||
.insertSprite(domainSprite)
|
.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(
|
domainCharacters.add(
|
||||||
CardCharacter(
|
CardCharacter(
|
||||||
@ -126,9 +137,9 @@ class CardImportController(
|
|||||||
nameSprite = card.spriteData.sprites[spriteCounter].pixelData,
|
nameSprite = card.spriteData.sprites[spriteCounter].pixelData,
|
||||||
stage = characters[index].stage,
|
stage = characters[index].stage,
|
||||||
attribute = NfcCharacter.Attribute.entries[characters[index].attribute],
|
attribute = NfcCharacter.Attribute.entries[characters[index].attribute],
|
||||||
baseHp = characters[index].hp,
|
baseHp = baseHp,
|
||||||
baseBp = characters[index].dp,
|
baseBp = baseBp,
|
||||||
baseAp = characters[index].ap,
|
baseAp = baseAp,
|
||||||
nameWidth = card.spriteData.sprites[spriteCounter].spriteDimensions.width,
|
nameWidth = card.spriteData.sprites[spriteCounter].spriteDimensions.width,
|
||||||
nameHeight = card.spriteData.sprites[spriteCounter].spriteDimensions.height
|
nameHeight = card.spriteData.sprites[spriteCounter].spriteDimensions.height
|
||||||
)
|
)
|
||||||
@ -150,6 +161,40 @@ class CardImportController(
|
|||||||
.insertCharacter(*domainCharacters.toTypedArray())
|
.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(
|
private suspend fun importAdventureMissions(
|
||||||
cardId: Long,
|
cardId: Long,
|
||||||
card: com.github.cfogrady.vb.dim.card.Card<*, *, *, *, *, *>
|
card: com.github.cfogrady.vb.dim.card.Card<*, *, *, *, *, *>
|
||||||
|
|||||||
@ -1,11 +1,14 @@
|
|||||||
package com.github.nacabaro.vbhelper.screens.settingsScreen.controllers
|
package com.github.nacabaro.vbhelper.screens.settingsScreen.controllers
|
||||||
|
|
||||||
|
import androidx.room.Room
|
||||||
|
import android.database.sqlite.SQLiteDatabase
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.provider.OpenableColumns
|
import android.provider.OpenableColumns
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
import com.github.nacabaro.vbhelper.database.AppDatabase
|
||||||
import com.github.nacabaro.vbhelper.di.VBHelper
|
import com.github.nacabaro.vbhelper.di.VBHelper
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@ -18,6 +21,19 @@ class DatabaseManagementController(
|
|||||||
val application: VBHelper
|
val application: VBHelper
|
||||||
) {
|
) {
|
||||||
private val roomDbName = "internalDb"
|
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) {
|
fun exportDatabase( destinationUri: Uri) {
|
||||||
componentActivity.lifecycleScope.launch(Dispatchers.IO) {
|
componentActivity.lifecycleScope.launch(Dispatchers.IO) {
|
||||||
@ -59,6 +75,19 @@ class DatabaseManagementController(
|
|||||||
return@launch
|
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()
|
application.container.db.close()
|
||||||
|
|
||||||
val dbPath = componentActivity.getDatabasePath(roomDbName)
|
val dbPath = componentActivity.getDatabasePath(roomDbName)
|
||||||
@ -92,6 +121,118 @@ 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) {
|
private fun copyFile(inputStream: InputStream, outputStream: OutputStream) {
|
||||||
val buffer = ByteArray(1024)
|
val buffer = ByteArray(1024)
|
||||||
var bytesRead: Int
|
var bytesRead: Int
|
||||||
|
|||||||
@ -44,12 +44,18 @@ class AuthRepository(
|
|||||||
preferences[IS_AUTHENTICATED] = isAuthenticated
|
preferences[IS_AUTHENTICATED] = isAuthenticated
|
||||||
if (nacatechToken != null) {
|
if (nacatechToken != null) {
|
||||||
preferences[AUTH_TOKEN] = nacatechToken
|
preferences[AUTH_TOKEN] = nacatechToken
|
||||||
|
} else {
|
||||||
|
preferences.remove(AUTH_TOKEN)
|
||||||
}
|
}
|
||||||
if (sessionToken != null) {
|
if (sessionToken != null) {
|
||||||
preferences[SESSION_TOKEN] = sessionToken
|
preferences[SESSION_TOKEN] = sessionToken
|
||||||
|
} else {
|
||||||
|
preferences.remove(SESSION_TOKEN)
|
||||||
}
|
}
|
||||||
if (userId != null) {
|
if (userId != null) {
|
||||||
preferences[USER_ID] = userId
|
preferences[USER_ID] = userId
|
||||||
|
} else {
|
||||||
|
preferences.remove(USER_ID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,28 @@
|
|||||||
|
package com.github.nacabaro.vbhelper.source
|
||||||
|
|
||||||
|
import androidx.datastore.core.DataStore
|
||||||
|
import androidx.datastore.preferences.core.Preferences
|
||||||
|
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||||
|
import androidx.datastore.preferences.core.edit
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
|
||||||
|
class CardSettingsRepository(
|
||||||
|
private val dataStore: DataStore<Preferences>
|
||||||
|
) {
|
||||||
|
private companion object {
|
||||||
|
val ENABLE_DIM_TO_BEM_CONVERSION = booleanPreferencesKey("enable_dim_to_bem_conversion")
|
||||||
|
}
|
||||||
|
|
||||||
|
val enableDimToBemConversion: Flow<Boolean> = dataStore.data
|
||||||
|
.map { preferences ->
|
||||||
|
preferences[ENABLE_DIM_TO_BEM_CONVERSION] ?: false
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setEnableDimToBemConversion(enable: Boolean) {
|
||||||
|
dataStore.edit { preferences ->
|
||||||
|
preferences[ENABLE_DIM_TO_BEM_CONVERSION] = enable
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@ -0,0 +1,54 @@
|
|||||||
|
package com.github.nacabaro.vbhelper.source
|
||||||
|
|
||||||
|
import com.github.nacabaro.vbhelper.database.AppDatabase
|
||||||
|
import com.github.nacabaro.vbhelper.domain.device_data.VBCharacterData
|
||||||
|
import com.github.nacabaro.vbhelper.utils.DeviceType
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Utility for converting character device types.
|
||||||
|
* Used to fix mismatched device types when sending to physical NFC devices.
|
||||||
|
*/
|
||||||
|
class DeviceTypeConverter(private val database: AppDatabase) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prepares a BE character for VB-target exports without deleting BE stats.
|
||||||
|
* This keeps the BE profile intact while ensuring a VB sidecar exists.
|
||||||
|
*/
|
||||||
|
suspend fun convertBEToVB(characterId: Long): Boolean = withContext(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
val userCharacter = database.userCharacterDao().getCharacter(characterId)
|
||||||
|
|
||||||
|
if (userCharacter.characterType != DeviceType.BEDevice) {
|
||||||
|
// Already VB or other type, no conversion needed
|
||||||
|
return@withContext true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep BE typing/stats as-is; only ensure a VB sidecar profile exists for exports.
|
||||||
|
ensureVBCharacterDataExists(characterId)
|
||||||
|
|
||||||
|
true
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// Log but don't crash - character can still be sent as-is
|
||||||
|
android.util.Log.e("DeviceTypeConverter", "Failed to convert character $characterId to VB type", e)
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun ensureVBCharacterDataExists(characterId: Long) {
|
||||||
|
val vbData = database.userCharacterDao().getVbDataOrNull(characterId)
|
||||||
|
if (vbData == null) {
|
||||||
|
val vbCharacterData = VBCharacterData(
|
||||||
|
id = characterId,
|
||||||
|
generation = 0,
|
||||||
|
totalTrophies = 0
|
||||||
|
)
|
||||||
|
database.userCharacterDao().insertVBCharacterData(vbCharacterData)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@ -3,11 +3,8 @@ package com.github.nacabaro.vbhelper.source
|
|||||||
import com.github.cfogrady.vitalwear.protos.Character
|
import com.github.cfogrady.vitalwear.protos.Character
|
||||||
import com.github.nacabaro.vbhelper.database.AppDatabase
|
import com.github.nacabaro.vbhelper.database.AppDatabase
|
||||||
import com.github.nacabaro.vbhelper.domain.device_data.BECharacterData
|
import com.github.nacabaro.vbhelper.domain.device_data.BECharacterData
|
||||||
import com.github.nacabaro.vbhelper.transfer.CharacterTransferPolicyResolver
|
|
||||||
import com.github.nacabaro.vbhelper.transfer.TransferTarget
|
|
||||||
import com.github.nacabaro.vbhelper.transfer.TransferTransport
|
|
||||||
import com.github.nacabaro.vbhelper.transfer.toTransferDeviceType
|
|
||||||
import com.github.nacabaro.vbhelper.utils.DeviceType
|
import com.github.nacabaro.vbhelper.utils.DeviceType
|
||||||
|
import com.google.protobuf.ByteString
|
||||||
import kotlinx.coroutines.flow.firstOrNull
|
import kotlinx.coroutines.flow.firstOrNull
|
||||||
|
|
||||||
internal const val DEFAULT_VITALWEAR_TRAINING_TIME_SECONDS = 100L * 60L * 60L
|
internal const val DEFAULT_VITALWEAR_TRAINING_TIME_SECONDS = 100L * 60L * 60L
|
||||||
@ -34,15 +31,25 @@ internal fun resolveTrainingSeconds(
|
|||||||
class VitalWearCharacterExporter(
|
class VitalWearCharacterExporter(
|
||||||
private val database: AppDatabase
|
private val database: AppDatabase
|
||||||
) {
|
) {
|
||||||
private val transferPolicyResolver = CharacterTransferPolicyResolver(database.characterTransferPolicyDao())
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Builds a Character proto from the stored character data.
|
* Builds a Character proto from the stored character data.
|
||||||
* Used by the HCE ISO-DEP transfer path.
|
* 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): Character {
|
suspend fun buildCharacterProto(
|
||||||
|
characterId: Long,
|
||||||
|
forcedTransferProfile: DeviceType? = null,
|
||||||
|
): Character {
|
||||||
val characterWithSprites = database.userCharacterDao().getCharacterWithSprites(characterId)
|
val characterWithSprites = database.userCharacterDao().getCharacterWithSprites(characterId)
|
||||||
val userCharacter = database.userCharacterDao().getCharacter(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 characterInfo = database.characterDao().getCharacterInfo(userCharacter.charId)
|
||||||
val vwSettings = database.vitalWearSettingsDao().getByCharacterId(characterId)
|
val vwSettings = database.vitalWearSettingsDao().getByCharacterId(characterId)
|
||||||
val card = database.cardDao().getCardByCharacterIdSync(characterId)
|
val card = database.cardDao().getCardByCharacterIdSync(characterId)
|
||||||
@ -50,17 +57,25 @@ class VitalWearCharacterExporter(
|
|||||||
val cardProgress = database.cardProgressDao().getCardProgressSync(card.id) ?: 0
|
val cardProgress = database.cardProgressDao().getCardProgressSync(card.id) ?: 0
|
||||||
val beData = database.userCharacterDao().getBeDataOrNull(characterId)
|
val beData = database.userCharacterDao().getBeDataOrNull(characterId)
|
||||||
val vbData = database.userCharacterDao().getVbDataOrNull(characterId)
|
val vbData = database.userCharacterDao().getVbDataOrNull(characterId)
|
||||||
val exportFormat = transferPolicyResolver.resolveExportFormat(
|
|
||||||
characterId = characterId,
|
|
||||||
characterType = userCharacter.characterType,
|
|
||||||
transport = TransferTransport.HCE,
|
|
||||||
target = TransferTarget.VITAL_WEAR,
|
|
||||||
isBemCard = card.isBEm,
|
|
||||||
)
|
|
||||||
val normalizedTransformationCountdownMinutes = normalizeTransformationCountdownMinutes(
|
val normalizedTransformationCountdownMinutes = normalizeTransformationCountdownMinutes(
|
||||||
transformationCountdownMinutes = userCharacter.transformationCountdown,
|
transformationCountdownMinutes = userCharacter.transformationCountdown,
|
||||||
hasPossibleTransformations = hasPossibleTransformations(userCharacter.charId),
|
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()
|
return Character.newBuilder()
|
||||||
.setCardId(card.cardId)
|
.setCardId(card.cardId)
|
||||||
.setCardName(card.name)
|
.setCardName(card.name)
|
||||||
@ -68,7 +83,7 @@ class VitalWearCharacterExporter(
|
|||||||
Character.CharacterStats.newBuilder()
|
Character.CharacterStats.newBuilder()
|
||||||
.setSlotId(characterInfo.charId)
|
.setSlotId(characterInfo.charId)
|
||||||
.setVitals(characterWithSprites.vitalPoints)
|
.setVitals(characterWithSprites.vitalPoints)
|
||||||
.setTrainingTimeRemainingInSeconds(resolveTrainingSeconds(userCharacter.characterType, beData))
|
.setTrainingTimeRemainingInSeconds(resolveTrainingSeconds(transferDeviceType, beData))
|
||||||
.setTimeUntilNextTransformation(normalizedTransformationCountdownMinutes.toLong() * 60L)
|
.setTimeUntilNextTransformation(normalizedTransformationCountdownMinutes.toLong() * 60L)
|
||||||
.setTrainedBp(resolveTrainedBp(beData))
|
.setTrainedBp(resolveTrainedBp(beData))
|
||||||
.setTrainedHp(resolveTrainedHp(beData))
|
.setTrainedHp(resolveTrainedHp(beData))
|
||||||
@ -81,7 +96,7 @@ class VitalWearCharacterExporter(
|
|||||||
.setTotalWins(characterWithSprites.totalBattlesWon)
|
.setTotalWins(characterWithSprites.totalBattlesWon)
|
||||||
.setCurrentPhaseWins(characterWithSprites.currentPhaseBattlesWon)
|
.setCurrentPhaseWins(characterWithSprites.currentPhaseBattlesWon)
|
||||||
.setMood(characterWithSprites.mood)
|
.setMood(characterWithSprites.mood)
|
||||||
.setDeviceType(exportFormat.toTransferDeviceType())
|
.setDeviceType(transferDeviceType.toTransferDeviceType())
|
||||||
.setAgeInDays(userCharacter.ageInDays.coerceAtLeast(0))
|
.setAgeInDays(userCharacter.ageInDays.coerceAtLeast(0))
|
||||||
.setActivityLevel(userCharacter.activityLevel.coerceAtLeast(0))
|
.setActivityLevel(userCharacter.activityLevel.coerceAtLeast(0))
|
||||||
.setHeartRateCurrent(userCharacter.heartRateCurrent.coerceAtLeast(0))
|
.setHeartRateCurrent(userCharacter.heartRateCurrent.coerceAtLeast(0))
|
||||||
@ -106,12 +121,7 @@ class VitalWearCharacterExporter(
|
|||||||
.setFirmwareMajorVersion(beData?.majorVersion ?: 0)
|
.setFirmwareMajorVersion(beData?.majorVersion ?: 0)
|
||||||
.build()
|
.build()
|
||||||
)
|
)
|
||||||
.setSettings(
|
.setSettings(settingsBuilder.build())
|
||||||
Character.Settings.newBuilder()
|
|
||||||
.setTrainingInBackground(vwSettings?.trainingInBackground ?: false)
|
|
||||||
.setAllowedBattles(resolveAllowedBattles(vwSettings?.allowedBattles))
|
|
||||||
.build()
|
|
||||||
)
|
|
||||||
.putMaxAdventureCompletedByCard(card.name, (cardProgress - 1).coerceAtLeast(0))
|
.putMaxAdventureCompletedByCard(card.name, (cardProgress - 1).coerceAtLeast(0))
|
||||||
.addAllTransformationHistory(
|
.addAllTransformationHistory(
|
||||||
database.userCharacterDao().getTransformationHistoryForExport(characterId).map {
|
database.userCharacterDao().getTransformationHistoryForExport(characterId).map {
|
||||||
@ -122,6 +132,43 @@ class VitalWearCharacterExporter(
|
|||||||
.build()
|
.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()
|
.build()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -5,12 +5,13 @@ import com.github.cfogrady.vitalwear.common.data.SharedTransferSeenDao
|
|||||||
import com.github.cfogrady.vitalwear.protos.Character
|
import com.github.cfogrady.vitalwear.protos.Character
|
||||||
import com.github.nacabaro.vbhelper.database.AppDatabase
|
import com.github.nacabaro.vbhelper.database.AppDatabase
|
||||||
import com.github.nacabaro.vbhelper.domain.card.Card
|
import com.github.nacabaro.vbhelper.domain.card.Card
|
||||||
|
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.BECharacterData
|
||||||
import com.github.nacabaro.vbhelper.domain.device_data.UserCharacter
|
import com.github.nacabaro.vbhelper.domain.device_data.UserCharacter
|
||||||
import com.github.nacabaro.vbhelper.domain.device_data.VBCharacterData
|
import com.github.nacabaro.vbhelper.domain.device_data.VBCharacterData
|
||||||
import com.github.nacabaro.vbhelper.domain.device_data.VitalWearCharacterSettings
|
import com.github.nacabaro.vbhelper.domain.device_data.VitalWearCharacterSettings
|
||||||
import com.github.nacabaro.vbhelper.transfer.CharacterTransferPolicyResolver
|
import com.github.nacabaro.vbhelper.domain.characters.Sprite
|
||||||
import com.github.nacabaro.vbhelper.transfer.ExportFormat
|
|
||||||
import com.github.nacabaro.vbhelper.utils.DeviceType
|
import com.github.nacabaro.vbhelper.utils.DeviceType
|
||||||
import kotlinx.coroutines.flow.firstOrNull
|
import kotlinx.coroutines.flow.firstOrNull
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
@ -20,8 +21,6 @@ class VitalWearCharacterImporter(
|
|||||||
private val database: AppDatabase,
|
private val database: AppDatabase,
|
||||||
private val transferSeenDao: SharedTransferSeenDao,
|
private val transferSeenDao: SharedTransferSeenDao,
|
||||||
) {
|
) {
|
||||||
private val transferPolicyResolver = CharacterTransferPolicyResolver(database.characterTransferPolicyDao())
|
|
||||||
|
|
||||||
data class ImportResult(
|
data class ImportResult(
|
||||||
val success: Boolean,
|
val success: Boolean,
|
||||||
val message: String
|
val message: String
|
||||||
@ -29,15 +28,16 @@ class VitalWearCharacterImporter(
|
|||||||
|
|
||||||
fun importCharacter(character: Character): ImportResult {
|
fun importCharacter(character: Character): ImportResult {
|
||||||
val importedCard = resolveCard(character)
|
val importedCard = resolveCard(character)
|
||||||
|
?: createPlaceholderCard(character)
|
||||||
?: return ImportResult(
|
?: return ImportResult(
|
||||||
success = false,
|
success = false,
|
||||||
message = "Matching card not found in VBHelper. Import that card first."
|
message = "Matching card not found in VBHelper. Import that card first."
|
||||||
)
|
)
|
||||||
|
|
||||||
val slotId = character.characterStats.slotId
|
val slotId = character.characterStats.slotId
|
||||||
val cardCharacter = runCatching {
|
val cardCharacter = database.characterDao().getCharacterByMonIndexOrNull(slotId, importedCard.id)
|
||||||
database.characterDao().getCharacterByMonIndex(slotId, importedCard.id)
|
?: createPlaceholderCharacter(importedCard, character)
|
||||||
}.getOrNull() ?: return ImportResult(
|
?: return ImportResult(
|
||||||
success = false,
|
success = false,
|
||||||
message = "Character slot $slotId was not found on card ${importedCard.name}."
|
message = "Character slot $slotId was not found on card ${importedCard.name}."
|
||||||
)
|
)
|
||||||
@ -62,11 +62,6 @@ class VitalWearCharacterImporter(
|
|||||||
fallbackIsBeCharacter = fallbackIsBeCharacter,
|
fallbackIsBeCharacter = fallbackIsBeCharacter,
|
||||||
)
|
)
|
||||||
val isBeCharacter = deviceType == DeviceType.BEDevice
|
val isBeCharacter = deviceType == DeviceType.BEDevice
|
||||||
val observedImportFormat = resolveObservedHceImportFormat(
|
|
||||||
transferDeviceType = character.characterStats.deviceType,
|
|
||||||
importedCard = importedCard,
|
|
||||||
hasBePayloadStats = hasBePayloadStats,
|
|
||||||
)
|
|
||||||
|
|
||||||
val userCharacterId = database.userCharacterDao().insertCharacterData(
|
val userCharacterId = database.userCharacterDao().insertCharacterData(
|
||||||
UserCharacter(
|
UserCharacter(
|
||||||
@ -100,6 +95,21 @@ class VitalWearCharacterImporter(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
if (isBeCharacter) {
|
||||||
val abilityRarity = resolveAbilityRarity(character.characterStats.abilityRarity)
|
val abilityRarity = resolveAbilityRarity(character.characterStats.abilityRarity)
|
||||||
database.userCharacterDao().insertBECharacterData(
|
database.userCharacterDao().insertBECharacterData(
|
||||||
@ -129,9 +139,138 @@ class VitalWearCharacterImporter(
|
|||||||
majorVersion = character.characterStats.firmwareMajorVersion
|
majorVersion = character.characterStats.firmwareMajorVersion
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
} else {
|
}
|
||||||
database.userCharacterDao().insertVBCharacterData(
|
|
||||||
VBCharacterData(
|
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) {
|
||||||
|
// 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 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."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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,
|
id = userCharacterId,
|
||||||
generation = if (character.characterStats.generation > 0) {
|
generation = if (character.characterStats.generation > 0) {
|
||||||
character.characterStats.generation
|
character.characterStats.generation
|
||||||
@ -144,16 +283,35 @@ class VitalWearCharacterImporter(
|
|||||||
character.characterStats.trainedPp.coerceAtLeast(0)
|
character.characterStats.trainedPp.coerceAtLeast(0)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
)
|
database.userCharacterDao().insertVBCharacterData(vbProfile)
|
||||||
}
|
|
||||||
|
|
||||||
runBlocking {
|
if (forcedDeviceType == DeviceType.BEDevice) {
|
||||||
database.characterTransferPolicyDao().upsert(
|
val abilityRarity = resolveAbilityRarity(character.characterStats.abilityRarity)
|
||||||
transferPolicyResolver.policyForHceImport(
|
database.userCharacterDao().insertBECharacterData(
|
||||||
characterId = userCharacterId,
|
BECharacterData(
|
||||||
importedCardIsBem = importedCard.isBEm,
|
id = userCharacterId,
|
||||||
resolvedDeviceType = deviceType,
|
trainingHp = character.characterStats.trainedHp,
|
||||||
observedFormat = observedImportFormat,
|
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
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -182,7 +340,6 @@ class VitalWearCharacterImporter(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (insertedTransformationCount == 0) {
|
if (insertedTransformationCount == 0) {
|
||||||
// Keep HomeScreen renderable for freshly imported characters with empty history.
|
|
||||||
database.userCharacterDao().insertTransformation(
|
database.userCharacterDao().insertTransformation(
|
||||||
userCharacterId,
|
userCharacterId,
|
||||||
slotId,
|
slotId,
|
||||||
@ -252,6 +409,131 @@ class VitalWearCharacterImporter(
|
|||||||
return resolveCard(incomingCardName, incomingCardId)
|
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
|
||||||
|
}
|
||||||
|
|
||||||
private fun secondsToMinutes(seconds: Long): Int {
|
private fun secondsToMinutes(seconds: Long): Int {
|
||||||
if (seconds <= 0L) {
|
if (seconds <= 0L) {
|
||||||
return 0
|
return 0
|
||||||
@ -290,23 +572,6 @@ class VitalWearCharacterImporter(
|
|||||||
return rarities.getOrElse(rawValue) { resolveDefaultAbilityRarity() }
|
return rarities.getOrElse(rawValue) { resolveDefaultAbilityRarity() }
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun resolveObservedHceImportFormat(
|
|
||||||
transferDeviceType: Character.CharacterStats.TransferDeviceType,
|
|
||||||
importedCard: Card,
|
|
||||||
hasBePayloadStats: Boolean,
|
|
||||||
): ExportFormat {
|
|
||||||
return when (transferDeviceType) {
|
|
||||||
Character.CharacterStats.TransferDeviceType.TRANSFER_DEVICE_TYPE_BE -> ExportFormat.BE
|
|
||||||
Character.CharacterStats.TransferDeviceType.TRANSFER_DEVICE_TYPE_VB -> ExportFormat.VB
|
|
||||||
Character.CharacterStats.TransferDeviceType.UNRECOGNIZED,
|
|
||||||
Character.CharacterStats.TransferDeviceType.TRANSFER_DEVICE_TYPE_UNSPECIFIED -> if (importedCard.isBEm || hasBePayloadStats) {
|
|
||||||
ExportFormat.BE
|
|
||||||
} else {
|
|
||||||
ExportFormat.VB
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun markSeen(cardName: String, slotId: Int, cardId: Long, timestamp: Long) {
|
private fun markSeen(cardName: String, slotId: Int, cardId: Long, timestamp: Long) {
|
||||||
database.dexDao().insertCharacter(slotId, cardId, timestamp)
|
database.dexDao().insertCharacter(slotId, cardId, timestamp)
|
||||||
transferSeenDao.markSeen(cardName, slotId, timestamp)
|
transferSeenDao.markSeen(cardName, slotId, timestamp)
|
||||||
|
|||||||
@ -2,6 +2,8 @@
|
|||||||
import android.nfc.tech.IsoDep
|
import android.nfc.tech.IsoDep
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import com.github.cfogrady.vitalwear.protos.Character
|
import com.github.cfogrady.vitalwear.protos.Character
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
/**
|
/**
|
||||||
* Phone-side ISO-DEP client that drives the VitalWear HCE session.
|
* Phone-side ISO-DEP client that drives the VitalWear HCE session.
|
||||||
* Mirrors the APDU protocol defined in VitalWearHceProtocol on the watch.
|
* Mirrors the APDU protocol defined in VitalWearHceProtocol on the watch.
|
||||||
@ -20,10 +22,17 @@ class VitalWearHceReaderClient(private val isoDep: IsoDep) {
|
|||||||
private val INS_READ_CHUNK: Byte = 0x20
|
private val INS_READ_CHUNK: Byte = 0x20
|
||||||
private val INS_WRITE_CHUNK: Byte = 0x30
|
private val INS_WRITE_CHUNK: Byte = 0x30
|
||||||
private val INS_COMMIT: Byte = 0x40
|
private val INS_COMMIT: Byte = 0x40
|
||||||
|
private val INS_STATUS: Byte = 0x50
|
||||||
|
private val INS_SYNC_UI: Byte = 0x60
|
||||||
|
private val INS_VIBRATE: Byte = 0x70
|
||||||
private val MODE_WATCH_TO_PHONE: Byte = 0x01
|
private val MODE_WATCH_TO_PHONE: Byte = 0x01
|
||||||
private val MODE_PHONE_TO_WATCH: Byte = 0x02
|
private val MODE_PHONE_TO_WATCH: Byte = 0x02
|
||||||
private val VERSION: Byte = 0x01
|
private val VERSION: Byte = 0x01
|
||||||
private val SW_OK = 0x9000
|
private val SW_OK = 0x9000
|
||||||
|
private val STATUS_SUCCESS: Byte = 0x04
|
||||||
|
private val STATUS_FAILURE: Byte = 0x05
|
||||||
|
private val STATUS_SYNCING: Byte = 0x03
|
||||||
|
|
||||||
/** MOVE READ: read character bytes first, let caller import/validate, then COMMIT only on success.
|
/** MOVE READ: read character bytes first, let caller import/validate, then COMMIT only on success.
|
||||||
* If [onCharacterRead] returns false, COMMIT is skipped so source can remain on the watch.
|
* If [onCharacterRead] returns false, COMMIT is skipped so source can remain on the watch.
|
||||||
*/
|
*/
|
||||||
@ -67,8 +76,16 @@ class VitalWearHceReaderClient(private val isoDep: IsoDep) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** WRITE: watch has called armReceive() — phone pushes a character to the watch. */
|
/** WRITE: watch has called armReceive() — phone pushes a character to the watch. */
|
||||||
fun sendCharacterToWatch(character: Character) {
|
fun sendCharacterToWatch(character: Character, closeSyncUiAfterCommit: Boolean = true) {
|
||||||
selectAid()
|
selectAid()
|
||||||
|
|
||||||
|
// --- True to Toy Ceremony ---
|
||||||
|
// 1. Haptic Lock-on
|
||||||
|
sendApdu(INS_VIBRATE, byteArrayOf())
|
||||||
|
|
||||||
|
// 2. Visual Sync (Start Beam)
|
||||||
|
sendApdu(INS_SYNC_UI, byteArrayOf(0x01))
|
||||||
|
|
||||||
val payload = character.toByteArray()
|
val payload = character.toByteArray()
|
||||||
val negResponse = sendApdu(INS_NEGOTIATE, byteArrayOf(MODE_PHONE_TO_WATCH, VERSION))
|
val negResponse = sendApdu(INS_NEGOTIATE, byteArrayOf(MODE_PHONE_TO_WATCH, VERSION))
|
||||||
requireOk(negResponse, "NEGOTIATE")
|
requireOk(negResponse, "NEGOTIATE")
|
||||||
@ -82,6 +99,40 @@ class VitalWearHceReaderClient(private val isoDep: IsoDep) {
|
|||||||
offset = chunkEnd
|
offset = chunkEnd
|
||||||
}
|
}
|
||||||
requireOk(sendApdu(INS_COMMIT, byteArrayOf()), "COMMIT")
|
requireOk(sendApdu(INS_COMMIT, byteArrayOf()), "COMMIT")
|
||||||
|
|
||||||
|
// 3. Visual Sync (End Beam)
|
||||||
|
if (closeSyncUiAfterCommit) {
|
||||||
|
sendApdu(INS_SYNC_UI, byteArrayOf(0x00))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WRITE + confirmation polling.
|
||||||
|
* Returns true only when the watch reports import success.
|
||||||
|
*/
|
||||||
|
fun sendCharacterToWatchAndConfirm(character: Character): Boolean {
|
||||||
|
sendCharacterToWatch(character, closeSyncUiAfterCommit = false)
|
||||||
|
val confirmed = runBlocking {
|
||||||
|
repeat(12) {
|
||||||
|
val statusResponse = sendApdu(INS_STATUS, byteArrayOf())
|
||||||
|
requireOk(statusResponse, "STATUS")
|
||||||
|
val statusByte = statusResponse.dropLast(2).firstOrNull()
|
||||||
|
Log.d("HCE_CLIENT", "STATUS poll[$it]: statusByte=${statusByte?.toInt()?.and(0xFF)}")
|
||||||
|
if (statusByte == STATUS_SUCCESS) {
|
||||||
|
return@runBlocking true
|
||||||
|
}
|
||||||
|
if (statusByte == STATUS_FAILURE) {
|
||||||
|
return@runBlocking false
|
||||||
|
}
|
||||||
|
delay(250)
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
// End the sync UI after we have consumed terminal status.
|
||||||
|
runCatching {
|
||||||
|
sendApdu(INS_SYNC_UI, byteArrayOf(0x00))
|
||||||
|
}
|
||||||
|
return confirmed
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -42,7 +42,7 @@ fun BitmapData.getImageBitmap(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun BitmapData.getBitmap(): Bitmap {
|
fun BitmapData.getBitmap(): Bitmap {
|
||||||
return Bitmap.createBitmap(createARGBIntArray(), this.width, this.height, Bitmap.Config.HARDWARE)
|
return Bitmap.createBitmap(createARGBIntArray(), this.width, this.height, Bitmap.Config.ARGB_8888)
|
||||||
}
|
}
|
||||||
|
|
||||||
object ARGBMasks {
|
object ARGBMasks {
|
||||||
@ -64,7 +64,7 @@ fun BitmapData.getObscuredBitmap(): Bitmap {
|
|||||||
argbPixels[i] = BLACK
|
argbPixels[i] = BLACK
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return Bitmap.createBitmap(argbPixels, this.width, this.height, Bitmap.Config.HARDWARE)
|
return Bitmap.createBitmap(argbPixels, this.width, this.height, Bitmap.Config.ARGB_8888)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -7,6 +7,41 @@ message Character {
|
|||||||
string card_name = 1;
|
string card_name = 1;
|
||||||
int32 card_id = 2;
|
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 {
|
message CharacterStats {
|
||||||
enum TransferDeviceType {
|
enum TransferDeviceType {
|
||||||
TRANSFER_DEVICE_TYPE_UNSPECIFIED = 0;
|
TRANSFER_DEVICE_TYPE_UNSPECIFIED = 0;
|
||||||
@ -77,4 +112,8 @@ message Character {
|
|||||||
repeated TransformationEvent transformation_history = 5;
|
repeated TransformationEvent transformation_history = 5;
|
||||||
|
|
||||||
map<string, int32> max_adventure_completed_by_card = 6;
|
map<string, int32> max_adventure_completed_by_card = 6;
|
||||||
|
|
||||||
|
optional TransferCard transfer_card = 7;
|
||||||
|
|
||||||
|
optional TransferSpecies transfer_species = 8;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -178,7 +178,7 @@
|
|||||||
<string name="dex_chara_name_icon_description">Nome do personagem</string>
|
<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_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_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_stage_attribute_unknown">Stg: -, Atr: -</string>
|
||||||
<string name="dex_chara_stats_unknown">HP: -, BP: -, AP: -</string>
|
<string name="dex_chara_stats_unknown">HP: -, BP: -, AP: -</string>
|
||||||
<string name="dex_chara_requirements">
|
<string name="dex_chara_requirements">
|
||||||
|
|||||||
@ -68,14 +68,17 @@
|
|||||||
<string name="scan_transfer_complete_remove">Transfer complete. You can remove the phone now.</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_failed_try_again">Transfer failed. Reposition devices and try again.</string>
|
||||||
<string name="scan_transfer_cancelled">Transfer cancelled.</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_title">Settings</string>
|
||||||
|
|
||||||
<string name="settings_section_nfc">NFC Communication</string>
|
<string name="settings_section_nfc">NFC Communication</string>
|
||||||
<string name="settings_section_dim_bem">DiM/BEm management</string>
|
<string name="settings_section_dim_bem">VBH-VW card management</string>
|
||||||
<string name="settings_section_about">About and credits</string>
|
<string name="settings_section_about">About and credits</string>
|
||||||
<string name="settings_section_data">Data management</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_title">Import APK</string>
|
||||||
<string name="settings_import_apk_desc">
|
<string name="settings_import_apk_desc">
|
||||||
@ -84,7 +87,12 @@
|
|||||||
|
|
||||||
<string name="settings_import_card_title">Import card</string>
|
<string name="settings_import_card_title">Import card</string>
|
||||||
<string name="settings_import_card_desc">
|
<string name="settings_import_card_desc">
|
||||||
Import DiM/BEm
|
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
|
||||||
</string>
|
</string>
|
||||||
|
|
||||||
<string name="settings_credits_title">Credits</string>
|
<string name="settings_credits_title">Credits</string>
|
||||||
@ -103,6 +111,27 @@
|
|||||||
Import application database
|
Import application database
|
||||||
</string>
|
</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_title">Credits</string>
|
||||||
<string name="credits_section_reverse_engineering">Reverse engineering</string>
|
<string name="credits_section_reverse_engineering">Reverse engineering</string>
|
||||||
<string name="credits_section_app_development">Application development</string>
|
<string name="credits_section_app_development">Application development</string>
|
||||||
@ -187,7 +216,7 @@
|
|||||||
<string name="dex_chara_name_icon_description">Character name</string>
|
<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_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_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_stage_attribute_unknown">Stg: -, Atr: -</string>
|
||||||
<string name="dex_chara_stats_unknown">HP: -, BP: -, AP: -</string>
|
<string name="dex_chara_stats_unknown">HP: -, BP: -, AP: -</string>
|
||||||
<string name="dex_chara_requirements">
|
<string name="dex_chara_requirements">
|
||||||
|
|||||||
@ -1,5 +1,8 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<paths xmlns:android="http://schemas.android.com/apk/res/android">
|
<paths xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<files-path
|
||||||
|
name="logs"
|
||||||
|
path="logs/" />
|
||||||
<cache-path
|
<cache-path
|
||||||
name="exports"
|
name="exports"
|
||||||
path="exports" />
|
path="exports" />
|
||||||
|
|||||||
@ -0,0 +1,25 @@
|
|||||||
|
package com.github.nacabaro.vbhelper.screens.scanScreen.converters
|
||||||
|
|
||||||
|
import com.github.nacabaro.vbhelper.utils.DeviceType
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class ToNfcConverterProfileSelectionTest {
|
||||||
|
@Test
|
||||||
|
fun shouldEncodeAsBem_usesStoredTypeWhenForcedProfileMissing() {
|
||||||
|
assertTrue(ToNfcConverter.shouldEncodeAsBem(null, DeviceType.BEDevice))
|
||||||
|
assertFalse(ToNfcConverter.shouldEncodeAsBem(null, DeviceType.VBDevice))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun shouldEncodeAsBem_forcedVBWinsOverStoredBE() {
|
||||||
|
assertFalse(ToNfcConverter.shouldEncodeAsBem(DeviceType.VBDevice, DeviceType.BEDevice))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun shouldEncodeAsBem_forcedBEWinsOverStoredVB() {
|
||||||
|
assertTrue(ToNfcConverter.shouldEncodeAsBem(DeviceType.BEDevice, DeviceType.VBDevice))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@ -17,6 +17,12 @@ protobufJavalite = "4.33.4"
|
|||||||
roomRuntime = "2.8.4"
|
roomRuntime = "2.8.4"
|
||||||
vbNfcReader = "0.2.0-SNAPSHOT"
|
vbNfcReader = "0.2.0-SNAPSHOT"
|
||||||
dimReader = "2.0.0"
|
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"
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
androidx-compose-material = { module = "androidx.compose.material:material" }
|
androidx-compose-material = { module = "androidx.compose.material:material" }
|
||||||
@ -46,6 +52,13 @@ protobuf-gradle-plugin = { module = "com.google.protobuf:protobuf-gradle-plugin"
|
|||||||
protobuf-javalite = { module = "com.google.protobuf:protobuf-javalite", version.ref = "protobufJavalite" }
|
protobuf-javalite = { module = "com.google.protobuf:protobuf-javalite", version.ref = "protobufJavalite" }
|
||||||
vb-nfc-reader = { module = "com.github.cfogrady:vb-nfc-reader", version.ref = "vbNfcReader" }
|
vb-nfc-reader = { module = "com.github.cfogrady:vb-nfc-reader", version.ref = "vbNfcReader" }
|
||||||
dim-reader = { module = "com.github.cfogrady:vb-dim-reader", version.ref = "dimReader" }
|
dim-reader = { module = "com.github.cfogrady:vb-dim-reader", version.ref = "dimReader" }
|
||||||
|
play-services-wearable = { 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]
|
[plugins]
|
||||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||||
|
|||||||
@ -17,17 +17,13 @@ dependencyResolutionManagement {
|
|||||||
mavenLocal()
|
mavenLocal()
|
||||||
google()
|
google()
|
||||||
mavenCentral()
|
mavenCentral()
|
||||||
|
maven { url = uri("https://jitpack.io") }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
rootProject.name = "VBHelper"
|
rootProject.name = "VBHelper"
|
||||||
include(":app")
|
include(":app")
|
||||||
|
|
||||||
includeBuild("../VB-DIM-Reader-2.0.0") {
|
|
||||||
dependencySubstitution {
|
|
||||||
substitute(module("com.github.cfogrady:vb-dim-reader")).using(project(":"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
includeBuild("../vb-nfc-reader") {
|
includeBuild("../vb-nfc-reader") {
|
||||||
dependencySubstitution {
|
dependencySubstitution {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user