diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 3ec675a5f..1eb6ecd60 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -309,3 +309,7 @@ # PdfBox-Android - ignore optional JPEG2000 codec dependencies -dontwarn com.gemalto.jp2.JP2Decoder -dontwarn com.gemalto.jp2.JP2Encoder + +# Keep RNBridge models to ensure Gson serialization works in release builds +-keep class com.flowfoundation.wallet.reactnative.bridge.RNBridge { *; } +-keep class com.flowfoundation.wallet.reactnative.bridge.RNBridge$* { *; } diff --git a/app/src/main/java/com/flowfoundation/wallet/manager/app/ActivityManager.kt b/app/src/main/java/com/flowfoundation/wallet/manager/app/ActivityManager.kt index 2a6abbfbc..d3635e478 100644 --- a/app/src/main/java/com/flowfoundation/wallet/manager/app/ActivityManager.kt +++ b/app/src/main/java/com/flowfoundation/wallet/manager/app/ActivityManager.kt @@ -73,6 +73,11 @@ object ActivityManager { } } + @JvmStatic + fun getReactContext(): ReactApplicationContext? { + return reactContextRef?.get() + } + /** * Clear all references * Should be called when the application is being destroyed @@ -91,4 +96,4 @@ object ActivityManager { fun hasActivity(): Boolean { return getCurrentActivity() != null } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/flowfoundation/wallet/manager/config/AppConfig.kt b/app/src/main/java/com/flowfoundation/wallet/manager/config/AppConfig.kt index 28551cfc8..f6be9f03a 100644 --- a/app/src/main/java/com/flowfoundation/wallet/manager/config/AppConfig.kt +++ b/app/src/main/java/com/flowfoundation/wallet/manager/config/AppConfig.kt @@ -43,6 +43,8 @@ object AppConfig { fun bridgeFeePayer() = if (isTestnet()) config().getBridgeFeePayer().testnet else config().getBridgeFeePayer().mainnet + fun checkBloctoKeyRotation() = isDev() || isTesting() || (config().getFeatures().bloctoKeyRotation ?: false) + fun addressRegistry(network: Int): Map { return when (network) { NETWORK_TESTNET -> flowAddressRegistry().testnet @@ -196,6 +198,10 @@ private data class Features( val txWarning: Boolean?, @SerializedName("cover_bridge_fee") val coverBridgeFee: Boolean?, + @SerializedName("blocto_key_rotation") + val bloctoKeyRotation: Boolean?, + @SerializedName("coa_migration") + val coaMigration: Boolean? ) private data class Payer( diff --git a/app/src/main/java/com/flowfoundation/wallet/manager/rotation/BloctoDetectorService.kt b/app/src/main/java/com/flowfoundation/wallet/manager/rotation/BloctoDetectorService.kt new file mode 100644 index 000000000..40fa375f1 --- /dev/null +++ b/app/src/main/java/com/flowfoundation/wallet/manager/rotation/BloctoDetectorService.kt @@ -0,0 +1,64 @@ +package com.flowfoundation.wallet.manager.rotation + +import com.flowfoundation.wallet.manager.flow.FlowCadenceApi +import com.flowfoundation.wallet.utils.Env +import com.flowfoundation.wallet.utils.logd +import org.onflow.flow.models.HashingAlgorithm +import org.onflow.flow.models.SigningAlgorithm +import androidx.core.content.edit + +class BloctoDetectorService { + data class Result( + val isBlocto: Boolean, + val needRevoke: Boolean, + val revokeKeyIndexes: List + ) + + companion object { + private const val PREF_NAME = "blocto_detector_cache" + private const val TAG = "BloctoDetectorService" + + private fun cacheKey(address: String): String { + return "blocto.detector.false.${address.lowercase()}" + } + + suspend fun detectBloctoKey(address: String): Result { + logd(TAG, "detectBloctoKey() called with address: $address") + val normalized = address.lowercase() + val prefs = Env.getApp().getSharedPreferences(PREF_NAME, 0) + if (prefs.getBoolean(cacheKey(normalized), false)) { + logd(TAG, "detectBloctoKey() - cached result found (not Blocto)") + return Result(isBlocto = false, needRevoke = false, revokeKeyIndexes = emptyList()) + } + + val account = FlowCadenceApi.getAccount(normalized) + val keys = account.keys ?: emptyList() + logd(TAG, "detectBloctoKey() - fetched ${keys.size} keys for account") + + val candidateKeys = keys.filter { key -> + key.signingAlgorithm == SigningAlgorithm.ECDSA_secp256k1 && + key.hashingAlgorithm == HashingAlgorithm.SHA3_256 + } + + val hasWeight999 = candidateKeys.any { it.weight.toInt() == 999 } + val hasWeight1 = candidateKeys.any { it.weight.toInt() == 1 } + val isBlocto = hasWeight999 && hasWeight1 + val revokeKeyIndexes = candidateKeys + .filter { !it.revoked } + .map { it.index.toInt() } + val needRevoke = isBlocto && revokeKeyIndexes.isNotEmpty() + + logd(TAG, "detectBloctoKey() - isBlocto: $isBlocto, needRevoke: $needRevoke, revokeKeyIndexes: $revokeKeyIndexes") + + if (!isBlocto) { + prefs.edit { putBoolean(cacheKey(normalized), true) } + } + + return Result( + isBlocto = isBlocto, + needRevoke = needRevoke, + revokeKeyIndexes = revokeKeyIndexes + ) + } + } +} diff --git a/app/src/main/java/com/flowfoundation/wallet/manager/wallet/WalletManager.kt b/app/src/main/java/com/flowfoundation/wallet/manager/wallet/WalletManager.kt index 20b10a297..42a9a5fbd 100644 --- a/app/src/main/java/com/flowfoundation/wallet/manager/wallet/WalletManager.kt +++ b/app/src/main/java/com/flowfoundation/wallet/manager/wallet/WalletManager.kt @@ -36,6 +36,10 @@ import java.util.concurrent.atomic.AtomicReference import com.flowfoundation.wallet.firebase.auth.firebaseUid import com.flowfoundation.wallet.manager.account.AccountWalletManager import com.flowfoundation.wallet.manager.account.KeyStoreMigrationManager +import com.flowfoundation.wallet.manager.config.AppConfig +import com.flowfoundation.wallet.manager.rotation.BloctoDetectorService +import com.flowfoundation.wallet.reactnative.ReactNativeActivity +import com.flowfoundation.wallet.reactnative.bridge.RNBridge object WalletManager { private val TAG = WalletManager::class.java.simpleName @@ -48,6 +52,9 @@ object WalletManager { private val initializationLock = Object() private var isInitialized = false + private var lastRotationCheckTime = 0L + private const val ROTATION_CHECK_COOLDOWN = 2000L + // Add a job reference and callback mechanism private var initializationJob: kotlinx.coroutines.Job? = null private val walletReadyCallbacks = mutableListOf<() -> Unit>() @@ -468,6 +475,43 @@ object WalletManager { return networkStr ?: chainNetWorkString() } + fun checkKeyRotation(activity: android.app.Activity) { + if (AppConfig.checkBloctoKeyRotation().not()) { + return + } + val address = wallet()?.walletAddress() + logd(TAG, "checkKeyRotation() called with address: $address") + + if (address.isNullOrBlank()) { + logd(TAG, "checkKeyRotation() - address is blank, returning") + return + } + + val currentTime = System.currentTimeMillis() + if (currentTime - lastRotationCheckTime < ROTATION_CHECK_COOLDOWN) { + logd(TAG, "checkKeyRotation() - skipped due to cooldown") + return + } + lastRotationCheckTime = currentTime + + if (isChildAccount(address) || EVMWalletManager.isEVMWalletAddress(address)) { + logd(TAG, "checkKeyRotation() - address is child account or EVM address, returning") + return + } + + ioScope { + logd(TAG, "checkKeyRotation() - detecting Blocto key...") + val result = BloctoDetectorService.detectBloctoKey(address) + logd(TAG, "checkKeyRotation() - detection result: $result") + if (result.needRevoke) { + logd(TAG, "checkKeyRotation() - need revoke, launching BACKUP_TIP") + uiScope { + ReactNativeActivity.launch(activity, RNBridge.ScreenType.BACKUP_TIP) + } + } + } + } + fun selectedWalletAddress(): String { val currentTime = System.currentTimeMillis() if (currentTime - lastAddressCheck < ADDRESS_CACHE_DURATION) { diff --git a/app/src/main/java/com/flowfoundation/wallet/page/main/MainActivity.kt b/app/src/main/java/com/flowfoundation/wallet/page/main/MainActivity.kt index 6132ee930..d80e57023 100644 --- a/app/src/main/java/com/flowfoundation/wallet/page/main/MainActivity.kt +++ b/app/src/main/java/com/flowfoundation/wallet/page/main/MainActivity.kt @@ -21,7 +21,6 @@ import com.flowfoundation.wallet.page.main.model.MainContentModel import com.flowfoundation.wallet.page.main.model.MainDrawerLayoutModel import com.flowfoundation.wallet.page.main.presenter.DrawerLayoutPresenter import com.flowfoundation.wallet.page.main.presenter.MainContentPresenter -import com.flowfoundation.wallet.BuildConfig import com.flowfoundation.wallet.page.others.NotificationPermissionActivity import com.flowfoundation.wallet.page.window.WindowFrame import com.flowfoundation.wallet.utils.debug.fragments.debugViewer.DebugViewerDataSource @@ -33,6 +32,7 @@ import com.flowfoundation.wallet.utils.isRegistered import com.flowfoundation.wallet.utils.uiScope import com.instabug.bug.BugReporting import com.instabug.library.Instabug +import com.flowfoundation.wallet.manager.wallet.WalletManager class MainActivity : BaseActivity() { @@ -72,7 +72,7 @@ class MainActivity : BaseActivity() { firebaseInformationCheck() } contentPresenter.checkAndShowContent() - + // Navigate to target tab if specified if (targetTabIndex >= 0) { val targetTab = HomeTab.values().find { it.index == targetTabIndex } @@ -85,6 +85,7 @@ class MainActivity : BaseActivity() { NotificationPermissionActivity.launch(this) } configurationInstabugBugReport() + WalletManager.checkKeyRotation(this) } private fun configurationInstabugBugReport() { @@ -170,4 +171,4 @@ class MainActivity : BaseActivity() { fun getInstance() = INSTANCE } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/flowfoundation/wallet/page/restore/keystore/presenter/PrivateKeyStoreUsernamePresenter.kt b/app/src/main/java/com/flowfoundation/wallet/page/restore/keystore/presenter/PrivateKeyStoreUsernamePresenter.kt index c2927d6ff..ed21d83bf 100644 --- a/app/src/main/java/com/flowfoundation/wallet/page/restore/keystore/presenter/PrivateKeyStoreUsernamePresenter.kt +++ b/app/src/main/java/com/flowfoundation/wallet/page/restore/keystore/presenter/PrivateKeyStoreUsernamePresenter.kt @@ -35,7 +35,7 @@ class PrivateKeyStoreUsernamePresenter( binding.editText.addTextChangedListener(object : SimpleTextWatcher() { override fun onTextChanged(s: CharSequence, start: Int, before: Int, count: Int) { hideStateView() - binding.progressBar.setVisible(true) + binding.progressBar.setVisible(s.isNotEmpty()) binding.nextButton.isEnabled = false viewModel.verifyUsername(s.toString()) } diff --git a/app/src/main/java/com/flowfoundation/wallet/page/security/recovery/SecurityRecoveryActivity.kt b/app/src/main/java/com/flowfoundation/wallet/page/security/recovery/SecurityRecoveryActivity.kt index b000430e5..d3801ac12 100644 --- a/app/src/main/java/com/flowfoundation/wallet/page/security/recovery/SecurityRecoveryActivity.kt +++ b/app/src/main/java/com/flowfoundation/wallet/page/security/recovery/SecurityRecoveryActivity.kt @@ -14,10 +14,12 @@ import com.flowfoundation.wallet.page.walletcreate.fragments.mnemonic.MnemonicMo import com.flowfoundation.wallet.utils.extensions.res2String import com.flowfoundation.wallet.utils.extensions.setVisible import com.flowfoundation.wallet.utils.ioScope +import com.flowfoundation.wallet.utils.isNightMode import com.flowfoundation.wallet.utils.textToClipboard import com.flowfoundation.wallet.utils.toast import com.flowfoundation.wallet.wallet.Wallet import com.flowfoundation.wallet.widgets.itemdecoration.GridSpaceItemDecoration +import com.zackratos.ultimatebarx.ultimatebarx.UltimateBarX import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -31,6 +33,8 @@ class SecurityRecoveryActivity : BaseActivity() { super.onCreate(savedInstanceState) binding = ActivitySecurityRecoveryBinding.inflate(layoutInflater) setContentView(binding.root) + UltimateBarX.with(this).fitWindow(true).light(!isNightMode(this)).applyStatusBar() + UltimateBarX.with(this).fitWindow(true).light(!isNightMode(this)).applyNavigationBar() setupToolbar() initPhrases() } @@ -90,4 +94,4 @@ class SecurityRecoveryActivity : BaseActivity() { fun launchIntent(context: Context): Intent = Intent(context, SecurityRecoveryActivity::class.java) } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/flowfoundation/wallet/page/walletcreate/fragments/username/WalletCreateUsernameViewModel.kt b/app/src/main/java/com/flowfoundation/wallet/page/walletcreate/fragments/username/WalletCreateUsernameViewModel.kt index 1bd2f570c..688948ce3 100644 --- a/app/src/main/java/com/flowfoundation/wallet/page/walletcreate/fragments/username/WalletCreateUsernameViewModel.kt +++ b/app/src/main/java/com/flowfoundation/wallet/page/walletcreate/fragments/username/WalletCreateUsernameViewModel.kt @@ -27,13 +27,13 @@ class WalletCreateUsernameViewModel : ViewModel() { fun verifyUsername(username: String) { this.username = username + handler.removeCallbacks(usernameCheckTask) val verifyMsg = usernameVerify(username) if (!verifyMsg.isNullOrEmpty()) { usernameStateLiveData.postValue(Pair(false, verifyMsg)) return } - handler.removeCallbacks(usernameCheckTask) handler.postDelayed(usernameCheckTask, 500) } diff --git a/app/src/main/java/com/flowfoundation/wallet/reactnative/ReactNativeActivity.kt b/app/src/main/java/com/flowfoundation/wallet/reactnative/ReactNativeActivity.kt index 3a0d4f9f7..88a97d5e7 100644 --- a/app/src/main/java/com/flowfoundation/wallet/reactnative/ReactNativeActivity.kt +++ b/app/src/main/java/com/flowfoundation/wallet/reactnative/ReactNativeActivity.kt @@ -2,7 +2,6 @@ package com.flowfoundation.wallet.reactnative import android.content.Context import android.content.Intent import android.os.Bundle -import android.util.Log import com.facebook.react.ReactActivity import com.facebook.react.ReactActivityDelegate import com.facebook.react.defaults.DefaultReactActivityDelegate @@ -11,8 +10,17 @@ import com.flowfoundation.wallet.reactnative.bridge.RNBridge import com.flowfoundation.wallet.manager.wallet.WalletManager import com.flowfoundation.wallet.manager.app.chainNetWorkString import com.flowfoundation.wallet.reactnative.bridge.createWalletAccountFromAddress +import com.flowfoundation.wallet.utils.logd import com.flowfoundation.wallet.wallet.toAddress import com.google.gson.Gson +import com.google.gson.annotations.SerializedName + +private val RNBridge.ScreenType.value: String + get() = try { + RNBridge.ScreenType::class.java.getField(this.name).getAnnotation(SerializedName::class.java)?.value ?: this.toString() + } catch (e: Exception) { + this.toString() + } class ReactNativeActivity : ReactActivity() { @@ -42,15 +50,15 @@ class ReactNativeActivity : ReactActivity() { // Top level props address?.let { launchOptions.putString("address", it) - Log.d(TAG, "Added address to launch options: $it") + logd(TAG, "Added address to launch options: $it") } network?.let { launchOptions.putString("network", it) - Log.d(TAG, "Added network to launch options: $it") + logd(TAG, "Added network to launch options: $it") } initialRoute?.let { launchOptions.putString("initialRoute", it) - Log.d(TAG, "Added initialRoute to launch options: $it") + logd(TAG, "Added initialRoute to launch options: $it") } // Create initialProps object if we have screen or sendToConfig @@ -59,36 +67,37 @@ class ReactNativeActivity : ReactActivity() { screen?.let { initialPropsBundle.putString("screen", it) - Log.d(TAG, "Added screen to initialProps: $it") + logd(TAG, "Added screen to initialProps: $it") } sendToConfigJson?.let { jsonString -> - Log.d(TAG, "Processing sendToConfig JSON: $jsonString") + logd(TAG, "Processing sendToConfig JSON: $jsonString") initialPropsBundle.putString("sendToConfig", jsonString) } launchOptions.putBundle("initialProps", initialPropsBundle) - Log.d(TAG, "Added initialProps bundle with ${initialPropsBundle.size()} properties") + logd(TAG, "Added initialProps bundle with ${initialPropsBundle.size()} " + + "properties") } } - Log.d(TAG, "Launch options created with ${launchOptions.size()} properties") + logd(TAG, "Launch options created with ${launchOptions.size()} properties") return launchOptions } } } override fun onCreate(savedInstanceState: Bundle?) { - Log.d(TAG, "onCreate called") + logd(TAG, "onCreate called") super.onCreate(savedInstanceState) // Log the intent extras for debugging intent?.let { - Log.d(TAG, "Intent extras:") - Log.d(TAG, " address: ${it.getStringExtra("address")}") - Log.d(TAG, " network: ${it.getStringExtra("network")}") - Log.d(TAG, " initialRoute: ${it.getStringExtra("initialRoute")}") - Log.d(TAG, " screen: ${it.getStringExtra("screen")}") - Log.d(TAG, " sendToConfig: ${it.getStringExtra("sendToConfig")}") + logd(TAG, "Intent extras:") + logd(TAG, " address: ${it.getStringExtra("address")}") + logd(TAG, " network: ${it.getStringExtra("network")}") + logd(TAG, " initialRoute: ${it.getStringExtra("initialRoute")}") + logd(TAG, " screen: ${it.getStringExtra("screen")}") + logd(TAG, " sendToConfig: ${it.getStringExtra("sendToConfig")}") } } @@ -121,7 +130,7 @@ class ReactNativeActivity : ReactActivity() { } } ?: "SelectTokens" } - RNBridge.ScreenType.TOKEN_DETAIL -> "Home" + RNBridge.ScreenType.BACKUP_TIP -> "KeyRotationTip" } } @@ -145,10 +154,10 @@ class ReactNativeActivity : ReactActivity() { * Launch the React Native Demo Activity with parameters */ fun launch(context: Context, screenType: RNBridge.ScreenType?, address: String?, network: String?) { - Log.d(TAG, "Launching ReactNativeActivity with params:") - Log.d(TAG, " screenType: $screenType") - Log.d(TAG, " address: $address") - Log.d(TAG, " network: $network") + logd(TAG, "Launching ReactNativeActivity with params:") + logd(TAG, " screenType: $screenType") + logd(TAG, " address: $address") + logd(TAG, " network: $network") val intent = Intent(context, ReactNativeActivity::class.java) @@ -165,7 +174,7 @@ class ReactNativeActivity : ReactActivity() { } screenType?.let { // Convert screen enum to string and determine route based on screen type - val screenString = if (it == RNBridge.ScreenType.SEND_ASSET) "send-asset" else "token-detail" + val screenString = it.value val routeName = getRouteName(it, null) intent.putExtra("screen", screenString) @@ -178,10 +187,10 @@ class ReactNativeActivity : ReactActivity() { * Launch with InitialProps containing screen and SendToConfig */ fun launchWithConfig(context: Context, screenType: RNBridge.ScreenType, sendToConfig: RNBridge.SendToConfig?, address: String?, network: String?) { - Log.d(TAG, "Launching ReactNativeActivity with config:") - Log.d(TAG, " screenType: $screenType") - Log.d(TAG, " address: $address") - Log.d(TAG, " network: $network") + logd(TAG, "Launching ReactNativeActivity with config:") + logd(TAG, " screenType: $screenType") + logd(TAG, " address: $address") + logd(TAG, " network: $network") val intent = Intent(context, ReactNativeActivity::class.java) @@ -198,7 +207,7 @@ class ReactNativeActivity : ReactActivity() { } // Convert screen enum to string and determine route based on screen type and config - val screenString = if (screenType == RNBridge.ScreenType.SEND_ASSET) "send-asset" else "token-detail" + val screenString = screenType.value val routeName = getRouteName(screenType, sendToConfig) intent.putExtra("screen", screenString) @@ -208,7 +217,7 @@ class ReactNativeActivity : ReactActivity() { sendToConfig?.let { val sendToConfigJson = Gson().toJson(it) intent.putExtra("sendToConfig", sendToConfigJson) - Log.d(TAG, "sendToConfig JSON: $sendToConfigJson") + logd(TAG, "sendToConfig JSON: $sendToConfigJson") } context.startActivity(intent) diff --git a/app/src/main/java/com/flowfoundation/wallet/reactnative/bridge/BridgeModels.kt b/app/src/main/java/com/flowfoundation/wallet/reactnative/bridge/BridgeModels.kt index cf5198ba0..5d99ef001 100644 --- a/app/src/main/java/com/flowfoundation/wallet/reactnative/bridge/BridgeModels.kt +++ b/app/src/main/java/com/flowfoundation/wallet/reactnative/bridge/BridgeModels.kt @@ -18,7 +18,7 @@ class RNBridge { enum class ScreenType { @SerializedName("send-asset") SEND_ASSET, - @SerializedName("token-detail") TOKEN_DETAIL + @SerializedName("backup-tip") BACKUP_TIP } data class EmojiInfo( @@ -116,6 +116,21 @@ class RNBridge { val contacts: List ) + data class AccountKeySignature( + @SerializedName("public_key") + val public_key: String, + @SerializedName("hash_algo") + val hash_algo: Int, + @SerializedName("sign_algo") + val sign_algo: Int, + @SerializedName("signature") + val signature: String, + @SerializedName("sign_message") + val sign_message: String?, + @SerializedName("weight") + val weight: Int? + ) + data class SendToConfig( @SerializedName("selectedToken") val selectedToken: TokenModel?, diff --git a/app/src/main/java/com/flowfoundation/wallet/reactnative/bridge/NativeEventModels.kt b/app/src/main/java/com/flowfoundation/wallet/reactnative/bridge/NativeEventModels.kt new file mode 100644 index 000000000..088a33bfb --- /dev/null +++ b/app/src/main/java/com/flowfoundation/wallet/reactnative/bridge/NativeEventModels.kt @@ -0,0 +1,49 @@ +// +// NativeEventModels.kt +// +// Auto-generated from TypeScript bridge types +// Do not edit manually +// + +package com.flowfoundation.wallet.reactnative.bridge + +import com.google.gson.annotations.SerializedName + +class RNNativeEvent { + data class KeyRotationCheckParams( + @SerializedName("address") + val address: String + ) + + data class KeyRotationCheckResult( + @SerializedName("address") + val address: String, + @SerializedName("isBlocto") + val isBlocto: Boolean + ) + + data class NativeRequestPayload( + @SerializedName("requestId") + val requestId: String, + @SerializedName("eventName") + val eventName: NativeEventName, + @SerializedName("paramsJson") + val paramsJson: String + ) + + data class NativeResponsePayload( + @SerializedName("requestId") + val requestId: String, + @SerializedName("eventName") + val eventName: NativeEventName, + @SerializedName("resultJson") + val resultJson: String?, + @SerializedName("error") + val error: String? + ) + + enum class NativeEventName { + @SerializedName("keyRotationCheck") KEYROTATIONCHECK + } + +} diff --git a/app/src/main/java/com/flowfoundation/wallet/reactnative/bridge/NativeFRWBridge.kt b/app/src/main/java/com/flowfoundation/wallet/reactnative/bridge/NativeFRWBridge.kt index ff9b752c3..3b161ddf5 100644 --- a/app/src/main/java/com/flowfoundation/wallet/reactnative/bridge/NativeFRWBridge.kt +++ b/app/src/main/java/com/flowfoundation/wallet/reactnative/bridge/NativeFRWBridge.kt @@ -3,6 +3,7 @@ package com.flowfoundation.wallet.reactnative.bridge import com.facebook.react.bridge.Promise import com.facebook.react.bridge.ReactApplicationContext import com.facebook.react.bridge.ReadableArray +import com.facebook.react.bridge.ReadableMap import com.facebook.react.bridge.WritableNativeMap import com.facebook.react.bridge.WritableNativeArray import com.facebook.react.bridge.WritableMap @@ -13,6 +14,7 @@ import com.flowfoundation.wallet.manager.key.CryptoProviderManager import com.flowfoundation.wallet.manager.wallet.WalletManager import com.flowfoundation.wallet.manager.wallet.walletAddress import com.flowfoundation.wallet.BuildConfig +import com.flowfoundation.wallet.manager.app.ActivityManager import com.flowfoundation.wallet.manager.evm.EVMWalletManager import com.flowfoundation.wallet.cache.recentTransactionCache import com.flowfoundation.wallet.manager.flowjvm.currentKeyId @@ -49,6 +51,18 @@ import com.flowfoundation.wallet.utils.logd import com.flowfoundation.wallet.utils.loge import com.flowfoundation.wallet.utils.logw import java.util.Locale +import wallet.core.jni.HDWallet +import com.flowfoundation.wallet.wallet.Wallet +import com.flowfoundation.wallet.manager.key.HDWalletCryptoProvider +import com.flow.wallet.keys.SeedPhraseKey +import org.onflow.flow.models.SigningAlgorithm +import org.onflow.flow.models.HashingAlgorithm +import org.onflow.flow.models.DomainTag +import android.view.WindowManager +import com.flowfoundation.wallet.firebase.auth.firebaseUid +import com.flowfoundation.wallet.utils.Env.getStorage +import androidx.core.content.edit +import com.flowfoundation.wallet.utils.Env class NativeFRWBridge(reactContext: ReactApplicationContext) : NativeFRWBridgeSpec(reactContext) { @@ -57,6 +71,8 @@ class NativeFRWBridge(reactContext: ReactApplicationContext) : NativeFRWBridgeSp init { logd(TAG, "NativeFRWBridge initialized with context: ${reactContext != null}") logd(TAG, "React context is active: ${reactContext.hasActiveCatalystInstance()}") + ActivityManager.setReactContext(reactContext) + System.loadLibrary("TrustWalletCore") } override fun getName(): String { @@ -155,6 +171,175 @@ class NativeFRWBridge(reactContext: ReactApplicationContext) : NativeFRWBridgeSp } } + override fun createSeedKey(strength: Double, promise: Promise) { + logd(TAG, "createSeedKey() called - strength: $strength") + try { + val mnemonicStrength = strength.toInt() + val hdWallet = HDWallet(mnemonicStrength, "") + val mnemonic = hdWallet.mnemonic() + + // Derive key using P256k1/SHA2_256 to match Flow defaults + val seedPhraseKey = SeedPhraseKey( + mnemonicString = mnemonic, + passphrase = "", + derivationPath = "m/44'/539'/0'/0/0", + keyPair = null, + storage = getStorage() + ) + val cryptoProvider = HDWalletCryptoProvider(seedPhraseKey) + val publicKey = cryptoProvider.getPublicKey() + + val flowKey = WritableNativeMap().apply { + putString("publicKey", publicKey) + putInt("signAlgo", cryptoProvider.getSignatureAlgorithm().cadenceIndex) + putInt("hashAlgo", cryptoProvider.getHashAlgorithm().cadenceIndex) + putInt("weight", 1000) + putString("signAlgoString", cryptoProvider.getSignatureAlgorithm().value) + putString("hashAlgoString", cryptoProvider.getHashAlgorithm().value) + } + + val result = WritableNativeMap().apply { + putString("seedphrase", mnemonic) + putMap("flowKey", flowKey) + } + + promise.resolve(result) + } catch (e: Exception) { + promise.reject("CREATE_KEY_ERROR", e.message, e) + } + } + + override fun saveNewKey(key: ReadableMap, promise: Promise) { + logd(TAG, "saveNewKey() called") + ioScope { + try { + val seedPhrase = key.getString("seedphrase") + if (seedPhrase.isNullOrEmpty()) { + throw IllegalArgumentException("Seed phrase is empty") + } + + // Update and store the mnemonic in Wallet + Wallet.store().updateMnemonic(seedPhrase).store() + + logd(TAG, "saveNewKey() - Seed phrase saved successfully") + uiScope { + promise.resolve(null) + } + } catch (e: Exception) { + loge(TAG, "saveNewKey() error: ${e.message}") + uiScope { + promise.reject("SAVE_KEY_ERROR", e.message, e) + } + } + } + } + + override fun removeOldKey(address: String, publicKey: String, promise: Promise) { + logd(TAG, "removeOldKey() called - address: $address, publicKey: $publicKey") + ioScope { + try { + val currentProvider = CryptoProviderManager.getCurrentCryptoProvider() ?: throw IllegalStateException("No active crypto provider found") + if (currentProvider.getPublicKey() != publicKey) { + logd(TAG, "removeOldKey() - Public key does not match current provider") + return@ioScope + } + val account = AccountManager.get() ?: throw IllegalStateException("No active account found") + val uid = firebaseUid() ?: account.wallet?.id + val keystoreInfo = account.keyStoreInfo + + // 1. Backup if exists + if (!keystoreInfo.isNullOrBlank()) { + logd(TAG, "Backing up keystore info for user: $uid") + val prefs = Env.getApp().getSharedPreferences("backup_keystore", android.content.Context.MODE_PRIVATE) + prefs.edit { putString("backup_info_$uid", keystoreInfo) } + } else { + logd(TAG, "No keystore info to backup") + } + + // 2. Remove keystore info from account + account.keyStoreInfo = null + + // Update AccountManager cache + // AccountManager.add(account) will update the list and cache it + AccountManager.add(account) + + // 3. Clear CryptoProvider + CryptoProviderManager.clear() + + // 4. Force reload to verify + val newProvider = CryptoProviderManager.getCurrentCryptoProvider() + if (newProvider is HDWalletCryptoProvider) { + logd(TAG, "Successfully switched to HDWalletCryptoProvider") + } else { + logw(TAG, "Provider is not HDWalletCryptoProvider after rotation: ${newProvider?.javaClass?.simpleName}") + } + + // Mark as rotated in BloctoDetector cache to prevent re-triggering + try { + val bloctoPrefs = Env.getApp().getSharedPreferences("blocto_detector_cache", 0) + val cacheKey = "blocto.detector.false.${address.lowercase()}" + bloctoPrefs.edit { putBoolean(cacheKey, true) } + logd(TAG, "Marked address $address as rotated in BloctoDetector cache") + } catch (e: Exception) { + loge(TAG, "Failed to update BloctoDetector cache: ${e.message}") + } + + uiScope { promise.resolve(null) } + } catch (e: Exception) { + loge(TAG, "removeOldKey() error: ${e.message}") + uiScope { promise.reject("REMOVE_OLD_KEY_ERROR", e.message, e) } + } + } + } + + override fun signRotationRequest(address: String, signatureData: String, promise: Promise) { + logd(TAG, "signRotationRequest() called - address: $address, signatureData: $signatureData") + ioScope { + try { + val currentAddress = WalletManager.wallet()?.walletAddress() ?: "" + if (currentAddress != address) { + throw IllegalArgumentException("Address mismatch: expected $address, got $currentAddress") + } + + val cryptoProvider = CryptoProviderManager.getCurrentCryptoProvider() + ?: throw IllegalStateException("No crypto provider found") + + // Add User Domain Tag (FLOW-V0.0-USER) + val dataToSign = DomainTag.User.bytes + signatureData.encodeToByteArray() + val signature = cryptoProvider.signData(dataToSign) + + val model = RNBridge.AccountKeySignature( + public_key = cryptoProvider.getPublicKey(), + hash_algo = cryptoProvider.getHashAlgorithm().cadenceIndex, + sign_algo = cryptoProvider.getSignatureAlgorithm().cadenceIndex, + signature = signature, + sign_message = signatureData, + weight = cryptoProvider.getKeyWeight(), + ) + + uiScope { promise.resolve(bridgeModelToWritableMap(model)) } + } catch (e: Exception) { + loge(TAG, "signRotationRequest() error: ${e.message}") + uiScope { promise.reject("SIGN_ROTATION_ERROR", e.message, e) } + } + } + } + + override fun setScreenSecurityLevel(level: String) { + logd(TAG, "setScreenSecurityLevel: $level") + val secure = level.equals("secure", ignoreCase = true) + uiScope { + val activity = currentActivity + if (activity != null) { + if (secure) { + activity.window.setFlags(WindowManager.LayoutParams.FLAG_SECURE, WindowManager.LayoutParams.FLAG_SECURE) + } else { + activity.window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE) + } + } + } + } + override fun listenTransaction(txid: String) { val transactionState = TransactionState( transactionId = txid, @@ -323,6 +508,7 @@ class NativeFRWBridge(reactContext: ReactApplicationContext) : NativeFRWBridgeSp } override fun closeRN(id: String?) { + logd(TAG, "closeRN() called - id: $id") try { val currentActivity = reactApplicationContext.currentActivity if (currentActivity != null && !currentActivity.isFinishing && !currentActivity.isDestroyed) { diff --git a/app/src/main/java/com/flowfoundation/wallet/reactnative/bridge/NativeFRWBridgePackage.kt b/app/src/main/java/com/flowfoundation/wallet/reactnative/bridge/NativeFRWBridgePackage.kt index 1e07924b1..c84e72bc2 100644 --- a/app/src/main/java/com/flowfoundation/wallet/reactnative/bridge/NativeFRWBridgePackage.kt +++ b/app/src/main/java/com/flowfoundation/wallet/reactnative/bridge/NativeFRWBridgePackage.kt @@ -11,6 +11,7 @@ class NativeFRWBridgePackage : BaseReactPackage() { override fun getModule(name: String, reactContext: ReactApplicationContext): NativeModule? = when (name) { NativeFRWBridge.NAME -> NativeFRWBridge(reactContext) + NativeRequestEventEmitter.NAME -> NativeRequestEventEmitter(reactContext) else -> null } @@ -23,6 +24,14 @@ class NativeFRWBridgePackage : BaseReactPackage() { needsEagerInit = false, isCxxModule = false, isTurboModule = true + ), + NativeRequestEventEmitter.NAME to ReactModuleInfo( + name = NativeRequestEventEmitter.NAME, + className = NativeRequestEventEmitter.NAME, + canOverrideExistingModule = false, + needsEagerInit = false, + isCxxModule = false, + isTurboModule = false ) ) } diff --git a/app/src/main/java/com/flowfoundation/wallet/reactnative/bridge/NativeRequestEmitter.kt b/app/src/main/java/com/flowfoundation/wallet/reactnative/bridge/NativeRequestEmitter.kt new file mode 100644 index 000000000..7a68c0248 --- /dev/null +++ b/app/src/main/java/com/flowfoundation/wallet/reactnative/bridge/NativeRequestEmitter.kt @@ -0,0 +1,28 @@ +package com.flowfoundation.wallet.reactnative.bridge + +import com.facebook.react.bridge.Arguments +import com.facebook.react.modules.core.DeviceEventManagerModule +import com.flowfoundation.wallet.manager.app.ActivityManager +import com.flowfoundation.wallet.utils.logw + +object NativeRequestEmitter { + private const val TAG = "NativeRequestEmitter" + + fun emit(requestId: String, eventName: String, paramsJson: String) { + val reactContext = ActivityManager.getReactContext() + if (reactContext == null) { + logw(TAG, "React context not available, skipping nativeRequest emit") + return + } + + val payload = Arguments.createMap().apply { + putString("requestId", requestId) + putString("eventName", eventName) + putString("paramsJson", paramsJson) + } + + reactContext + .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java) + .emit("nativeRequest", payload) + } +} diff --git a/app/src/main/java/com/flowfoundation/wallet/reactnative/bridge/NativeRequestEventEmitter.kt b/app/src/main/java/com/flowfoundation/wallet/reactnative/bridge/NativeRequestEventEmitter.kt new file mode 100644 index 000000000..f2c00f958 --- /dev/null +++ b/app/src/main/java/com/flowfoundation/wallet/reactnative/bridge/NativeRequestEventEmitter.kt @@ -0,0 +1,14 @@ +package com.flowfoundation.wallet.reactnative.bridge + +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.bridge.ReactContextBaseJavaModule + +class NativeRequestEventEmitter(reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext) { + override fun getName(): String { + return NAME + } + + companion object { + const val NAME = "NativeRequestEventEmitter" + } +} diff --git a/app/src/main/java/com/flowfoundation/wallet/reactnative/bridge/NativeRequestEventName.kt b/app/src/main/java/com/flowfoundation/wallet/reactnative/bridge/NativeRequestEventName.kt new file mode 100644 index 000000000..f9c5edf72 --- /dev/null +++ b/app/src/main/java/com/flowfoundation/wallet/reactnative/bridge/NativeRequestEventName.kt @@ -0,0 +1,5 @@ +package com.flowfoundation.wallet.reactnative.bridge + +object NativeRequestEventName { + const val KEY_ROTATION_CHECK = "keyRotationCheck" +} diff --git a/app/src/main/java/com/flowfoundation/wallet/reactnative/bridge/NativeRequestRegistry.kt b/app/src/main/java/com/flowfoundation/wallet/reactnative/bridge/NativeRequestRegistry.kt new file mode 100644 index 000000000..4908095d8 --- /dev/null +++ b/app/src/main/java/com/flowfoundation/wallet/reactnative/bridge/NativeRequestRegistry.kt @@ -0,0 +1,23 @@ +package com.flowfoundation.wallet.reactnative.bridge + +import java.util.concurrent.ConcurrentHashMap + +object NativeRequestRegistry { + private val callbacks = ConcurrentHashMap Unit>() + + fun register(requestId: String, callback: (NativeRequestResult) -> Unit) { + callbacks[requestId] = callback + } + + fun handle(result: NativeRequestResult) { + val callback = callbacks.remove(result.requestId) + callback?.invoke(result) + } +} + +data class NativeRequestResult( + val requestId: String, + val eventName: String, + val resultJson: String?, + val error: String? +) diff --git a/app/src/main/res/drawable/ic_key_list_index.xml b/app/src/main/res/drawable/ic_key_list_index.xml index ef5554e2c..d89693a54 100644 --- a/app/src/main/res/drawable/ic_key_list_index.xml +++ b/app/src/main/res/drawable/ic_key_list_index.xml @@ -5,6 +5,5 @@ android:viewportHeight="16"> + android:fillColor="@color/text_3"/> diff --git a/app/src/main/res/layout/layout_key_list_item.xml b/app/src/main/res/layout/layout_key_list_item.xml index f1b79b5c2..edd898d90 100644 --- a/app/src/main/res/layout/layout_key_list_item.xml +++ b/app/src/main/res/layout/layout_key_list_item.xml @@ -5,7 +5,7 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginVertical="4dp"> - + @@ -358,4 +361,4 @@ - \ No newline at end of file + diff --git a/gradle.properties b/gradle.properties index ed2818c30..ac9c57c23 100644 --- a/gradle.properties +++ b/gradle.properties @@ -65,7 +65,7 @@ android.r8.dexing.parallel=true #android.experimental.enableArtProfiles=true vCode=330 -vName=r3.0.9 +vName=r3.0.10 # --- GitHub Packages (Flow Wallet Kit) --- # Set credentials locally (or via environment vars) to resolve