From d09b5a62ad5684c5861674a92d8864c89999c77f Mon Sep 17 00:00:00 2001 From: HashEngineering Date: Wed, 21 Feb 2024 06:24:36 -0800 Subject: [PATCH] feat(dashpay): add timeskew support to CoinJoinService and warning dialogs (#1252) * feat: add handling for timeskew --- wallet/res/values/strings-extra.xml | 2 + wallet/res/values/strings.xml | 2 + .../wallet/service/CoinJoinService.kt | 82 +++++++++++++++---- .../ui/coinjoin/CoinJoinLevelFragment.kt | 26 +++++- .../ui/coinjoin/CoinJoinLevelViewModel.kt | 19 +++++ .../ui/dashpay/CreateIdentityService.kt | 2 +- .../schildbach/wallet/ui/main/MainActivity.kt | 18 ++-- .../wallet/ui/main/MainViewModel.kt | 31 ++++--- .../wallet/ui/main/WalletActivityExt.kt | 24 +++--- .../de/schildbach/wallet/util/TimeUtils.kt | 64 +++++++++++++++ 10 files changed, 222 insertions(+), 48 deletions(-) create mode 100644 wallet/src/de/schildbach/wallet/util/TimeUtils.kt diff --git a/wallet/res/values/strings-extra.xml b/wallet/res/values/strings-extra.xml index 293c52a8e4..0c5db8aaa3 100644 --- a/wallet/res/values/strings-extra.xml +++ b/wallet/res/values/strings-extra.xml @@ -697,4 +697,6 @@ Not used Revoked Private / Public Keys (base64) + ahead + behind \ No newline at end of file diff --git a/wallet/res/values/strings.xml b/wallet/res/values/strings.xml index c3adad84d6..b8401fb391 100644 --- a/wallet/res/values/strings.xml +++ b/wallet/res/values/strings.xml @@ -43,6 +43,8 @@ Manage apps Check date & time settings Your device time is off by %d minutes. You probably cannot send or receive Dash due to this problem.\n\nYou should check and if necessary correct your date, time and timezone settings. + Your device time is %s by %d seconds. You cannot use CoinJoin due to this difference.\n\nThe time settings on your device needs to be changed to “Set time automatically” to use CoinJoin. + Your device time is off by more than 5 seconds. You cannot use CoinJoin due to this difference.\n\nThe time settings on your device needs to be changed to “Set time automatically” before using CoinJoin. Send Dash Fetching signature from %s… Fetching signature failed diff --git a/wallet/src/de/schildbach/wallet/service/CoinJoinService.kt b/wallet/src/de/schildbach/wallet/service/CoinJoinService.kt index 8a6f2f6ace..73f8a94711 100644 --- a/wallet/src/de/schildbach/wallet/service/CoinJoinService.kt +++ b/wallet/src/de/schildbach/wallet/service/CoinJoinService.kt @@ -17,9 +17,15 @@ package de.schildbach.wallet.service +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter import com.google.common.base.Stopwatch +import dagger.hilt.android.qualifiers.ApplicationContext import de.schildbach.wallet.data.CoinJoinConfig import de.schildbach.wallet.ui.dashpay.PlatformRepo +import de.schildbach.wallet.util.getTimeSkew import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.asCoroutineDispatcher @@ -50,7 +56,6 @@ import org.bitcoinj.coinjoin.utils.CoinJoinManager import org.bitcoinj.coinjoin.utils.CoinJoinTransactionType import org.bitcoinj.core.AbstractBlockChain import org.bitcoinj.core.Coin -import org.bitcoinj.core.Context import org.bitcoinj.core.ECKey import org.bitcoinj.core.MasternodeAddress import org.bitcoinj.core.Transaction @@ -84,6 +89,7 @@ interface CoinJoinService { suspend fun getMixingState(): MixingStatus fun observeMixingState(): Flow fun observeMixingProgress(): Flow + fun updateTimeSkew(timeSkew: Long) } enum class MixingStatus { @@ -94,8 +100,12 @@ enum class MixingStatus { ERROR // An error stopped the mixing process } +const val MAX_ALLOWED_AHEAD_TIMESKEW = 5000L // 5 seconds +const val MAX_ALLOWED_BEHIND_TIMESKEW = 20000L // 20 seconds + @Singleton class CoinJoinMixingService @Inject constructor( + @ApplicationContext private val context: Context, val walletDataProvider: WalletDataProvider, private val blockchainStateProvider: BlockchainStateProvider, private val config: CoinJoinConfig, @@ -111,7 +121,15 @@ class CoinJoinMixingService @Inject constructor( const val DEFAULT_DENOMINATIONS_HARDCAP = 300 // these are not for production - val FAST_MIXING_DENOMINATIONS_REMOVE = listOf() // Denomination.THOUSANDTH) + val FAST_MIXING_DENOMINATIONS_REMOVE = listOf() // Denomination.THOUSANDTH + + fun isInsideTimeSkewBounds(timeSkew: Long): Boolean { + return if (timeSkew > 0) { + timeSkew < MAX_ALLOWED_AHEAD_TIMESKEW + } else { + (-timeSkew) < MAX_ALLOWED_BEHIND_TIMESKEW + } + } } private val coinJoinManager: CoinJoinManager? @@ -140,12 +158,25 @@ class CoinJoinMixingService @Inject constructor( private var networkStatus: NetworkStatus = NetworkStatus.UNKNOWN private var isSynced = false private var hasAnonymizableBalance: Boolean = false + private var timeSkew: Long = 0L // https://stackoverflow.com/questions/55421710/how-to-suspend-kotlin-coroutine-until-notified private val updateMutex = Mutex(locked = false) private val updateMixingStateMutex = Mutex(locked = false) private var exception: Throwable? = null + private val timeChangeReceiver = object: BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + if (intent?.action == Intent.ACTION_TIME_CHANGED) { + // Time has changed, handle the change here + log.info("Time or Time Zone changed") + coroutineScope.launch { + updateTimeSkewInternal(getTimeSkew()) + } + } + } + } + override fun observeMixingProgress(): Flow = _progressFlow init { @@ -168,7 +199,7 @@ class CoinJoinMixingService @Inject constructor( .onEach { blockChainState -> val isSynced = blockChainState.isSynced() if (isSynced != this.isSynced) { - updateState(config.getMode(), hasAnonymizableBalance, networkStatus, isSynced, blockChain) + updateState(config.getMode(), timeSkew, hasAnonymizableBalance, networkStatus, isSynced, blockChain) } // this will trigger mixing as new blocks are mined and received tx's are confirmed if (isSynced) { @@ -196,9 +227,18 @@ class CoinJoinMixingService @Inject constructor( .launchIn(coroutineScope) } + /** updates timeSkew in #[coroutineScope] */ + override fun updateTimeSkew(timeSkew: Long) { + coroutineScope.launch { + updateTimeSkewInternal(timeSkew) + } + } + + suspend fun updateTimeSkewInternal(timeSkew: Long) { + updateState(config.getMode(), timeSkew, hasAnonymizableBalance, networkStatus, isSynced, blockChain) + } + private suspend fun updateBalance(balance: Coin) { - // leave this ui scope - Context.propagate(walletDataProvider.wallet!!.context) CoinJoinClientOptions.setAmount(balance) log.info("coinjoin: total balance: ${balance.toFriendlyString()}") val walletEx = walletDataProvider.wallet as WalletEx @@ -223,9 +263,14 @@ class CoinJoinMixingService @Inject constructor( else -> false } - log.info("coinjoin: mixing can occur: $hasBalanceLeftToMix = balance: (${anonBalance.isGreaterThan(CoinJoin.getSmallestDenomination())} && canDenominate: $canDenominate) || partially-mixed: $hasPartiallyMixedCoins") + log.info( + "coinjoin: mixing can occur: $hasBalanceLeftToMix = balance: (${anonBalance.isGreaterThan( + CoinJoin.getSmallestDenomination() + )} && canDenominate: $canDenominate) || partially-mixed: $hasPartiallyMixedCoins" + ) updateState( config.getMode(), + getTimeSkew(), hasBalanceLeftToMix, networkStatus, isSynced, @@ -235,6 +280,7 @@ class CoinJoinMixingService @Inject constructor( private suspend fun updateState( mode: CoinJoinMode, + timeSkew: Long, hasAnonymizableBalance: Boolean, networkStatus: NetworkStatus, isSynced: Boolean, @@ -242,19 +288,21 @@ class CoinJoinMixingService @Inject constructor( ) { updateMutex.lock() log.info( - "coinjoin-old-state: ${this.mode}, ${this.hasAnonymizableBalance}, ${this.networkStatus}, synced: ${this.isSynced} ${blockChain != null}" + "coinjoin-old-state: ${this.mode}, ${this.timeSkew}ms, ${this.hasAnonymizableBalance}, ${this.networkStatus}, synced: ${this.isSynced} ${blockChain != null}" ) try { setBlockchain(blockChain) log.info( - "coinjoin-new-state: $mode, $hasAnonymizableBalance, $networkStatus, synced: $isSynced, ${blockChain != null}" + "coinjoin-new-state: $mode, $timeSkew ms, $hasAnonymizableBalance, $networkStatus, synced: $isSynced, ${blockChain != null}" ) + log.info("coinjoin-Current timeskew: ${getTimeSkew()}") this.networkStatus = networkStatus this.hasAnonymizableBalance = hasAnonymizableBalance this.isSynced = isSynced this.mode = mode + this.timeSkew = timeSkew - if (mode == CoinJoinMode.NONE) { + if (mode == CoinJoinMode.NONE || !isInsideTimeSkewBounds(timeSkew)) { updateMixingState(MixingStatus.NOT_STARTED) } else { configureMixing() @@ -302,11 +350,11 @@ class CoinJoinMixingService @Inject constructor( } private suspend fun updateBlockChain(blockChain: AbstractBlockChain?) { - updateState(mode, hasAnonymizableBalance, networkStatus, isSynced, blockChain) + updateState(mode, timeSkew, hasAnonymizableBalance, networkStatus, isSynced, blockChain) } private suspend fun updateNetworkStatus(networkStatus: NetworkStatus) { - updateState(mode, hasAnonymizableBalance, networkStatus, isSynced, blockChain) + updateState(mode, timeSkew, hasAnonymizableBalance, networkStatus, isSynced, blockChain) } private suspend fun updateMode(mode: CoinJoinMode) { @@ -315,7 +363,8 @@ class CoinJoinMixingService @Inject constructor( configureMixing() updateBalance(walletDataProvider.wallet!!.getBalance(Wallet.BalanceType.AVAILABLE)) } - updateState(mode, hasAnonymizableBalance, networkStatus, isSynced, blockChain) + val currentTimeSkew = getTimeSkew() + updateState(mode, currentTimeSkew, hasAnonymizableBalance, networkStatus, isSynced, blockChain) } private var mixingProgressTracker: MixingProgressTracker = object : MixingProgressTracker() { @@ -475,7 +524,10 @@ class CoinJoinMixingService @Inject constructor( } private suspend fun startMixing(): Boolean { - Context.propagate(walletDataProvider.wallet!!.context) + val filter = IntentFilter().apply { + addAction(Intent.ACTION_TIME_CHANGED) + } + context.registerReceiver(timeChangeReceiver, filter) clientManager.setBlockChain(blockChain) return if (!clientManager.startMixing()) { log.info("Mixing has been started already.") @@ -483,7 +535,8 @@ class CoinJoinMixingService @Inject constructor( } else { // run this on a different thread? val asyncStart = coroutineScope.async(Dispatchers.IO) { - Context.propagate(walletDataProvider.wallet!!.context) + // though coroutineScope is on a Context propogated thread, we still need this + org.bitcoinj.core.Context.propagate(walletDataProvider.wallet!!.context) coinJoinManager?.initMasternodeGroup(blockChain) clientManager.doAutomaticDenominating() } @@ -511,6 +564,7 @@ class CoinJoinMixingService @Inject constructor( sessionCompleteListeners.forEach { coinJoinManager?.removeSessionCompleteListener(it) } clientManager.stopMixing() coinJoinManager?.stop() + context.unregisterReceiver(timeChangeReceiver) } private fun setBlockchain(blockChain: AbstractBlockChain?) { diff --git a/wallet/src/de/schildbach/wallet/ui/coinjoin/CoinJoinLevelFragment.kt b/wallet/src/de/schildbach/wallet/ui/coinjoin/CoinJoinLevelFragment.kt index f1a88adbd9..e4b4e51e44 100644 --- a/wallet/src/de/schildbach/wallet/ui/coinjoin/CoinJoinLevelFragment.kt +++ b/wallet/src/de/schildbach/wallet/ui/coinjoin/CoinJoinLevelFragment.kt @@ -17,7 +17,9 @@ package de.schildbach.wallet.ui.coinjoin +import android.content.Intent import android.os.Bundle +import android.provider.Settings import android.view.View import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels @@ -81,7 +83,7 @@ class CoinJoinLevelFragment : Fragment(R.layout.fragment_coinjoin_level) { } } - private fun showConnectionWaringDialog(mode: CoinJoinMode) { + private fun showConnectionWarningDialog(mode: CoinJoinMode) { AdaptiveDialog.create( R.drawable.ic_warning, getString( @@ -123,9 +125,27 @@ class CoinJoinLevelFragment : Fragment(R.layout.fragment_coinjoin_level) { } } } else if (viewModel.isWifiConnected()) { - setMode(mode) + val settingsIntent = Intent(Settings.ACTION_DATE_SETTINGS) + val hasSettings = requireActivity().packageManager.resolveActivity(settingsIntent, 0) != null + lifecycleScope.launch { + if (viewModel.isTimeSkewedForCoinJoin()) { + AdaptiveDialog.create( + R.drawable.ic_coinjoin, + getString(R.string.coinjoin), + getString(R.string.settings_coinjoin_timeskew_dialog_msg), + getString(R.string.cancel), + if (hasSettings) getString(R.string.button_settings) else null + ).show(requireActivity()) { openSettings -> + if (openSettings == true && hasSettings) { + startActivity(settingsIntent) + } + } + } else { + setMode(mode) + } + } } else { - showConnectionWaringDialog(mode) + showConnectionWarningDialog(mode) } } diff --git a/wallet/src/de/schildbach/wallet/ui/coinjoin/CoinJoinLevelViewModel.kt b/wallet/src/de/schildbach/wallet/ui/coinjoin/CoinJoinLevelViewModel.kt index 035dd91443..004f4e59b1 100644 --- a/wallet/src/de/schildbach/wallet/ui/coinjoin/CoinJoinLevelViewModel.kt +++ b/wallet/src/de/schildbach/wallet/ui/coinjoin/CoinJoinLevelViewModel.kt @@ -22,6 +22,10 @@ import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import de.schildbach.wallet.data.CoinJoinConfig import de.schildbach.wallet.service.CoinJoinMode +import de.schildbach.wallet.service.CoinJoinService +import de.schildbach.wallet.service.MAX_ALLOWED_AHEAD_TIMESKEW +import de.schildbach.wallet.service.MAX_ALLOWED_BEHIND_TIMESKEW +import de.schildbach.wallet.util.getTimeSkew import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.filterNotNull @@ -35,6 +39,7 @@ import javax.inject.Inject open class CoinJoinLevelViewModel @Inject constructor( private val analytics: AnalyticsService, private val coinJoinConfig: CoinJoinConfig, + private val coinJoinService: CoinJoinService, private var networkState: NetworkStateInt ) : ViewModel() { @@ -64,4 +69,18 @@ open class CoinJoinLevelViewModel @Inject constructor( fun logEvent(event: String) { analytics.logEvent(event, mapOf()) } + + suspend fun isTimeSkewedForCoinJoin(): Boolean { + return try { + val timeSkew = getTimeSkew() + coinJoinService.updateTimeSkew(timeSkew) + if (timeSkew > 0) { + timeSkew > MAX_ALLOWED_AHEAD_TIMESKEW + } else { + -timeSkew > MAX_ALLOWED_BEHIND_TIMESKEW + } + } catch (e: Exception) { + false + } + } } diff --git a/wallet/src/de/schildbach/wallet/ui/dashpay/CreateIdentityService.kt b/wallet/src/de/schildbach/wallet/ui/dashpay/CreateIdentityService.kt index 42b3f5d6a6..21cec3e4f7 100644 --- a/wallet/src/de/schildbach/wallet/ui/dashpay/CreateIdentityService.kt +++ b/wallet/src/de/schildbach/wallet/ui/dashpay/CreateIdentityService.kt @@ -130,7 +130,7 @@ class CreateIdentityService : LifecycleService() { } private val walletApplication by lazy { application as WalletApplication } - @Inject lateinit var platformRepo: PlatformRepo// by lazy { PlatformRepo.getInstance() } + @Inject lateinit var platformRepo: PlatformRepo @Inject lateinit var platformSyncService: PlatformSyncService @Inject lateinit var userAlertDao: UserAlertDao @Inject lateinit var blockchainIdentityDataDao: BlockchainIdentityConfig diff --git a/wallet/src/de/schildbach/wallet/ui/main/MainActivity.kt b/wallet/src/de/schildbach/wallet/ui/main/MainActivity.kt index ce524f3c57..50291f725a 100644 --- a/wallet/src/de/schildbach/wallet/ui/main/MainActivity.kt +++ b/wallet/src/de/schildbach/wallet/ui/main/MainActivity.kt @@ -18,6 +18,7 @@ package de.schildbach.wallet.ui.main import android.Manifest +import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.content.IntentFilter @@ -50,6 +51,7 @@ import de.schildbach.wallet.ui.dashpay.* import de.schildbach.wallet.ui.invite.AcceptInviteActivity import de.schildbach.wallet.ui.invite.InviteHandler import de.schildbach.wallet.ui.invite.InviteSendContactRequestDialog +import de.schildbach.wallet.ui.main.WalletActivityExt.checkLowStorageAlert import de.schildbach.wallet.ui.main.WalletActivityExt.checkTimeSkew import de.schildbach.wallet.ui.main.WalletActivityExt.handleFirebaseAction import de.schildbach.wallet.ui.main.WalletActivityExt.requestDisableBatteryOptimisation @@ -109,7 +111,7 @@ class MainActivity : AbstractBindServiceActivity(), ActivityCompat.OnRequestPerm val requestPermissionLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()) { _ -> requestDisableBatteryOptimisation() - }; + } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -129,9 +131,9 @@ class MainActivity : AbstractBindServiceActivity(), ActivityCompat.OnRequestPerm handleIntent(intent) - //Prevent showing dialog twice or more when activity is recreated (e.g: rotating device, etc) + // Prevent showing dialog twice or more when activity is recreated (e.g: rotating device, etc) if (savedInstanceState == null) { - //Add BIP44 support and PIN if missing + // Add BIP44 support and PIN if missing upgradeWalletKeyChains(Constants.BIP44_PATH, false) } @@ -143,6 +145,9 @@ class MainActivity : AbstractBindServiceActivity(), ActivityCompat.OnRequestPerm currencies.component1()!!, currencies.component2()!! ) } + val timeChangedFilter = IntentFilter().apply { + addAction(Intent.ACTION_TIME_CHANGED) + } } override fun onStart() { @@ -532,13 +537,6 @@ class MainActivity : AbstractBindServiceActivity(), ActivityCompat.OnRequestPerm } } - private fun checkLowStorageAlert() { - val stickyIntent = registerReceiver(null, IntentFilter(Intent.ACTION_DEVICE_STORAGE_LOW)) - if (stickyIntent != null) { - showLowStorageAlertDialog() - } - } - override fun onOptionsItemSelected(item: MenuItem): Boolean { when (item.itemId) { android.R.id.home -> { diff --git a/wallet/src/de/schildbach/wallet/ui/main/MainViewModel.kt b/wallet/src/de/schildbach/wallet/ui/main/MainViewModel.kt index 2d9f5e3922..5ea27f5ae8 100644 --- a/wallet/src/de/schildbach/wallet/ui/main/MainViewModel.kt +++ b/wallet/src/de/schildbach/wallet/ui/main/MainViewModel.kt @@ -45,6 +45,8 @@ import de.schildbach.wallet.livedata.Status import de.schildbach.wallet.security.BiometricHelper import de.schildbach.wallet.service.CoinJoinMode import de.schildbach.wallet.service.CoinJoinService +import de.schildbach.wallet.service.MAX_ALLOWED_AHEAD_TIMESKEW +import de.schildbach.wallet.service.MAX_ALLOWED_BEHIND_TIMESKEW import de.schildbach.wallet.service.MixingStatus import de.schildbach.wallet.service.platform.PlatformService import de.schildbach.wallet.service.platform.PlatformSyncService @@ -56,6 +58,7 @@ import de.schildbach.wallet.ui.dashpay.PlatformRepo import de.schildbach.wallet.ui.dashpay.utils.DashPayConfig import de.schildbach.wallet.ui.dashpay.work.SendContactRequestOperation import de.schildbach.wallet.ui.transactions.TransactionRowView +import de.schildbach.wallet.util.getTimeSkew import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -96,17 +99,15 @@ import org.dash.wallet.common.services.analytics.AnalyticsTimer import org.dash.wallet.common.transactions.TransactionUtils.isEntirelySelf import org.dash.wallet.common.transactions.TransactionWrapper import org.dash.wallet.common.transactions.TransactionWrapperComparator -import org.dash.wallet.common.util.Constants.HTTP_CLIENT -import org.dash.wallet.common.util.head import org.dash.wallet.common.util.toBigDecimal import org.dash.wallet.integrations.crowdnode.transactions.FullCrowdNodeSignUpTxSet import org.slf4j.LoggerFactory +import kotlin.math.abs import java.text.DecimalFormat import java.util.Currency import java.util.Locale import javax.inject.Inject import kotlin.collections.set -import kotlin.math.abs @OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) @HiltViewModel @@ -136,6 +137,8 @@ class MainViewModel @Inject constructor( companion object { private const val THROTTLE_DURATION = 500L private const val DIRECTION_KEY = "tx_direction" + private const val TIME_SKEW_TOLERANCE = 3600000L // seconds (1 hour) + private val log = LoggerFactory.getLogger(MainViewModel::class.java) } @@ -379,16 +382,24 @@ class MainViewModel @Inject constructor( } } - suspend fun getDeviceTimeSkew(): Long { + suspend fun getCoinJoinMode(): CoinJoinMode { + return coinJoinConfig.getMode() + } + + suspend fun getDeviceTimeSkew(): Pair { return try { - val systemTimeMillis = System.currentTimeMillis() - val result = HTTP_CLIENT.head("https://www.dash.org/") - val networkTime = result.headers.getDate("date")?.time - requireNotNull(networkTime) - abs(systemTimeMillis - networkTime) + val timeSkew = getTimeSkew() + val maxAllowedTimeSkew: Long = if (coinJoinConfig.getMode() == CoinJoinMode.NONE) { + TIME_SKEW_TOLERANCE + } else { + if (timeSkew > 0) MAX_ALLOWED_AHEAD_TIMESKEW else MAX_ALLOWED_BEHIND_TIMESKEW + } + coinJoinService.updateTimeSkew(timeSkew) + log.info("timeskew: {} ms", timeSkew) + return Pair(abs(timeSkew) > maxAllowedTimeSkew, timeSkew) } catch (ex: Exception) { // Ignore errors - 0L + Pair(false, 0) } } diff --git a/wallet/src/de/schildbach/wallet/ui/main/WalletActivityExt.kt b/wallet/src/de/schildbach/wallet/ui/main/WalletActivityExt.kt index b3277a840a..28e1a68115 100644 --- a/wallet/src/de/schildbach/wallet/ui/main/WalletActivityExt.kt +++ b/wallet/src/de/schildbach/wallet/ui/main/WalletActivityExt.kt @@ -28,8 +28,6 @@ import android.os.PowerManager import android.os.storage.StorageManager import android.provider.Settings import android.view.MenuItem -import androidx.activity.result.ActivityResultLauncher -import androidx.activity.result.contract.ActivityResultContracts import androidx.core.content.ContextCompat import androidx.core.content.getSystemService import androidx.core.view.isVisible @@ -40,15 +38,16 @@ import androidx.navigation.ui.NavigationUI.onNavDestinationSelected import androidx.navigation.ui.NavigationUI.setupWithNavController import com.google.android.material.bottomnavigation.BottomNavigationView import de.schildbach.wallet.WalletBalanceWidgetProvider +import de.schildbach.wallet.service.CoinJoinMode import de.schildbach.wallet_test.R import kotlinx.coroutines.launch import org.dash.wallet.common.services.analytics.AnalyticsConstants import org.dash.wallet.common.ui.dialogs.AdaptiveDialog import org.dash.wallet.common.ui.dialogs.AdaptiveDialog.Companion.create import org.dash.wallet.common.util.openCustomTab +import kotlin.math.abs object WalletActivityExt { - private const val TIME_SKEW_TOLERANCE = 60 // minutes private const val STORAGE_TOLERANCE = 500 // MB private var timeSkewDialogShown = false private var lowStorageDialogShown = false @@ -98,12 +97,12 @@ object WalletActivityExt { fun MainActivity.checkTimeSkew(viewModel: MainViewModel) { lifecycleScope.launch { - val timeSkew = viewModel.getDeviceTimeSkew() - val inMinutes = timeSkew / 1000 / 60 - - if (inMinutes > TIME_SKEW_TOLERANCE && !timeSkewDialogShown) { + val (isTimeSkewed, timeSkew) = viewModel.getDeviceTimeSkew() + val coinJoinOn = viewModel.getCoinJoinMode() != CoinJoinMode.NONE + if (isTimeSkewed && (!timeSkewDialogShown || coinJoinOn)) { timeSkewDialogShown = true - showTimeSkewAlertDialog(inMinutes) + // add 1 to round up so 2.2 seconds appears as 3 + showTimeSkewAlertDialog((if (timeSkew > 0) 1 else -1) + timeSkew / 1000L, coinJoinOn) } } } @@ -173,14 +172,19 @@ object WalletActivityExt { } } - private fun MainActivity.showTimeSkewAlertDialog(diffMinutes: Long) { + private fun MainActivity.showTimeSkewAlertDialog(diffSeconds: Long, coinJoin: Boolean) { val settingsIntent = Intent(Settings.ACTION_DATE_SETTINGS) val hasSettings = packageManager.resolveActivity(settingsIntent, 0) != null AdaptiveDialog.create( R.drawable.ic_warning, getString(R.string.wallet_timeskew_dialog_title), - getString(R.string.wallet_timeskew_dialog_msg, diffMinutes), + if (coinJoin) { + val position = getString(if (diffSeconds > 0) R.string.timeskew_ahead else R.string.timeskew_behind) + getString(R.string.wallet_coinjoin_timeskew_dialog_msg, position, abs(diffSeconds)) + } else { + getString(R.string.wallet_timeskew_dialog_msg, diffSeconds / 1000) + }, getString(R.string.button_dismiss), if (hasSettings) getString(R.string.button_settings) else null ).show(this) { openSettings -> diff --git a/wallet/src/de/schildbach/wallet/util/TimeUtils.kt b/wallet/src/de/schildbach/wallet/util/TimeUtils.kt new file mode 100644 index 0000000000..d9aca9b3d3 --- /dev/null +++ b/wallet/src/de/schildbach/wallet/util/TimeUtils.kt @@ -0,0 +1,64 @@ +package de.schildbach.wallet.util + +import org.dash.wallet.common.util.Constants +import org.dash.wallet.common.util.head +import java.net.DatagramPacket +import java.net.DatagramSocket +import java.net.InetAddress +import java.net.SocketTimeoutException +import kotlin.math.abs + +private fun queryNtpTime(server: String): Long? { + try { + val address = InetAddress.getByName(server) + val message = ByteArray(48) + message[0] = 0b00100011 // NTP mode client (3) and version (4) + + val socket = DatagramSocket().apply { + soTimeout = 3000 // Set timeout to 3000ms + } + + val request = DatagramPacket(message, message.size, address, 123) + socket.send(request) // Send request + + // Receive response + val response = DatagramPacket(message, message.size) + socket.receive(response) + + // Timestamp starts at byte 40 of the received packet and is four bytes, + // or two words, long. First byte is the high-order byte of the integer; + // the last byte is the low-order byte. The high word is the seconds field, + // and the low word is the fractional field. + val seconds = message[40].toLong() and 0xff shl 24 or + (message[41].toLong() and 0xff shl 16) or + (message[42].toLong() and 0xff shl 8) or + (message[43].toLong() and 0xff) + + // Convert seconds to milliseconds and adjust from 1900 to epoch (1970) + return (seconds - 2208988800L) * 1000 + } catch (e: Exception) { + e.printStackTrace() + } + return null +} + +suspend fun getTimeSkew(): Long { + var networkTime: Long? = null + try { + networkTime = queryNtpTime("pool.ntp.org") + } catch (e: SocketTimeoutException) { + // swallow, the next block will use alternate method + } + if (networkTime == null) { + var result = Constants.HTTP_CLIENT.head("https://www.dash.org/") + + networkTime = result.headers.getDate("date")?.time + if (networkTime == null) { + result = Constants.HTTP_CLIENT.head("https://insight.dash.org/insight") + networkTime = result.headers.getDate("date")?.time + } + requireNotNull(networkTime) + } + val systemTimeMillis = System.currentTimeMillis() + return systemTimeMillis - networkTime +}