Skip to content
Draft
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 .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -17,5 +17,6 @@ CLAUDE.md
agent.md
.claude/
.codex/
.codegraph/
.github/copilot-instructions.md
.vscode/
6 changes: 4 additions & 2 deletions app/src/main/java/one/mixin/android/Constants.kt
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ object Constants {
const val WS_URL = "wss://blaze.mixin.one"
const val Mixin_URL = "https://mixin-api.zeromesh.net/"
const val Mixin_WS_URL = "wss://mixin-blaze.zeromesh.net"
const val CASH_URL = "https://api.cash.mixin.one"
const val CASH_HOME_URL = "https://cash.mixin.one"

const val GIPHY_URL = "https://api.giphy.com/v1/"
const val FOURSQUARE_URL = "https://api.foursquare.com/v2/"
Expand Down Expand Up @@ -106,6 +108,8 @@ object Constants {
const val PREF_MARKET_ORDER = "pref_market_order"
const val PREF_INSCRIPTION_ORDER = "pref_inscription_order"
const val PREF_ROUTE_BOT_PK = "pref_route_bot_pk"
const val PREF_CASH_BOT_PK = "pref_cash_bot_pk"
const val PREF_CASH_ACCOUNT = "pref_cash_account"

const val PREF_REFERRAL_BOT_PK = "pref_referral_bot_pk"

Expand Down Expand Up @@ -536,13 +540,11 @@ object Constants {

const val ROUTE_BOT_USER_ID = "61cb8dd4-16b1-4744-ba0c-7b2d2e52fc59"
const val REFERRAL_BOT_USER_ID = "b35af74d-cca6-400c-a62b-5a7e659de91e"

const val SAFE_BOT_USER_ID = "b5418449-9ed6-4979-a690-82690949c542"

const val ROUTE_BOT_URL = "https://api.route.mixin.one"

const val REFERRAL_API_URL = "https://api.reward.mixin.one"

const val GOOGLE_PAY = "googlepay"

const val PAYMENTS_ENVIRONMENT = WalletConstants.ENVIRONMENT_PRODUCTION
Expand Down
10 changes: 10 additions & 0 deletions app/src/main/java/one/mixin/android/api/response/CashAccount.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package one.mixin.android.api.response

import com.google.gson.annotations.SerializedName

data class CashAccount(
@SerializedName("balance")
val balance: String,
@SerializedName("min_amount")
val minAmount: String,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
package one.mixin.android.api.response

import com.google.gson.annotations.SerializedName

data class WalletHomeBanner(
@SerializedName(value = "banner_id", alternate = ["id"])
val bannerId: String? = null,
@SerializedName("placement")
val placement: String? = null,
@SerializedName("lang")
val lang: String? = null,
@SerializedName("icon_url")
val iconUrl: String? = null,
@SerializedName("title")
val title: String? = null,
@SerializedName("description")
val description: String? = null,
@SerializedName("actions")
val actions: List<WalletHomeBannerAction>? = emptyList(),
@SerializedName("action_url")
val actionUrl: String? = null,
@SerializedName("tracking_key")
val trackingKey: String? = null,
@SerializedName("status")
val status: String? = null,
@SerializedName("start_at")
val startAt: String? = null,
@SerializedName("end_at")
val endAt: String? = null,
@SerializedName("chains")
val chains: List<String>? = emptyList(),
@SerializedName("priority")
val priority: Int = 0,
@SerializedName("created_at")
val createdAt: String? = null,
@SerializedName("updated_at")
val updatedAt: String? = null,
) {
val key: String
get() = bannerId.takeUnless(String?::isNullOrBlank)
?: actionUrl.takeUnless(String?::isNullOrBlank)
?: title.takeUnless(String?::isNullOrBlank)
?: iconUrl.orEmpty()

val hasVisualContent: Boolean
get() = !title.isNullOrBlank() ||
!description.isNullOrBlank() ||
visibleActions.isNotEmpty() ||
!iconUrl.isNullOrBlank()

val visibleActions: List<WalletHomeBannerAction>
get() = actions.orEmpty()
.firstOrNull { !it.label.isNullOrBlank() && !it.action.isNullOrBlank() }
?.let(::listOf)
.orEmpty()

val hasButtonStyle: Boolean
get() = visibleActions.isNotEmpty()

val isActive: Boolean
get() = status.isNullOrBlank() || status.equals(BANNER_STATUS_ACTIVE, ignoreCase = true)

companion object {
const val BANNER_STATUS_ACTIVE = "active"
const val BANNER_STATUS_INACTIVE = "inactive"
}
}

data class WalletHomeBannerAction(
@SerializedName("label")
val label: String? = null,
@SerializedName("action")
val action: String? = null,
)

fun Set<String>.syncedWalletHomeClosedBannerIds(remoteBanners: List<WalletHomeBanner>): Set<String> {
val remoteKeys = remoteBanners.mapNotNull { it.key.takeIf(String::isNotBlank) }.toSet()
return filter { remoteKeys.contains(it) }.toSet()
}

fun List<WalletHomeBanner>.visibleWalletHomeBanners(closedBannerIds: Set<String>): List<WalletHomeBanner> =
filter { banner ->
banner.key.isNotBlank() &&
banner.isActive &&
banner.hasVisualContent &&
(!banner.actionUrl.isNullOrBlank() || banner.visibleActions.isNotEmpty()) &&
!closedBannerIds.contains(banner.key)
}.sortedByDescending { it.priority }
10 changes: 10 additions & 0 deletions app/src/main/java/one/mixin/android/api/service/CashService.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package one.mixin.android.api.service

import one.mixin.android.api.MixinResponse
import one.mixin.android.api.response.CashAccount
import retrofit2.http.GET

interface CashService {
@GET("account")
suspend fun account(): MixinResponse<CashAccount>
}
Original file line number Diff line number Diff line change
@@ -1,10 +1,17 @@
package one.mixin.android.api.service

import one.mixin.android.api.MixinResponse
import one.mixin.android.api.response.WalletHomeBanner
import one.mixin.android.api.response.referral.ReferralResponse
import retrofit2.http.GET
import retrofit2.http.Query

interface ReferralService {
@GET("referral")
suspend fun referral(): MixinResponse<ReferralResponse>

@GET("app-banners")
suspend fun walletHomeBanners(
@Query("chain") chains: List<String>? = null,
): MixinResponse<List<WalletHomeBanner>>
}
25 changes: 25 additions & 0 deletions app/src/main/java/one/mixin/android/db/property/PropertyHelper.kt
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import one.mixin.android.Constants.Account.Migration.PREF_MIGRATION_INSCRIPTION
import one.mixin.android.Constants.Account.Migration.PREF_MIGRATION_TRANSCRIPT_ATTACHMENT
import one.mixin.android.Constants.Account.Migration.PREF_MIGRATION_TRANSCRIPT_ATTACHMENT_LAST
import one.mixin.android.Constants.Account.PREF_BACKUP
import one.mixin.android.Constants.Account.PREF_CASH_ACCOUNT
import one.mixin.android.Constants.Account.PREF_CLEANUP_QUOTE_CONTENT
import one.mixin.android.Constants.Account.PREF_CLEANUP_THUMB
import one.mixin.android.Constants.Account.PREF_DUPLICATE_TRANSFER
Expand All @@ -21,13 +22,15 @@ import one.mixin.android.Constants.Download.MOBILE_DEFAULT
import one.mixin.android.Constants.Download.ROAMING_DEFAULT
import one.mixin.android.Constants.Download.WIFI_DEFAULT
import one.mixin.android.MixinApplication
import one.mixin.android.api.response.CashAccount
import one.mixin.android.db.MixinDatabase
import one.mixin.android.db.PropertyDao
import one.mixin.android.extension.defaultSharedPreferences
import one.mixin.android.extension.nowInUtc
import one.mixin.android.job.ClearFts4Job.Companion.FTS_CLEAR
import one.mixin.android.job.MigratedFts4Job.Companion.FTS_NEED_MIGRATED_LAST_ROW_ID
import one.mixin.android.session.Session
import one.mixin.android.util.GsonHelper
import one.mixin.android.vo.Property

object PropertyHelper {
Expand Down Expand Up @@ -148,6 +151,28 @@ object PropertyHelper {
propertyDao.deletePropertyByKey(key)
}

suspend fun updateCashAccount(account: CashAccount?) {
val value = runCatching {
account?.takeIf { it.balance.isNotBlank() && it.minAmount.isNotBlank() }?.let {
GsonHelper.customGson.toJson(it)
}
}.getOrNull()
if (value == null) {
deleteKeyValue(PREF_CASH_ACCOUNT)
} else {
updateKeyValue(PREF_CASH_ACCOUNT, value)
}
}

suspend fun findCashAccount(): CashAccount? {
val value = findValueByKey(PREF_CASH_ACCOUNT, "")
if (value.isBlank()) return null
return runCatching {
val account = GsonHelper.customGson.fromJson(value, CashAccount::class.java) ?: return@runCatching null
account.takeIf { it.balance.isNotBlank() && it.minAmount.isNotBlank() }
}.getOrNull()
}

suspend fun <T> findValueByKey(
key: String,
default: T,
Expand Down
48 changes: 47 additions & 1 deletion app/src/main/java/one/mixin/android/di/AppModule.kt
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,12 @@ import okhttp3.logging.HttpLoggingInterceptor
import one.mixin.android.BuildConfig
import one.mixin.android.Constants
import one.mixin.android.Constants.ALLOW_INTERVAL
import one.mixin.android.Constants.API.CASH_URL
import one.mixin.android.Constants.API.FOURSQUARE_URL
import one.mixin.android.Constants.API.GIPHY_URL
import one.mixin.android.Constants.API.Mixin_URL
import one.mixin.android.Constants.API.URL
import one.mixin.android.Constants.Account.PREF_CASH_BOT_PK
import one.mixin.android.Constants.Account.PREF_REFERRAL_BOT_PK
import one.mixin.android.Constants.Account.PREF_ROUTE_BOT_PK
import one.mixin.android.Constants.DNS
Expand All @@ -52,6 +54,7 @@ import one.mixin.android.api.service.AccountService
import one.mixin.android.api.service.AddressService
import one.mixin.android.api.service.AssetService
import one.mixin.android.api.service.AuthorizationService
import one.mixin.android.api.service.CashService
import one.mixin.android.api.service.CircleService
import one.mixin.android.api.service.ContactService
import one.mixin.android.api.service.ConversationService
Expand Down Expand Up @@ -531,7 +534,7 @@ object AppModule {
val sourceRequest = chain.request()
val b = sourceRequest.newBuilder()
b.addHeader("User-Agent", API_UA)
.addHeader("Accept-Language", Locale.getDefault().language)
.addHeader("Accept-Language", Locale.getDefault().toLanguageTag())
.addHeader("Mixin-Device-Id", getStringDeviceId(resolver))
.addHeader(xRequestId, UUID.randomUUID().toString())
val botPublicKey = appContext.defaultSharedPreferences.getString(PREF_ROUTE_BOT_PK, null)
Expand Down Expand Up @@ -596,6 +599,49 @@ object AppModule {
return retrofit.create(ReferralService::class.java)
}

@Singleton
@Provides
fun provideCashService(
resolver: ContentResolver,
httpLoggingInterceptor: HttpLoggingInterceptor?,
@ApplicationContext appContext: Context,
): CashService {
val builder = OkHttpClient.Builder()
builder.connectTimeout(15, TimeUnit.SECONDS)
builder.writeTimeout(15, TimeUnit.SECONDS)
builder.readTimeout(15, TimeUnit.SECONDS)
builder.dns(DNS)
val client =
builder.apply {
httpLoggingInterceptor?.let { interceptor ->
addNetworkInterceptor(interceptor)
}
addInterceptor { chain ->
val sourceRequest = chain.request()
val b = sourceRequest.newBuilder()
b.addHeader("User-Agent", API_UA)
.addHeader("Accept-Language", Locale.getDefault().language)
.addHeader("Mixin-Device-Id", getStringDeviceId(resolver))
.addHeader(xRequestId, UUID.randomUUID().toString())
val botPublicKey = appContext.defaultSharedPreferences.getString(PREF_CASH_BOT_PK, null)
if (botPublicKey.isNullOrBlank()) return@addInterceptor chain.proceed(b.build())
val (ts, signature) = Session.getBotSignature(botPublicKey, sourceRequest)
b.addHeader(mrAccessTimestamp, ts.toString())
b.addHeader(mrAccessSign, signature)
val request = b.build()
return@addInterceptor chain.proceed(request)
}
}.build()
val retrofit =
Retrofit.Builder()
.baseUrl(CASH_URL)
.addConverterFactory(GsonConverterFactory.create())
.addCallAdapterFactory(CoroutineCallAdapterFactory())
.client(client)
.build()
return retrofit.create(CashService::class.java)
}

@Provides
@Singleton
fun provideCallState() = CallStateLiveData()
Expand Down
30 changes: 30 additions & 0 deletions app/src/main/java/one/mixin/android/repository/CashRepository.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package one.mixin.android.repository

import one.mixin.android.Constants.MIXIN_CASH_USER_ID
import one.mixin.android.api.MixinResponse
import one.mixin.android.api.response.CashAccount
import one.mixin.android.api.service.CashService
import one.mixin.android.db.property.PropertyHelper
import one.mixin.android.util.ErrorHandler
import javax.inject.Inject

class CashRepository
@Inject
constructor(
private val cashService: CashService,
private val userRepository: UserRepository,
) {
suspend fun account(): MixinResponse<CashAccount> {
userRepository.getBotPublicKey(MIXIN_CASH_USER_ID, false)
val response = cashService.account()
if (response.errorCode != ErrorHandler.AUTHENTICATION) {
if (response.isSuccess) PropertyHelper.updateCashAccount(response.data)
return response
}

userRepository.getBotPublicKey(MIXIN_CASH_USER_ID, true)
return cashService.account().also {
if (it.isSuccess) PropertyHelper.updateCashAccount(it.data)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import javax.inject.Inject
import one.mixin.android.api.referral.ReferralShareInfo
import one.mixin.android.api.referral.calculateReferralRebatePercentOrNull
import one.mixin.android.api.referral.requestReferralMixinAPI
import one.mixin.android.api.response.WalletHomeBanner
import one.mixin.android.api.response.referral.ReferralCode
import one.mixin.android.api.response.referral.ReferralResponse
import one.mixin.android.api.service.ReferralService
Expand Down Expand Up @@ -88,6 +89,33 @@ class ReferralRepository
requestSession = { userRepository.fetchSessionsSuspend(it) },
)
}

suspend fun fetchWalletHomeBanners(chains: List<String> = emptyList()): List<WalletHomeBanner> {
runCatching {
userRepository.getBotPublicKey(REFERRAL_BOT_USER_ID, false)
}.onFailure {
Timber.w(it, "Failed to warm up referral bot session before fetching wallet home banners")
}

return requestReferralMixinAPI(
invokeNetwork = { referralService.walletHomeBanners(chains.takeIf { it.isNotEmpty() }) },
successBlock = { response -> response.data.orEmpty() },
failureBlock = { response ->
Timber.d(
"Fetch wallet home banners failed code=%s message=%s",
response.errorCode,
response.errorDescription,
)
true
},
exceptionBlock = {
Timber.w(it, "Fetch wallet home banners failed")
true
},
requestSession = { userRepository.fetchSessionsSuspend(it) },
).orEmpty()
}

}

internal fun hasValidReferralMembership(membership: Membership?): Boolean = membership?.isMembership() == true
Expand Down
Loading