Updated on 2026-08-14

This commit is contained in:
Tangem 2025-08-29 15:41:41 +02:00
parent 5a11a6109f
commit bc7d7ea7b6
18 changed files with 357 additions and 84 deletions

4
.gitignore vendored
View file

@ -9,6 +9,10 @@ local.properties
# Google services
# Huawei services
app/agconnect-services.json
app/src/debug/google-services.json
app/src/internal/google-services.json
app/src/external/google-services.json

View file

@ -11,9 +11,14 @@ plugins {
alias(deps.plugins.firebase.crashlytics)
alias(deps.plugins.firebase.perf)
alias(deps.plugins.ksp)
id(deps.plugins.agconnect.get().pluginId)
id("configuration")
}
agcp {
manifest = false
}
android {
namespace = "com.tangem.wallet"
testOptions {
@ -53,12 +58,35 @@ android {
keyPassword = keystoreProperties["key_password"] as String
}
}
flavorDimensions += "services"
productFlavors {
create("google") {
dimension = "services"
buildConfigField("String", "FLAVOR_NAME", "\"google\"")
}
create("huawei") {
dimension = "services"
buildConfigField("String", "FLAVOR_NAME", "\"huawei\"")
}
}
buildTypes {
debug {
buildConfigField("String", "BUILD_TYPE", "\"debug\"")
}
release {
buildConfigField("String", "BUILD_TYPE", "\"release\"")
}
}
}
configurations.all {
exclude(group = "org.bouncycastle", module = "bcprov-jdk15to18")
exclude(group = "com.github.komputing.kethereum")
exclude(group = "com.android.tools.build", module = "gradle")
resolutionStrategy {
dependencySubstitution {
@ -379,4 +407,9 @@ dependencies {
// excludes version 9999.0-empty-to-avoid-conflict-with-guava
exclude(group = "com.google.guava", module = "listenablefuture")
}
/** Huawei flavor-specific dependencies */
"huaweiImplementation"(deps.huawei.push)
"huaweiImplementation"(deps.agconnect.agcp)
"huaweiImplementation"(deps.agconnect.core)
}

View file

@ -7,6 +7,13 @@
-keep class com.google.android.gms.internal.** { *; }
-keepclasseswithmembers class com.google.firebase.FirebaseException
# huawei push kit
-ignorewarnings
-keepattributes SourceFile,LineNumberTable
-keep class com.huawei.hianalytics.**{*;}
-keep class com.huawei.updatesdk.**{*;}
-keep class com.huawei.hms.**{*;}
# hedera sdk
-keep class com.hedera.hashgraph.sdk.** { *; }
-keep interface com.hedera.hashgraph.sdk.** { *; }

View file

@ -1,4 +1,4 @@
package com.tangem.tap.data
package com.tangem.tap
import com.google.firebase.messaging.FirebaseMessaging
import com.tangem.utils.notifications.PushNotificationsTokenProvider

View file

@ -1,6 +1,6 @@
package com.tangem.tap.di.data
package com.tangem.tap.di
import com.tangem.tap.data.FirebasePushNotificationsTokenProvider
import com.tangem.tap.FirebasePushNotificationsTokenProvider
import com.tangem.utils.notifications.PushNotificationsTokenProvider
import dagger.Binds
import dagger.Module
@ -10,7 +10,7 @@ import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
internal interface PushNotificationsModule {
internal interface GooglePushModule {
@Binds
@Singleton

View file

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application>
<service
android:name="com.tangem.tap.HuaweiPushService"
android:exported="false">
<intent-filter>
<action android:name="com.huawei.push.action.MESSAGING_EVENT" />
</intent-filter>
</service>
<meta-data
android:name="push_kit_auto_init_enabled"
android:value="true" />
</application>
</manifest>

View file

@ -0,0 +1,50 @@
package com.tangem.tap
import android.content.Context
import com.google.firebase.messaging.FirebaseMessaging
import com.huawei.agconnect.AGConnectOptionsBuilder
import com.huawei.hms.aaid.HmsInstanceId
import com.huawei.hms.common.ApiException
import com.tangem.google.GoogleServicesHelper
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.notifications.PushNotificationsTokenProvider
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.tasks.await
import kotlinx.coroutines.withContext
import timber.log.Timber
import javax.inject.Inject
internal class HuaweiPushNotificationsTokenProvider @Inject constructor(
@ApplicationContext private val context: Context,
private val coroutineDispatcherProvider: CoroutineDispatcherProvider,
) : PushNotificationsTokenProvider {
override suspend fun getToken(): String {
val isGoogleServicesAvailable = GoogleServicesHelper.checkGoogleServicesAvailability(context)
return if (isGoogleServicesAvailable) {
try {
FirebaseMessaging.getInstance().token.await()
} catch (ex: Exception) {
Timber.e(ex)
""
}
} else {
withContext(coroutineDispatcherProvider.io) {
try {
val appId = AGConnectOptionsBuilder().build(context).getString(APP_ID_KEY)
val token = HmsInstanceId.getInstance(context).getToken(appId, TOKEN_REQUEST_MODE)
Timber.i("Requested token from HuaweiService: $token")
token
} catch (e: ApiException) {
Timber.i("Fetching token from HuaweiService failed cause: ${e.message}")
""
}
}
}
}
companion object {
private const val APP_ID_KEY = "client/app_id"
private const val TOKEN_REQUEST_MODE = "HCM"
}
}

View file

@ -0,0 +1,47 @@
package com.tangem.tap
import android.os.Bundle
import com.huawei.hms.push.HmsMessageService
import com.huawei.hms.push.RemoteMessage
import com.tangem.google.GoogleServicesHelper
import com.tangem.tap.common.pushes.PushNotificationDelegate
import timber.log.Timber
class HuaweiPushService : HmsMessageService() {
private val pushNotificationDelegate: PushNotificationDelegate by lazy {
PushNotificationDelegate(applicationContext)
}
override fun onNewToken(token: String?, bundle: Bundle?) {
super.onNewToken(token, bundle)
Timber.i("HuaweiPushService: On new token from HuaweiService: $token")
}
override fun onTokenError(e: Exception?, bundle: Bundle?) {
super.onTokenError(e, bundle)
Timber.i("HuaweiPushService: Fetching token from HuaweiService failed cause: ${e?.message}")
}
override fun onMessageReceived(message: RemoteMessage?) {
super.onMessageReceived(message)
val isGoogleServicesAvailable = GoogleServicesHelper.checkGoogleServicesAvailability(this)
if (isGoogleServicesAvailable) return
val notification = message?.notification ?: return
val channelId = notification.channelId ?: TANGEM_CHANNEL_ID
pushNotificationDelegate.showNotification(
dataMap = message.dataOfMap,
title = notification.title,
body = notification.body,
channelId = channelId,
priority = message.urgency,
imageUrl = notification.imageUrl,
vibratePattern = notification.vibrateConfig,
)
}
private companion object {
const val TANGEM_CHANNEL_ID = "Tangem General" // General channel for notifications
}
}

View file

@ -0,0 +1,18 @@
package com.tangem.tap.di
import com.tangem.tap.HuaweiPushNotificationsTokenProvider
import com.tangem.utils.notifications.PushNotificationsTokenProvider
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
internal interface HuaweiPushModule {
@Binds
@Singleton
fun bindPushNotificationsTokenProvider(impl: HuaweiPushNotificationsTokenProvider): PushNotificationsTokenProvider
}

View file

@ -0,0 +1,13 @@
package com.tangem.tap.common.buildconfig
import com.tangem.utils.buildConfig.AppConfigurationProvider
import com.tangem.wallet.BuildConfig
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
internal class AppConfigurationProviderImpl @Inject constructor() : AppConfigurationProvider {
override fun isDebug(): Boolean = BuildConfig.BUILD_TYPE == "debug"
override fun isHuawei(): Boolean = BuildConfig.FLAVOR_NAME == "huawei"
}

View file

@ -0,0 +1,102 @@
package com.tangem.tap.common.pushes
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.net.Uri
import android.os.Build
import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat
import androidx.core.graphics.drawable.toBitmap
import coil.executeBlocking
import coil.request.ImageRequest
import com.tangem.domain.common.LogConfig
import com.tangem.tap.MainActivity
import com.tangem.tap.common.images.createCoilImageLoader
import com.tangem.tap.features.intentHandler.handlers.OnPushClickedIntentHandler
import com.tangem.wallet.R
class PushNotificationDelegate(private val context: Context) {
@Suppress("LongParameterList")
fun showNotification(
dataMap: Map<String, String>,
title: String?,
body: String?,
channelId: String,
priority: Int,
imageUrl: Uri? = null,
vibratePattern: LongArray?,
) {
val intent = Intent(context, MainActivity::class.java).apply {
dataMap.forEach { (key, value) ->
putExtra(key, value)
}
putExtra(OnPushClickedIntentHandler.OPENED_FROM_GCM_PUSH, true)
addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
}
val pendingIntent = PendingIntent.getActivity(
/* context = */ context,
/* requestCode = */ PUSH_NOTIFICATION_REQUEST_CODE,
/* intent = */ intent,
/* flags = */ PendingIntent.FLAG_ONE_SHOT or PendingIntent.FLAG_IMMUTABLE,
)
val notificationBuilder = NotificationCompat.Builder(context, channelId)
.setSmallIcon(R.drawable.ic_tangem_24)
.setContentTitle(title)
.setContentText(body)
.setPriority(priority)
.setAutoCancel(true)
.setContentIntent(pendingIntent)
.setVibrate(vibratePattern)
.apply {
imageUrl?.let { uri ->
val bitmap = getBitmapImageFromUrl(uri)
setStyle(
NotificationCompat
.BigPictureStyle()
.bigPicture(bitmap),
).setLargeIcon(bitmap)
}
}
val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val notificationChannel = NotificationChannel(
channelId,
ContextCompat.getString(context, R.string.tangem_app_name),
NotificationManager.IMPORTANCE_HIGH,
)
notificationManager.createNotificationChannel(notificationChannel)
}
// Generating unique notification id
val uniqueId = (System.currentTimeMillis() % Integer.MAX_VALUE).toInt()
notificationManager.notify(
/* id = */ uniqueId,
/* notification = */ notificationBuilder.build(),
)
}
private fun getBitmapImageFromUrl(url: Uri): Bitmap? {
return createCoilImageLoader(
context,
logEnabled = LogConfig.imageLoader,
).executeBlocking(
ImageRequest.Builder(context)
.data(url)
.build(),
).drawable?.toBitmap()
}
private companion object {
const val PUSH_NOTIFICATION_REQUEST_CODE = 123
}
}

View file

@ -1,30 +1,17 @@
package com.tangem.tap.common.pushes
import android.annotation.SuppressLint
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Intent
import android.graphics.Bitmap
import android.net.Uri
import android.os.Build
import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat
import androidx.core.graphics.drawable.toBitmap
import coil.executeBlocking
import coil.request.ImageRequest
import com.google.firebase.messaging.FirebaseMessagingService
import com.google.firebase.messaging.RemoteMessage
import com.tangem.domain.common.LogConfig
import com.tangem.tap.MainActivity
import com.tangem.tap.common.images.createCoilImageLoader
import com.tangem.tap.features.intentHandler.handlers.OnPushClickedIntentHandler
import com.tangem.wallet.R
import timber.log.Timber
@SuppressLint("MissingFirebaseInstanceTokenRefresh")
internal class TangemPushNotificationService : FirebaseMessagingService() {
private val pushNotificationDelegate: PushNotificationDelegate by lazy {
PushNotificationDelegate(applicationContext)
}
override fun onNewToken(token: String) {
super.onNewToken(token)
Timber.d("New FCM token received: $token")
@ -36,73 +23,18 @@ internal class TangemPushNotificationService : FirebaseMessagingService() {
val notification = message.notification ?: return
val channelId = notification.channelId ?: TANGEM_CHANNEL_ID
val intent = Intent(applicationContext, MainActivity::class.java).apply {
message.data.forEach {
putExtra(it.key, it.value)
}
putExtra(OnPushClickedIntentHandler.OPENED_FROM_GCM_PUSH, true)
addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
}
val pendingIntent = PendingIntent.getActivity(
/* context = */ this,
/* requestCode = */ PUSH_NOTIFICATION_REQUEST_CODE,
/* intent = */ intent,
/* flags = */ PendingIntent.FLAG_ONE_SHOT or PendingIntent.FLAG_IMMUTABLE,
pushNotificationDelegate.showNotification(
dataMap = message.data,
title = notification.title,
body = notification.body,
channelId = channelId,
priority = message.priority,
imageUrl = notification.imageUrl,
vibratePattern = notification.vibrateTimings,
)
val notificationBuilder =
NotificationCompat.Builder(applicationContext, channelId)
.setSmallIcon(R.drawable.ic_tangem_24)
.setContentTitle(notification.title)
.setContentText(notification.body)
.setPriority(message.priority)
.setAutoCancel(true)
.setContentIntent(pendingIntent)
.setVibrate(notification.vibrateTimings)
.apply {
notification.imageUrl?.let { uri ->
val bitmap = getBitmapImageFromUrl(uri)
setStyle(
NotificationCompat
.BigPictureStyle()
.bigPicture(bitmap),
).setLargeIcon(bitmap)
}
}
val notificationManager = applicationContext.getSystemService(NOTIFICATION_SERVICE) as NotificationManager
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val notificationChannel = NotificationChannel(
channelId,
ContextCompat.getString(applicationContext, R.string.tangem_app_name),
NotificationManager.IMPORTANCE_HIGH,
)
notificationManager.createNotificationChannel(notificationChannel)
}
// Generating unique notification id
val uniqueId = (System.currentTimeMillis() % Integer.MAX_VALUE).toInt()
notificationManager.notify(
/* id = */ uniqueId,
/* notification = */ notificationBuilder.build(),
)
}
private fun getBitmapImageFromUrl(url: Uri): Bitmap? {
return createCoilImageLoader(
applicationContext,
logEnabled = LogConfig.imageLoader,
).executeBlocking(
ImageRequest.Builder(applicationContext)
.data(url)
.build(),
).drawable?.toBitmap()
}
private companion object {
const val TANGEM_CHANNEL_ID = "Tangem General" // General channel for notifications
const val PUSH_NOTIFICATION_REQUEST_CODE = 123
}
}

View file

@ -0,0 +1,18 @@
package com.tangem.tap.di
import com.tangem.tap.common.buildconfig.AppConfigurationProviderImpl
import com.tangem.utils.buildConfig.AppConfigurationProvider
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
internal interface AppConfigurationModule {
@Binds
@Singleton
fun bindAppConfigurationProvider(impl: AppConfigurationProviderImpl): AppConfigurationProvider
}

View file

@ -16,6 +16,13 @@ plugins {
alias(deps.plugins.ksp) apply false
}
buildscript {
dependencies {
classpath(deps.gradle.android)
classpath(deps.agconnect.agcp)
}
}
val clean by tasks.registering {
delete(rootProject.buildDir)
}

View file

@ -0,0 +1,6 @@
package com.tangem.utils.buildConfig
interface AppConfigurationProvider {
fun isDebug(): Boolean
fun isHuawei(): Boolean
}

View file

@ -11,6 +11,12 @@ ksp = "2.1.10-1.0.30"
firebasePerf = "1.4.2"
# endregion Classpath
# region AppGallery
agconnect = "1.9.1.304"
huaweiServices = "6.9.0.301"
huaweiPush = "6.11.0.300"
# endregion AppGallery
# region AndroidX
androidxActivityCompose = "1.8.0"
androidxAppCompat = "1.5.1"
@ -130,6 +136,7 @@ detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt" }
room = { id = "androidx.room", version.ref = "room" }
kotlin-compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
agconnect = { id = "com.huawei.agconnect", version.ref = "agconnect" }
[libraries]
# region Classpath
@ -190,6 +197,15 @@ firebase-messaging = { module = "com.google.firebase:firebase-messaging-ktx" }
firebase-perf = { module = "com.google.firebase:firebase-perf" }
# endregion Firebase
# region AppGallery
agconnect-agcp = { module = "com.huawei.agconnect:agcp", version.ref = "agconnect" }
agconnect-core = { module = "com.huawei.agconnect:agconnect-core", version.ref = "agconnect" }
agconnect-crash = { module = "com.huawei.agconnect:agconnect-crash", version.ref = "agconnect" }
huawei-base = { module = "com.huawei.hms:base", version.ref = "huaweiServices" }
huawei-analytics = { module = "com.huawei.hms:hianalytics", version.ref = "huaweiServices" }
huawei-push = { module = "com.huawei.hms:push", version.ref = "huaweiPush" }
# endregion AppGallery
# region Detekt
detekt-compose = { module = "ru.kode:detekt-rules-compose", version.ref = "detektComposeRules" }
detekt-formatting = { module = "io.gitlab.arturbosch.detekt:detekt-formatting", version.ref = "detekt" }

View file

@ -75,6 +75,7 @@ private fun AndroidBuildType.configureBuildVariant(appExtension: AppExtension, b
}
BuildType.Debug -> {
isDebuggable = true
signingConfig = appExtension.signingConfigs.getByName(BuildType.Debug.id)
}
BuildType.Internal,
BuildType.External

View file

@ -3,6 +3,7 @@ pluginManagement {
gradlePluginPortal()
google()
mavenCentral()
maven { url = uri("https://developer.huawei.com/repo/") }
}
includeBuild("plugins/configuration")
@ -35,6 +36,7 @@ dependencyResolutionManagement {
}
}
mavenCentral()
maven { url = uri("https://developer.huawei.com/repo/") }
mavenLocal {
content {
includeGroupAndSubgroups("com.tangem.tangem-sdk-kotlin")