Skip to content
Open
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
4 changes: 4 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ plugins {
id 'com.android.application' version '8.9.1' apply false
id 'com.android.library' version '8.9.1' apply false
id 'org.jetbrains.kotlin.android' version '2.0.0' apply false
id 'org.jetbrains.kotlin.plugin.compose' version '2.0.0' apply false
id 'io.github.gradle-nexus.publish-plugin' version "1.2.0"
id "org.jetbrains.dokka" version "1.8.10"
id 'com.diffplug.spotless' version '8.0.0' apply false
Expand All @@ -29,6 +30,9 @@ subprojects {
target '**/*.kt'
targetExclude '**/build/**', '**/generated/**', '**/xmtpv3.kt'
ktlint('1.7.1')
suppressLintsFor {
shortCode = 'standard:max-line-length'
}
}

kotlinGradle {
Expand Down
29 changes: 28 additions & 1 deletion example/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ buildscript {
plugins {
id 'com.android.application'
id 'org.jetbrains.kotlin.android'
id 'org.jetbrains.kotlin.plugin.compose'
// id 'com.google.gms.google-services'
}

Expand Down Expand Up @@ -43,11 +44,15 @@ android {
jvmTarget = '17'
}
buildFeatures {
viewBinding true
buildConfig true
compose true
}
}

composeCompiler {
reportsDestination = layout.buildDirectory.dir("compose_compiler")
}

dependencies {
implementation project(':library')
implementation 'androidx.core:core-ktx:1.12.0'
Expand All @@ -64,6 +69,28 @@ dependencies {
implementation 'androidx.recyclerview:recyclerview:1.3.2'
implementation 'org.web3j:crypto:5.0.0'

// Security - Encrypted SharedPreferences
implementation 'androidx.security:security-crypto:1.1.0-alpha06'

// Glide for GIF support
implementation 'com.github.bumptech.glide:glide:4.16.0'

// Jetpack Compose
def composeBom = platform('androidx.compose:compose-bom:2024.02.00')
implementation composeBom
androidTestImplementation composeBom
implementation 'androidx.compose.ui:ui'
implementation 'androidx.compose.ui:ui-graphics'
implementation 'androidx.compose.ui:ui-tooling-preview'
implementation 'androidx.compose.material3:material3'
implementation 'androidx.compose.material:material-icons-extended'
implementation 'androidx.activity:activity-compose:1.8.2'
implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0'
implementation 'androidx.lifecycle:lifecycle-runtime-compose:2.7.0'
implementation 'androidx.navigation:navigation-compose:2.7.6'
debugImplementation 'androidx.compose.ui:ui-tooling'
debugImplementation 'androidx.compose.ui:ui-test-manifest'

// WalletConnect V2: core library + WalletConnectModal
implementation(platform("com.walletconnect:android-bom:1.19.1"))
implementation("com.walletconnect:android-core")
Expand Down
13 changes: 13 additions & 0 deletions example/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,19 @@
<activity
android:name=".conversation.ConversationDetailActivity"
android:exported="false" />
<activity
android:name=".conversation.GroupManagementActivity"
android:exported="false" />
<activity
android:name=".conversation.UserProfileActivity"
android:exported="false" />
<activity
android:name=".conversation.NewConversationActivity"
android:exported="false" />
<activity
android:name=".conversation.AttachmentPreviewActivity"
android:exported="false"
android:theme="@style/Theme.XMTPAndroid.NoActionBar" />

<service
android:name=".account.AuthenticatorService"
Expand Down
122 changes: 108 additions & 14 deletions example/src/main/java/org/xmtp/android/example/ClientManager.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,24 +2,57 @@ package org.xmtp.android.example

import android.content.Context
import androidx.annotation.UiThread
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import org.xmtp.android.example.utils.KeyUtil
import org.xmtp.android.library.Client
import org.xmtp.android.library.ClientOptions
import org.xmtp.android.library.XMTPEnvironment
import org.xmtp.android.library.codecs.AttachmentCodec
import org.xmtp.android.library.codecs.GroupUpdatedCodec
import org.xmtp.android.library.libxmtp.IdentityKind
import org.xmtp.android.library.libxmtp.PublicIdentity
import org.xmtp.android.library.codecs.ReactionCodec
import org.xmtp.android.library.codecs.RemoteAttachmentCodec
import org.xmtp.android.library.codecs.ReplyCodec
import org.xmtp.android.library.messages.PrivateKeyBuilder
import timber.log.Timber
import uniffi.xmtpv3.FfiLogLevel
import java.security.SecureRandom
import java.util.concurrent.atomic.AtomicReference

object ClientManager {
// Thread-safe environment and log level using AtomicReference
private val _selectedEnvironment = AtomicReference(XMTPEnvironment.DEV)
var selectedEnvironment: XMTPEnvironment
get() = _selectedEnvironment.get()
set(value) = _selectedEnvironment.set(value)

private val _selectedLogLevel = AtomicReference<FfiLogLevel?>(null)
var selectedLogLevel: FfiLogLevel?
get() = _selectedLogLevel.get()
set(value) = _selectedLogLevel.set(value)

// Lock for thread-safe operations
private val lock = Any()

// Application-scoped coroutine scope for client operations
private var managerScope: CoroutineScope? = null
Comment thread
macroscopeapp[bot] marked this conversation as resolved.

private fun getOrCreateScope(): CoroutineScope =
synchronized(lock) {
managerScope ?: CoroutineScope(SupervisorJob() + Dispatchers.IO).also {
managerScope = it
}
}

fun clientOptions(
appContext: Context,
address: String,
environment: XMTPEnvironment = selectedEnvironment,
): ClientOptions {
val keyUtil = KeyUtil(appContext)
val encryptionKey =
Expand All @@ -29,7 +62,7 @@ object ClientManager {
return ClientOptions(
api =
ClientOptions.Api(
XMTPEnvironment.DEV,
environment,
isSecure = true,
),
appContext = appContext,
Expand All @@ -55,32 +88,93 @@ object ClientManager {
address: String,
appContext: Context,
) {
if (clientState.value is ClientState.Ready) return
GlobalScope.launch(Dispatchers.IO) {
// Use compareAndSet to atomically transition from Unknown/Error to Creating
// This prevents race conditions with concurrent createClient() calls
synchronized(lock) {
val currentState = _clientState.value
if (currentState is ClientState.Ready || currentState is ClientState.Creating) return
_clientState.value = ClientState.Creating
}
getOrCreateScope().launch {
try {
val keyUtil = KeyUtil(appContext)
val privateKeyBytes = keyUtil.retrievePrivateKey(address)
Comment thread
macroscopeapp[bot] marked this conversation as resolved.

// Restore the saved environment, default to DEV if not found
val savedEnvironment = keyUtil.retrieveEnvironment()
selectedEnvironment = savedEnvironment?.let {
try {
XMTPEnvironment.valueOf(it)
} catch (e: IllegalArgumentException) {
XMTPEnvironment.DEV
}
} ?: XMTPEnvironment.DEV

Timber.d(
"createClient: address=$address, hasPrivateKey=${privateKeyBytes != null}, environment=$selectedEnvironment",
)

_client =
Client.build(
PublicIdentity(IdentityKind.ETHEREUM, address),
clientOptions(appContext, address),
)
if (privateKeyBytes != null) {
// Rebuild the wallet from stored private key and use Client.create()
// This allows signing if identity registration is needed
val privateKey = PrivateKeyBuilder.buildFromPrivateKeyData(privateKeyBytes)
val wallet = PrivateKeyBuilder(privateKey)
val rebuiltAddress = wallet.publicIdentity.identifier
Timber.d("createClient: rebuiltAddress=$rebuiltAddress")

Client.create(
wallet,
clientOptions(appContext, rebuiltAddress),
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
)
} else {
// No private key stored - this could happen on old installations
// Signal that we need to re-authenticate
throw IllegalStateException("No wallet key found. Please sign in again.")
}
Client.register(codec = GroupUpdatedCodec())
Client.register(codec = ReplyCodec())
Client.register(codec = ReactionCodec())
Client.register(codec = AttachmentCodec())
Client.register(codec = RemoteAttachmentCodec())
_clientState.value = ClientState.Ready
} catch (e: Exception) {
Timber.e(e, "createClient failed")
_clientState.value = ClientState.Error(e.localizedMessage.orEmpty())
}
}
}

// Set a client that was created externally (e.g., during wallet generation)
fun setClient(client: Client) {
synchronized(lock) {
_client = client
Client.register(codec = GroupUpdatedCodec())
Client.register(codec = ReplyCodec())
Client.register(codec = ReactionCodec())
Client.register(codec = AttachmentCodec())
Client.register(codec = RemoteAttachmentCodec())
_clientState.value = ClientState.Ready
}
}

@UiThread
fun clearClient() {
_clientState.value = ClientState.Unknown
_client = null
synchronized(lock) {
_clientState.value = ClientState.Unknown
_client = null
// Cancel the scope to prevent memory leaks from pending coroutines
managerScope?.cancel()
managerScope = null
}
}

sealed class ClientState {
object Unknown : ClientState()
data object Unknown : ClientState()

data object Creating : ClientState()

object Ready : ClientState()
data object Ready : ClientState()

data class Error(
val message: String,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,12 @@ const val BASE_LOG_TAG = "WC2"
class ExampleApp : Application() {
override fun onCreate() {
super.onCreate()

// Initialize Timber for logging
if (BuildConfig.DEBUG) {
Timber.plant(Timber.DebugTree())
}

val connectionType = ConnectionType.AUTOMATIC
val relayUrl = "relay.walletconnect.com"
val serverUrl = "wss://$relayUrl?projectId=${BuildConfig.PROJECT_ID}"
Expand Down
Loading
Loading