Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions app/src/main/java/one/mixin/android/Constants.kt
Original file line number Diff line number Diff line change
Expand Up @@ -413,6 +413,7 @@ object Constants {
const val INTERVAL_30_MINS: Long = (1000 * 60 * 30).toLong()
const val INTERVAL_1_MIN: Long = (1000 * 60).toLong()
const val INTERVAL_7_DAYS: Long = INTERVAL_24_HOURS * 7
const val INTERVAL_60_DAYS: Long = INTERVAL_24_HOURS * 60
const val DELAY_SECOND = 60
const val ALLOW_INTERVAL: Long = (5 * 60 * 1000).toLong()

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,5 +33,6 @@ enum class VerificationPurpose {
SESSION,
PHONE,
DEACTIVATED,
ANONYMOUS_SESSION
ANONYMOUS_SESSION,
NONE
}
21 changes: 16 additions & 5 deletions app/src/main/java/one/mixin/android/ui/common/PinCodeFragment.kt
Original file line number Diff line number Diff line change
Expand Up @@ -84,13 +84,24 @@ abstract class PinCodeFragment(
}

protected fun handleFailure(error: ResponseError) {
hideLoading()
pinVerificationView.error()
pinVerificationTipTv.visibility = View.VISIBLE
pinVerificationTipTv.text = getString(R.string.The_code_is_incorrect)
if (error.code == ErrorHandler.PHONE_VERIFICATION_CODE_INVALID ||
error.code == ErrorHandler.PHONE_VERIFICATION_CODE_EXPIRED
) {
verificationNextFab.visibility = View.INVISIBLE

when (error.code) {
ErrorHandler.PHONE_VERIFICATION_CODE_INVALID -> {
pinVerificationTipTv.text = getString(R.string.error_phone_verification_code_invalid)
verificationNextFab.visibility = View.INVISIBLE
return
}
ErrorHandler.PHONE_VERIFICATION_CODE_EXPIRED -> {
pinVerificationTipTv.text = getString(R.string.error_phone_verification_code_expired)
verificationNextFab.visibility = View.INVISIBLE
return
}
else -> {
pinVerificationTipTv.text = getString(R.string.The_code_is_incorrect)
}
}
ErrorHandler.handleMixinError(error.code, error.description)
}
Expand Down
17 changes: 15 additions & 2 deletions app/src/main/java/one/mixin/android/ui/common/VerifyFragment.kt
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import one.mixin.android.extension.updatePinCheck
import one.mixin.android.extension.viewDestroyed
import one.mixin.android.extension.withArgs
import one.mixin.android.repository.AccountRepository
import one.mixin.android.session.Session
import one.mixin.android.tip.exception.TipNetworkException
import one.mixin.android.ui.landing.LandingActivity
import one.mixin.android.ui.landing.MobileFragment
Expand All @@ -41,15 +42,26 @@ class VerifyFragment : BaseFragment(R.layout.fragment_verify_pin), PinView.OnPin
const val FROM_DELETE_ACCOUNT = 2

const val ARGS_FROM = "args_from"
private const val ARGS_PHONE_NUMBER = "args_phone_number"

fun newInstance(from: Int) =
fun newInstance(
from: Int,
phoneNumber: String? = null,
) =
VerifyFragment().withArgs {
putInt(ARGS_FROM, from)
if (!phoneNumber.isNullOrBlank()) {
putString(ARGS_PHONE_NUMBER, phoneNumber)
}
}
}

private val from by lazy { requireArguments().getInt(ARGS_FROM) }

private val phoneNumber: String? by lazy {
requireArguments().getString(ARGS_PHONE_NUMBER)
}

@Inject
lateinit var accountRepository: AccountRepository

Expand Down Expand Up @@ -121,7 +133,8 @@ class VerifyFragment : BaseFragment(R.layout.fragment_verify_pin), PinView.OnPin
}
when (from) {
FROM_PHONE -> {
LandingActivity.show(requireContext(), pinCode)
val fragment = MobileFragment.newInstance(pin = pinCode, from = if (Session.hasPhone()) MobileFragment.FROM_VERIFY_MOBILE_REMINDER else MobileFragment.FROM_CHANGE_PHONE_ACCOUNT, phoneNumber = phoneNumber)
activity?.addFragment(this@VerifyFragment, fragment, MobileFragment.TAG)
}
FROM_EMERGENCY -> {
val f = FriendsNoBotFragment.newInstance(pinCode)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,13 +69,19 @@ import one.mixin.android.session.Session
import one.mixin.android.ui.common.EditDialog
import one.mixin.android.ui.common.LinkFragment
import one.mixin.android.ui.common.NavigationController
import one.mixin.android.ui.common.VerifyFragment
import one.mixin.android.ui.common.editDialog
import one.mixin.android.ui.common.recyclerview.NormalHolder
import one.mixin.android.ui.common.recyclerview.PagedHeaderAdapter
import one.mixin.android.ui.conversation.ConversationActivity
import one.mixin.android.ui.home.circle.CirclesFragment
import one.mixin.android.ui.home.reminder.ReminderBottomSheetDialogFragment
import one.mixin.android.ui.home.reminder.VerifyMobileReminderBottomSheetDialogFragment
import one.mixin.android.ui.landing.MobileFragment
import one.mixin.android.ui.landing.VerificationFragment
import one.mixin.android.ui.search.SearchFragment
import one.mixin.android.ui.setting.AddPhoneBeforeFragment
import one.mixin.android.ui.setting.AddPhoneFragment
import one.mixin.android.util.ErrorHandler.Companion.errorHandler
import one.mixin.android.util.GsonHelper
import one.mixin.android.util.analytics.AnalyticsTracker
Expand Down Expand Up @@ -751,7 +757,20 @@ class ConversationListFragment : LinkFragment() {
}
lifecycleScope.launch {
val totalUsd = conversationListViewModel.findTotalUSDBalance()
if (isAdded && !parentFragmentManager.isStateSaved) {
if (isAdded && parentFragmentManager.fragments.any {
it.tag in listOf(AddPhoneBeforeFragment.TAG, VerifyFragment.TAG, VerificationFragment.TAG, MobileFragment.TAG)
}.not()) {
if (parentFragmentManager.findFragmentByTag(VerifyMobileReminderBottomSheetDialogFragment.TAG) != null) return@launch
if (VerifyMobileReminderBottomSheetDialogFragment.shouldShow(requireContext())) {
try {
VerifyMobileReminderBottomSheetDialogFragment.showSafely(
parentFragmentManager
)
} catch (e: IllegalStateException) {
// Fragment state already saved, skip showing dialog
}
return@launch
}
ReminderBottomSheetDialogFragment.getType(requireContext(), totalUsd)
.let { type ->
val existingDialog = parentFragmentManager.findFragmentByTag(ReminderBottomSheetDialogFragment.TAG) as? ReminderBottomSheetDialogFragment
Expand All @@ -771,20 +790,6 @@ class ConversationListFragment : LinkFragment() {
}
}


private fun openCamera(scan: Boolean) {
RxPermissions(requireActivity())
.request(Manifest.permission.CAMERA)
.autoDispose(stopScope)
.subscribe { granted ->
if (granted) {
(requireActivity() as? MainActivity)?.showCapture(scan)
} else {
context?.openPermissionSetting()
}
}
}

class MessageAdapter : PagedHeaderAdapter<ConversationItem>(ConversationItem.DIFF_CALLBACK) {
override fun getNormalViewHolder(
context: Context,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,10 @@ fun ReminderPage(@DrawableRes contentImage: Int, @StringRes title: Int, @StringR
)
Spacer(modifier = Modifier.height(10.dp))
Text(
stringResource(content), color = MixinAppTheme.colors.textAssist
text = stringResource(content),
color = MixinAppTheme.colors.textAssist,
modifier = Modifier.fillMaxWidth(),
textAlign = androidx.compose.ui.text.style.TextAlign.Center
)
Spacer(modifier = Modifier.height(50.dp))
Button(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
package one.mixin.android.ui.home.reminder
Copy link

Copilot AI Jan 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The file name uses snake_case (verify_mobile_reminder_bottom_sheet_dialog_fragment.kt) instead of PascalCase which is inconsistent with Kotlin naming conventions. The file should be named VerifyMobileReminderBottomSheetDialogFragment.kt to match the class name inside and follow standard Kotlin file naming conventions.

Copilot uses AI. Check for mistakes.

import android.annotation.SuppressLint
import android.app.Dialog
import android.content.Context
import android.view.Gravity
import android.view.View
import android.view.ViewGroup
import androidx.compose.runtime.Composable
import dagger.hilt.android.AndroidEntryPoint
import one.mixin.android.Constants
import one.mixin.android.R
import one.mixin.android.compose.theme.MixinAppTheme
import one.mixin.android.extension.booleanFromAttribute
import one.mixin.android.extension.defaultSharedPreferences
import one.mixin.android.extension.getSafeAreaInsetsTop
import one.mixin.android.extension.isNightMode
import one.mixin.android.extension.navTo
import one.mixin.android.extension.putLong
import one.mixin.android.extension.screenHeight
import one.mixin.android.session.Session
import one.mixin.android.ui.common.MixinComposeBottomSheetDialogFragment
import one.mixin.android.ui.common.VerifyFragment
import one.mixin.android.ui.setting.AddPhoneBeforeFragment
import one.mixin.android.util.SystemUIManager
import one.mixin.android.vo.Account
import java.time.Instant

@AndroidEntryPoint
class VerifyMobileReminderBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragment() {
Comment on lines +1 to +30
Copy link

Copilot AI Jan 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The filename uses snake_case (verify_mobile_reminder_bottom_sheet_dialog_fragment.kt) while the class name uses PascalCase (VerifyMobileReminderBottomSheetDialogFragment). Kotlin convention is to use PascalCase for filenames that match the class name. The file should be renamed to VerifyMobileReminderBottomSheetDialogFragment.kt to follow standard Kotlin naming conventions.

Copilot uses AI. Check for mistakes.

companion object {
const val TAG: String = "VerifyMobileReminderBottomSheetDialogFragment"
private const val PREF_VERIFY_MOBILE_REMINDER_SNOOZE: String = "pref_verify_mobile_reminder_snooze"
private const val ARGS_SUBTITLE_RES_ID: String = "args_subtitle_res_id"
private const val ARGS_ENABLE_SNOOZE: String = "args_enable_snooze"

@Volatile
private var isShowing = false

fun newInstance(subtitleResId: Int = R.string.Verify_Mobile_Number_Desc): VerifyMobileReminderBottomSheetDialogFragment? {
if (isShowing) return null

return VerifyMobileReminderBottomSheetDialogFragment().apply {
arguments = android.os.Bundle().apply {
putInt(ARGS_SUBTITLE_RES_ID, subtitleResId)
putBoolean(ARGS_ENABLE_SNOOZE, true)
}
}
}

fun newInstance(
subtitleResId: Int = R.string.Verify_Mobile_Number_Desc,
enableSnooze: Boolean,
): VerifyMobileReminderBottomSheetDialogFragment? {
if (isShowing) return null

return VerifyMobileReminderBottomSheetDialogFragment().apply {
arguments = android.os.Bundle().apply {
putInt(ARGS_SUBTITLE_RES_ID, subtitleResId)
putBoolean(ARGS_ENABLE_SNOOZE, enableSnooze)
}
}
}

fun showSafely(
fragmentManager: androidx.fragment.app.FragmentManager,
subtitleResId: Int = R.string.Verify_Mobile_Number_Desc,
enableSnooze: Boolean = true
): Boolean {
if (isShowing) return false

val fragment = VerifyMobileReminderBottomSheetDialogFragment().apply {
arguments = android.os.Bundle().apply {
putInt(ARGS_SUBTITLE_RES_ID, subtitleResId)
putBoolean(ARGS_ENABLE_SNOOZE, enableSnooze)
}
}

try {
fragment.showNow(fragmentManager, TAG)
return true
} catch (e: Exception) {
isShowing = false
return false
}
}

fun shouldShow(context: Context): Boolean {
val account = Session.getAccount() ?: return false
if (Session.hasPhone().not()) return false
val lastSnoozeTimeMillis: Long = context.defaultSharedPreferences.getLong(PREF_VERIFY_MOBILE_REMINDER_SNOOZE, 0)
if (System.currentTimeMillis() - lastSnoozeTimeMillis < Constants.INTERVAL_7_DAYS) return false
return shouldShowWithoutSnooze(account)
}

fun shouldShowForBuy(context: Context): Boolean {
val account = Session.getAccount() ?: return false
if (account.phone.isBlank()) return true
return shouldShowWithoutSnooze(account)
}

private fun shouldShowWithoutSnooze(account: Account): Boolean {
if (account.phone.isBlank()) return true
val phoneVerifiedAt: String? = account.phoneVerifiedAt
if (phoneVerifiedAt.isNullOrBlank()) return true
val verifiedAtMillis: Long = runCatching {
Instant.parse(phoneVerifiedAt).toEpochMilli()
}.getOrNull() ?: return true
return System.currentTimeMillis() - verifiedAtMillis > Constants.INTERVAL_60_DAYS
}
Comment on lines +89 to +111
Copy link

Copilot AI Jan 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The phone verification date check logic is duplicated in this file (lines 40-43) and in PrivacyWalletFragment (lines 141-144) and ClassicWalletFragment (lines 177-180). This same logic for checking if the phone was verified more than 60 days ago appears in multiple places. Consider extracting this into a shared utility function or extension function on Session to avoid code duplication and improve maintainability.

Suggested change
fun shouldShow(context: Context): Boolean {
if (!Session.hasPhone()) return false
val account = Session.getAccount() ?: return false
val lastSnoozeTimeMillis: Long = context.defaultSharedPreferences.getLong(PREF_VERIFY_MOBILE_REMINDER_SNOOZE, 0)
if (System.currentTimeMillis() - lastSnoozeTimeMillis < Constants.INTERVAL_7_DAYS) return false
val phoneVerifiedAt: String? = account.phoneVerifiedAt
if (phoneVerifiedAt.isNullOrBlank()) return true
val verifiedAtMillis: Long = runCatching {
Instant.parse(phoneVerifiedAt).toEpochMilli()
}.getOrNull() ?: return true
return System.currentTimeMillis() - verifiedAtMillis > Constants.INTERVAL_60_DAYS
}
private fun isPhoneVerifiedMoreThanSixtyDaysAgo(phoneVerifiedAt: String?): Boolean {
if (phoneVerifiedAt.isNullOrBlank()) return true
val verifiedAtMillis: Long = runCatching {
Instant.parse(phoneVerifiedAt).toEpochMilli()
}.getOrNull() ?: return true
return System.currentTimeMillis() - verifiedAtMillis > Constants.INTERVAL_60_DAYS
}
fun shouldShow(context: Context): Boolean {
if (!Session.hasPhone()) return false
val account = Session.getAccount() ?: return false
val lastSnoozeTimeMillis: Long = context.defaultSharedPreferences.getLong(PREF_VERIFY_MOBILE_REMINDER_SNOOZE, 0)
if (System.currentTimeMillis() - lastSnoozeTimeMillis < Constants.INTERVAL_7_DAYS) return false
return isPhoneVerifiedMoreThanSixtyDaysAgo(account.phoneVerifiedAt)
}

Copilot uses AI. Check for mistakes.
}

override fun getTheme() = R.style.AppTheme_Dialog

@SuppressLint("RestrictedApi")
override fun setupDialog(
dialog: Dialog,
style: Int,
) {
super.setupDialog(dialog, R.style.MixinBottomSheet)
dialog.window?.let { window ->
SystemUIManager.lightUI(window, requireContext().isNightMode())
}
dialog.window?.setGravity(Gravity.BOTTOM)
dialog.window?.setLayout(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT,
)
}

override fun onStart() {
super.onStart()
isShowing = true
dialog?.window?.let { window ->
SystemUIManager.lightUI(
window,
!requireContext().booleanFromAttribute(R.attr.flag_night),
)
}
}

override fun onDestroy() {
super.onDestroy()
isShowing = false
}

override fun dismiss() {
isShowing = false
super.dismiss()
}

override fun dismissAllowingStateLoss() {
isShowing = false
super.dismissAllowingStateLoss()
}

@Composable
override fun ComposeContent() {
val subtitleResId: Int = arguments?.getInt(ARGS_SUBTITLE_RES_ID, R.string.Verify_Mobile_Number_Desc)
?: R.string.Verify_Mobile_Number_Desc
val enableSnooze: Boolean = arguments?.getBoolean(ARGS_ENABLE_SNOOZE, true) ?: true
val phoneNumber: String? = if (Session.hasPhone()) Session.getAccount()?.phone else null
MixinAppTheme {
ReminderPage(
R.drawable.bg_reminder_verify_mobile,
R.string.Verify_Mobile_Number,
subtitleResId,
R.string.Verify_Now,
action = {
dismissAllowingStateLoss()
if (phoneNumber.isNullOrBlank()) {
navTo(AddPhoneBeforeFragment.newInstance(), AddPhoneBeforeFragment.TAG)
} else {
navTo(VerifyFragment.newInstance(VerifyFragment.FROM_PHONE, phoneNumber), VerifyFragment.TAG)
}
},
dismiss = {
if (enableSnooze) {
requireContext().defaultSharedPreferences.putLong(
PREF_VERIFY_MOBILE_REMINDER_SNOOZE,
System.currentTimeMillis(),
)
}
dismissAllowingStateLoss()
},
)
}
}

override fun getBottomSheetHeight(view: View): Int {
return requireContext().screenHeight() - view.getSafeAreaInsetsTop()
}

override fun showError(error: String) {
}
}
Loading
Loading