diff --git a/CHANGELOG.md b/CHANGELOG.md index be99f8c..8ba284d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## 0.5.0 — 2026-09-15 + +- Add secure guest conversations and Compose reply/comment screens with foreground live updates, pagination, read markers, follow/mute and removal. +- Preserve legacy requests while attaching new feedback to a secure session. +- Add native FCM registration and notification-tap routing; host apps own permissions and Firebase setup. +- Use Keystore-backed encrypted credentials and close sessions permanently on logout. + ## 0.4.1 — 2026-09-11 - Add typed `conversationSettings()` discovery for project reply, notification and public-comment policy. diff --git a/README.md b/README.md index 8416c5b..0fc33de 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ Native in-app feedback for Android: a drop-in Compose feature-request board with The SDK is on Maven Central — no extra repository setup needed: ```kotlin -implementation("com.feedbackthread:feedbackthread-android:0.4.1") +implementation("com.feedbackthread:feedbackthread-android:0.5.0") ``` Alternatively, publish locally from this repository: @@ -171,8 +171,70 @@ Call this only when your application needs the policy; existing screens do not make new requests automatically. Older/self-hosted servers without the endpoint return the existing `FeedbackThreadException.Server` (404). -This version does not add a conversation inbox, comment composer, secure customer -sessions, or Android push delivery. A true service flag is project policy, not a -claim of SDK support or device permission. Do not expose private threads using -an external user ID; they require separate secure customer credentials. For full -conversation integration available today, see [Swift 0.5.0](https://github.com/aivars/feedbackthread-swift). +Project policy does not grant device notification permission. Private threads +require secure customer credentials; an external user ID is not authentication. + +## Replies and public comments (0.5.0) + +Version 0.5.0 adds secure guest sessions, private replies, public comment threads, +history pagination, read markers, follow/mute, removal and live unread state. +Requires the FeedbackThread server update released on 2026-09-15. +Physical-device push delivery has not been verified in a host app; validate your +Firebase configuration, permission flow and notification routing before rollout. + +Keep one manager per host-app account in your app model: + +```kotlin +val conversations = FeedbackThreadConversations( + context = applicationContext, + configuration = FeedbackThreadConfiguration(projectKey = "YOUR_PUBLIC_PROJECT_KEY"), + accountScope = "local-account-id", +) +``` + +At the app root, use `FeedbackThreadConversationHost(conversations) { client -> … }` +and pass that supplied client to `FeedbackThreadBoard`, `FeedbackThreadMyRequestsScreen` +and `FeedbackThreadFeedbackForm`. The host prepares secure credentials, manages +foreground live updates, shows unread messages, and opens the discussion screen. +The board exposes Comments when the project enables them; My Requests exposes +Replies only for feedback submitted with the secure session. Existing cards and +votes remain accessible through the legacy identity without claiming ownership. + +For custom UI, collect `conversations.state` and use `history`, `send`, `markRead`, +`follow`, `remove` and `open`. Supply the same `clientId` when retrying a send. +Only mark messages read once they have been displayed. `runLive()` should run only +while foregrounded; the Compose host handles its lifecycle. + +### Android notifications + +Configure FCM credentials in the project's **App discussions → Android push +configuration**. Enable Firebase Cloud Messaging and add Firebase Messaging to +the host app following Firebase's Android setup. The SDK does not own your +Firebase initialization or notification permission prompt. + +- Pass new and rotated Firebase tokens to `registerDeviceToken(token)`. +- Forward notification-tap data to `handleNotification(remoteMessageData)` (or + the matching activity intent extras). Route data never grants access by itself. +- Set up the host's notification channel and request permission when appropriate. +- Use `unregisterDeviceToken` when detaching a device without ending the session. + +Notifications contain a generic alert, never the private message body. Denying +notification permission does not prevent reading replies in the app. Validate +background delivery on a physical device with the host's Firebase project before +release; unit tests use a provider stub. + +### Identity and privacy + +Tokens are encrypted with an Android Keystore key; ciphertext lives in the app's +no-backup directory. Do not substitute plain preferences. A custom secure store +can implement `FeedbackThreadConversationStore`. + +`accountScope` is local isolation, not verified account login or cross-device +identity merging. On logout, await `logout()` and replace the manager for the next +account; the old object is permanently closed. Handle revocation errors and retry +while retaining that old object. Never reuse its client after logout. + +Public comments default off. Disabling them hides existing discussion without +deleting it. Private replies remain enabled. Image uploads are deferred and have +no SDK or server implementation in this change. New Compose conversation copy is +English, matching the current Android SDK; localization remains a release check. diff --git a/feedbackthread/build.gradle.kts b/feedbackthread/build.gradle.kts index 0641c3d..dae9617 100644 --- a/feedbackthread/build.gradle.kts +++ b/feedbackthread/build.gradle.kts @@ -7,7 +7,7 @@ plugins { } group = "com.feedbackthread" -version = "0.4.1" +version = "0.5.0" android { namespace = "com.feedbackthread.sdk" @@ -33,6 +33,8 @@ kotlin { } dependencies { + implementation("com.squareup.okhttp3:okhttp:4.12.0") + implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.7") implementation("androidx.activity:activity-compose:1.10.0") implementation(platform("androidx.compose:compose-bom:2024.12.01")) implementation("androidx.compose.ui:ui") diff --git a/feedbackthread/src/main/java/com/feedbackthread/sdk/FeedbackThreadBoard.kt b/feedbackthread/src/main/java/com/feedbackthread/sdk/FeedbackThreadBoard.kt index 6c19492..41f4d6b 100644 --- a/feedbackthread/src/main/java/com/feedbackthread/sdk/FeedbackThreadBoard.kt +++ b/feedbackthread/src/main/java/com/feedbackthread/sdk/FeedbackThreadBoard.kt @@ -37,6 +37,7 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -427,6 +428,11 @@ private fun FeatureRequestDetail( verticalArrangement = Arrangement.spacedBy(20.dp), ) { item { + val conversations = LocalFeedbackThreadConversations.current + val conversationState = conversations?.state?.collectAsState()?.value + if (conversationState?.publicCommentsEnabled == true) { + TextButton(onClick = { conversations.open(FeedbackThreadConversationRoute(request.id, "public")) }) { Text("Comments") } + } Text( text = request.title, style = MaterialTheme.typography.headlineSmall, diff --git a/feedbackthread/src/main/java/com/feedbackthread/sdk/FeedbackThreadClient.kt b/feedbackthread/src/main/java/com/feedbackthread/sdk/FeedbackThreadClient.kt index 79fb0b7..ec275bd 100644 --- a/feedbackthread/src/main/java/com/feedbackthread/sdk/FeedbackThreadClient.kt +++ b/feedbackthread/src/main/java/com/feedbackthread/sdk/FeedbackThreadClient.kt @@ -167,6 +167,7 @@ public data class FeedbackThreadMyRequest( public val createdAt: String, public val voteCount: Int, public val shippedInVersion: String? = null, + public val conversationAvailable: Boolean = false, ) /** @@ -193,6 +194,7 @@ public data class FeedbackThreadConfiguration( public val source: String = DEFAULT_SOURCE, public val connectTimeoutMillis: Int = 10_000, public val readTimeoutMillis: Int = 15_000, + internal val customerSessionProvider: (() -> FeedbackThreadCustomerSession)? = null, ) { public companion object { /** The hosted FeedbackThread API; override only for local development. */ @@ -321,6 +323,20 @@ public class FeedbackThreadClient private constructor( public suspend fun acknowledgeUpdates(ids: List, externalUserId: String): Int = handlers.acknowledgeUpdates(ids, externalUserId) + internal fun withConversationSession(provider: () -> FeedbackThreadCustomerSession): FeedbackThreadClient { + val configuration = handlers.configuration ?: throw FeedbackThreadException.InvalidConfiguration("Conversations require an HTTP client.") + val current = createHandlers(configuration.copy(customerSessionProvider = provider), handlers.connectionFactory) + return FeedbackThreadClient(current.copy( + myRequests = { id -> (handlers.myRequests(id).map { it.copy(conversationAvailable=false) } + current.myRequests(id)).associateBy { it.id }.values.sortedByDescending { it.createdAt } }, + myUpdates = { id -> + val legacy=handlers.myUpdates(id); val latest=current.myUpdates(id) + val overlap=legacy.updates.map { it.id }.toSet().intersect(latest.updates.map { it.id }.toSet()).size + FeedbackThreadMyUpdatesResult((legacy.updates+latest.updates).distinctBy { it.id },legacy.unreadCount+latest.unreadCount-overlap) + }, + acknowledgeUpdates = { ids,id -> handlers.acknowledgeUpdates(ids,id)+current.acknowledgeUpdates(ids,id) }, + )) + } + /** Read project policy without starting a private session or changing existing screens. */ public suspend fun conversationSettings(): FeedbackThreadConversationSettings = handlers.conversationSettings() @@ -331,6 +347,8 @@ public class FeedbackThreadClient private constructor( ): FeedbackThreadHandlers { val transport = FeedbackThreadHTTPTransport(configuration, connectionFactory) return FeedbackThreadHandlers( + configuration = configuration, + connectionFactory = connectionFactory, conversationSettings = transport::conversationSettings, submit = transport::submit, requests = transport::requests, @@ -344,6 +362,8 @@ public class FeedbackThreadClient private constructor( } private data class FeedbackThreadHandlers( + val connectionFactory: (URL) -> HttpURLConnection = { it.openConnection() as HttpURLConnection }, + val configuration: FeedbackThreadConfiguration? = null, val conversationSettings: suspend () -> FeedbackThreadConversationSettings = { throw FeedbackThreadException.InvalidConfiguration("This custom client does not support conversation settings.") }, @@ -395,7 +415,7 @@ private class FeedbackThreadHTTPTransport( title = submission.title, text = submission.text, appVersion = submission.appVersion, - externalUserId = submission.externalUserId, + externalUserId = configuration.customerSessionProvider?.invoke()?.externalUserId ?: submission.externalUserId, customerTier = submission.customerTier, ), ) @@ -404,6 +424,7 @@ private class FeedbackThreadHTTPTransport( try { connection.requestMethod = "POST" + configureConnection(connection) connection.connectTimeout = configuration.connectTimeoutMillis connection.readTimeout = configuration.readTimeoutMillis connection.doOutput = true @@ -560,6 +581,8 @@ private class FeedbackThreadHTTPTransport( } private fun configureConnection(connection: HttpURLConnection) { + connection.instanceFollowRedirects = false + configuration.customerSessionProvider?.invoke()?.let { connection.setRequestProperty("X-FeedbackThread-Customer", it.token) } connection.connectTimeout = configuration.connectTimeoutMillis connection.readTimeout = configuration.readTimeoutMillis connection.setRequestProperty("Accept", "application/json") @@ -575,10 +598,11 @@ private class FeedbackThreadHTTPTransport( }.getOrNull() ?: "FeedbackThread returned HTTP $statusCode." throw FeedbackThreadException.Server(statusCode, message) } + configuration.customerSessionProvider?.invoke() // Discard responses after logout. return responseBody } - private fun normalizedUserId(value: String?): String? = value?.trim()?.takeIf { it.isNotEmpty() } + private fun normalizedUserId(value: String?): String? = configuration.customerSessionProvider?.invoke()?.externalUserId ?: value?.trim()?.takeIf { it.isNotEmpty() } private fun endpointURL(path: String = "feedback"): URL { val baseUrl = configuration.baseUrl.trim().trimEnd('/') diff --git a/feedbackthread/src/main/java/com/feedbackthread/sdk/FeedbackThreadConversationHost.kt b/feedbackthread/src/main/java/com/feedbackthread/sdk/FeedbackThreadConversationHost.kt new file mode 100644 index 0000000..0fb8bff --- /dev/null +++ b/feedbackthread/src/main/java/com/feedbackthread/sdk/FeedbackThreadConversationHost.kt @@ -0,0 +1,149 @@ +package com.feedbackthread.sdk + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.repeatOnLifecycle +import java.util.UUID +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.launch + +internal val LocalFeedbackThreadConversations = staticCompositionLocalOf { null } + +/** Keep at app root and pass the supplied secure client to all FeedbackThread screens. */ +@Composable +public fun FeedbackThreadConversationHost( + conversations: FeedbackThreadConversations, + modifier: Modifier = Modifier, + content: @Composable (FeedbackThreadClient) -> Unit, +) { + val state by conversations.state.collectAsState() + val lifecycle = LocalLifecycleOwner.current.lifecycle + LaunchedEffect(conversations, lifecycle) { + lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { conversations.runLive() } + } + CompositionLocalProvider(LocalFeedbackThreadConversations provides conversations) { + key(conversations) { + Column(modifier.fillMaxSize()) { + Box(Modifier.weight(1f)) { + if (state.ready) content(conversations.client) + else Column(Modifier.padding(24.dp)) { + CircularProgressIndicator() + Text(state.error ?: "Connecting to your feedback…") + } + } + state.inbox.firstOrNull { it.unreadCount > 0 }?.let { unread -> + TextButton(onClick = { conversations.open(FeedbackThreadConversationRoute(unread.feedbackId,unread.audience)) }, modifier=Modifier.fillMaxWidth()) { + Text("New message (${state.unreadCount})") + } + } + } + state.route?.let { route -> + Dialog(onDismissRequest={conversations.open(null)}, properties=DialogProperties(usePlatformDefaultWidth=false)) { + key(route) { Surface { FeedbackThreadConversationScreen(conversations,route,{conversations.open(null)}) } } + } + } + } + } +} + +/** Opening history does not mark it read. Only messages displayed in the foreground do. */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +public fun FeedbackThreadConversationScreen( + conversations: FeedbackThreadConversations, + route: FeedbackThreadConversationRoute, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, +) { + key(conversations,route) { ConversationContent(conversations,route,onDismiss,modifier) } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ConversationContent(conversations: FeedbackThreadConversations,route: FeedbackThreadConversationRoute,onDismiss: () -> Unit,modifier: Modifier) { + val state by conversations.state.collectAsState() + val scope=rememberCoroutineScope() + val lifecycle=LocalLifecycleOwner.current.lifecycle + val list=rememberLazyListState() + var history by remember { mutableStateOf(null) } + var earlier by remember { mutableStateOf(emptyList()) } + var pages by remember { mutableStateOf(1) } + var before by remember { mutableStateOf(null) } + var draft by remember { mutableStateOf("") } + var pending by remember { mutableStateOf?>(null) } + var busy by remember { mutableStateOf(false) } + var error by remember { mutableStateOf(null) } + var revision by remember { mutableStateOf(0) } + var remove by remember { mutableStateOf(null) } + val messages=(earlier+(history?.messages ?: emptyList())).associateBy { it.id }.values.sortedBy { it.seq } + LaunchedEffect(state.revision,revision) { + try { + val value=conversations.history(route) + var cursor=value.nextBefore + val older=mutableListOf() + repeat(pages-1) { + cursor?.let { next -> val page=conversations.history(route,next); older.addAll(page.messages); cursor=page.nextBefore } + } + history=value; earlier=older; before=cursor; error=null + } catch(e: CancellationException) { throw e } catch(e: Exception) { history=null; earlier=emptyList(); pages=1; error="Could not load this conversation." } + } + LaunchedEffect(messages) { + lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) { + snapshotFlow { list.layoutInfo.visibleItemsInfo.mapNotNull { info -> messages.firstOrNull { it.id==info.key }?.seq }.maxOrNull() } + .distinctUntilChanged().collect { seq -> + if(seq!=null) try { conversations.markRead(route,seq) } catch(e: CancellationException) { throw e } catch(_: Exception) { } + } + } + } + fun action(work: suspend () -> Unit) { + if(busy) return + busy=true; error=null + scope.launch { + try { work(); revision++; try { conversations.refresh() } catch(e: CancellationException) { throw e } catch(_: Exception) {} } + catch(e: CancellationException) { throw e } catch(_: Exception) { error="Could not complete this action. Try again." } + finally { busy=false } + } + } + val disabled=route.audience=="public" && state.ready && !state.publicCommentsEnabled + Scaffold(modifier=modifier.fillMaxSize().imePadding(),topBar={ TopAppBar(title={Text(if(route.audience=="private") "Replies" else "Comments")},navigationIcon={TextButton(onClick=onDismiss){Text("Back")}}) },bottomBar={ + if(!disabled) Column(Modifier.fillMaxWidth().navigationBarsPadding().padding(12.dp)) { + OutlinedTextField(value=draft,onValueChange={ if(it.length<=4000) draft=it },label={Text(if(route.audience=="private") "Write a private reply" else "Write a public comment")},enabled=!busy,modifier=Modifier.fillMaxWidth(),maxLines=5) + Button(enabled=!busy && history!=null && draft.isNotBlank(),onClick={ + val text=draft.trim(); if(pending?.first!=text) pending=text to UUID.randomUUID().toString() + val id=pending!!.second + action { conversations.send(route,text,id); draft=""; pending=null } + },modifier=Modifier.fillMaxWidth()) { Text(if(busy) "Sending…" else if(route.audience=="private") "Send reply" else "Post comment") } + } + }) { padding -> + if(disabled) Text("Public comments are disabled for this app.",Modifier.padding(padding).padding(20.dp)) + else LazyColumn(state=list,modifier=Modifier.padding(padding).fillMaxSize(),contentPadding=PaddingValues(16.dp),verticalArrangement=Arrangement.spacedBy(12.dp)) { + item { + Text(if(route.audience=="private") "Only you and the app developer can see these replies." else "Everyone using this app can see these comments.") + error?.let { Text(it,color=MaterialTheme.colorScheme.error); TextButton(onClick={revision++}){Text("Try again")} } + history?.let { value -> TextButton(enabled=!busy,onClick={action {conversations.follow(route,!value.following)}}){Text(if(value.following) "Mute notifications" else "Notify me")} } + if(before!=null) TextButton(enabled=!busy,onClick={val cursor=before; action {val value=conversations.history(route,cursor); pages++; earlier=value.messages+earlier; before=value.nextBefore}}){Text("Load earlier messages")} + if(messages.isEmpty()) Text(if(history==null) "Loading…" else "No messages yet.") + } + items(messages,key={it.id}) { message -> + Card(Modifier.fillMaxWidth()) { Column(Modifier.padding(12.dp),verticalArrangement=Arrangement.spacedBy(8.dp)) { + Text(if(message.mine) "You" else if(message.authorRole=="developer") "Developer" else "App user",style=MaterialTheme.typography.titleSmall) + Text(if(message.deletedAt!=null) "Message removed" else message.body) + if(message.mine) Text(if((history?.otherReadSeq ?: 0)>=message.seq) "Read" else "Posted",style=MaterialTheme.typography.labelSmall) + if(message.mine && message.deletedAt==null) TextButton(enabled=!busy,onClick={remove=message}){Text("Remove message")} + } } + } + } + } + remove?.let { message -> AlertDialog(onDismissRequest={remove=null},title={Text("Remove message?")},text={Text("The conversation will show that this message was removed.")},confirmButton={TextButton(onClick={remove=null; action {conversations.remove(route,message.id)}}){Text("Remove")}},dismissButton={TextButton(onClick={remove=null}){Text("Cancel")}}) } +} diff --git a/feedbackthread/src/main/java/com/feedbackthread/sdk/FeedbackThreadConversations.kt b/feedbackthread/src/main/java/com/feedbackthread/sdk/FeedbackThreadConversations.kt new file mode 100644 index 0000000..605a29f --- /dev/null +++ b/feedbackthread/src/main/java/com/feedbackthread/sdk/FeedbackThreadConversations.kt @@ -0,0 +1,194 @@ +package com.feedbackthread.sdk + +import android.content.Context +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties +import java.io.File +import java.net.HttpURLConnection +import java.net.URI +import java.net.URL +import java.net.URLEncoder +import java.security.KeyStore +import java.security.MessageDigest +import java.util.UUID +import javax.crypto.Cipher +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey +import javax.crypto.spec.GCMParameterSpec +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.serialization.Serializable +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.* +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Response +import okhttp3.WebSocket +import okhttp3.WebSocketListener + +@Serializable public data class FeedbackThreadCustomerSession(val customerId: String, val externalUserId: String, val token: String) +@Serializable public data class FeedbackThreadConversationRoute(val feedbackId: String, val audience: String) { + init { require(audience == "private" || audience == "public"); require(feedbackId.matches(Regex("FDBK-[A-Za-z0-9-]+"))) } + internal val path get() = "/threads/${URLEncoder.encode(feedbackId, "UTF-8")}/$audience" +} +@Serializable public data class FeedbackThreadConversationMessage(val id: String, val seq: Long, val body: String, val authorRole: String, val mine: Boolean, val createdAt: String, val deletedAt: String? = null) +@Serializable public data class FeedbackThreadConversationThread(val id: String, val audience: String, val status: String) +@Serializable public data class FeedbackThreadConversationHistory(val thread: FeedbackThreadConversationThread, val messages: List, val unreadCount: Int, val following: Boolean, val hasMore: Boolean, val nextBefore: Long? = null, val otherReadSeq: Long? = null) +@Serializable public data class FeedbackThreadConversationSummary(val id: String, val feedbackId: String, val audience: String, val title: String, val preview: String, val status: String, val unreadCount: Int, val updatedAt: String) +@Serializable public data class FeedbackThreadConversationInbox(val conversations: List, val unreadCount: Int) +public data class FeedbackThreadConversationState(val inbox: List = emptyList(), val unreadCount: Int = 0, val publicCommentsEnabled: Boolean = false, val ready: Boolean = false, val revision: Long = 0, val route: FeedbackThreadConversationRoute? = null, val error: String? = null) + +/** Back with encrypted platform storage; never use plain preferences for guest tokens. */ +public interface FeedbackThreadConversationStore { public fun load(key: String): String?; public fun save(key: String, value: String); public fun remove(key: String) } +/** AES-GCM key stays in Android Keystore; ciphertext is excluded from device backup. */ +public class FeedbackThreadSecureConversationStore(context: Context) : FeedbackThreadConversationStore { + private val directory = File(context.noBackupFilesDir, "feedbackthread-conversations").also { it.mkdirs() } + private fun key(): SecretKey { + val store = KeyStore.getInstance("AndroidKeyStore").apply { load(null) } + (store.getKey("feedbackthread.conversations.v1", null) as? SecretKey)?.let { return it } + return KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore").apply { + init(KeyGenParameterSpec.Builder("feedbackthread.conversations.v1", KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT) + .setBlockModes(KeyProperties.BLOCK_MODE_GCM).setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE).build()) + }.generateKey() + } + private fun file(key: String) = File(directory, key) + @Synchronized override fun load(key: String): String? { + val file = file(key); if (!file.exists()) return null + val bytes = file.readBytes(); require(bytes.size >= 28) + val cipher = Cipher.getInstance("AES/GCM/NoPadding"); cipher.init(Cipher.DECRYPT_MODE, key(), GCMParameterSpec(128, bytes.copyOfRange(0,12))) + cipher.updateAAD(key.toByteArray()); return String(cipher.doFinal(bytes.copyOfRange(12,bytes.size)), Charsets.UTF_8) + } + @Synchronized override fun save(key: String, value: String) { + val cipher = Cipher.getInstance("AES/GCM/NoPadding"); cipher.init(Cipher.ENCRYPT_MODE, key()); cipher.updateAAD(key.toByteArray()) + val target = file(key); val temporary = File(directory, "$key.tmp") + temporary.outputStream().use { it.write(cipher.iv + cipher.doFinal(value.toByteArray())) } + check(temporary.renameTo(target)) { "Could not persist conversation credentials." } + } + @Synchronized override fun remove(key: String) { val target = file(key); check(!target.exists() || target.delete()) } +} + +/** Retain one instance per host account; accountScope is local isolation, not verified host login. */ +public class FeedbackThreadConversations( + private val configuration: FeedbackThreadConfiguration, + private val store: FeedbackThreadConversationStore, + accountScope: String = "guest", + private val connectionFactory: (URL) -> HttpURLConnection = { it.openConnection() as HttpURLConnection }, +) { + public constructor(context: Context, configuration: FeedbackThreadConfiguration, accountScope: String = "guest") : this(configuration, FeedbackThreadSecureConversationStore(context.applicationContext), accountScope) + private val json = Json { ignoreUnknownKeys = true; explicitNulls = false } + private val mutex = Mutex() + private val key = MessageDigest.getInstance("SHA-256").digest("${configuration.baseUrl.trimEnd('/')}:${configuration.projectKey}:$accountScope".toByteArray()).joinToString("") { "%02x".format(it) } + @Volatile private var revocationSession: FeedbackThreadCustomerSession? = null + @Volatile private var session: FeedbackThreadCustomerSession? = null + @Volatile private var closed = false + private var liveSocket: WebSocket? = null + private val refreshMutex = Mutex() + private val mutableState = MutableStateFlow(FeedbackThreadConversationState()) + public val state: StateFlow = mutableState.asStateFlow() + private val legacy = FeedbackThreadClient(configuration, connectionFactory) + public val client: FeedbackThreadClient = legacy.withConversationSession { + if (closed) throw FeedbackThreadException.InvalidConfiguration("This conversation session has signed out.") + session ?: throw FeedbackThreadException.InvalidConfiguration("Prepare conversations before using the client.") + } + public suspend fun prepare(): FeedbackThreadCustomerSession = mutex.withLock { + check(!closed) { "This conversation session has signed out." } + session?.let { return@withLock it } + val stored = withContext(Dispatchers.IO) { store.load(key) } + val value = if (stored == null) revocationSession ?: json.decodeFromString(request("/session", "POST", buildJsonObject {}, false)) else json.decodeFromString(stored) + require(value.token.matches(Regex("[a-f0-9-]{72}")) && value.externalUserId.startsWith("ft-guest:")) + revocationSession = value // Retain for logout/revocation even when preparation is cancelled. + check(!closed) + if (stored == null) withContext(Dispatchers.IO) { store.save(key, json.encodeToString(value)) } + session = value; value + } + public suspend fun refresh() = refreshMutex.withLock { + prepare() + val settings = json.decodeFromString(request("/settings")) + val inbox = json.decodeFromString(request("/inbox")) + if (!closed) mutableState.value = mutableState.value.copy(inbox=inbox.conversations, unreadCount=inbox.unreadCount, publicCommentsEnabled=settings.publicCommentsEnabled, ready=true, revision=mutableState.value.revision+1, error=null, + route=mutableState.value.route?.takeUnless { it.audience == "public" && !settings.publicCommentsEnabled }) + } + public suspend fun history(route: FeedbackThreadConversationRoute, before: Long? = null): FeedbackThreadConversationHistory { + prepare(); require(before == null || before > 0) + return json.decodeFromString(request(route.path + (before?.let { "?before=$it" } ?: ""))) + } + public suspend fun send(route: FeedbackThreadConversationRoute, body: String, clientId: String) { + require(body.trim().isNotEmpty() && body.length <= 4000 && clientId.isNotEmpty() && clientId.length <= 100) + prepare(); request(route.path+"/messages", "POST", buildJsonObject { put("body",body.trim()); put("clientId",clientId) }) + } + public suspend fun markRead(route: FeedbackThreadConversationRoute, seq: Long) { prepare(); request(route.path+"/read", "POST", buildJsonObject { put("seq",seq) }) } + public suspend fun follow(route: FeedbackThreadConversationRoute, following: Boolean) { prepare(); request(route.path+"/follow", "PUT", buildJsonObject { put("following",following) }) } + public suspend fun remove(route: FeedbackThreadConversationRoute, messageId: String) { prepare(); request(route.path+"/messages/"+URLEncoder.encode(messageId,"UTF-8"), "DELETE", buildJsonObject {}) } + public suspend fun registerDeviceToken(token: String) { prepare(); request("/device","PUT",buildJsonObject { put("provider","fcm"); put("token",token) }) } + public suspend fun unregisterDeviceToken(token: String) { prepare(); request("/device","DELETE",buildJsonObject { put("provider","fcm"); put("token",token) }) } + public fun open(route: FeedbackThreadConversationRoute?) { if (!closed) mutableState.value=mutableState.value.copy(route=route) } + /** Forward Firebase RemoteMessage.data or activity intent extras after a notification tap. */ + public fun handleNotification(data: Map): Boolean { + val route = runCatching { json.decodeFromString(data["feedbackThread"] ?: return false) }.getOrNull() ?: return false + if (!(state.value.ready && !state.value.publicCommentsEnabled && route.audience=="public")) open(route) + return true + } + public suspend fun logout() { + closed=true; liveSocket?.cancel(); mutableState.value=FeedbackThreadConversationState() + mutex.withLock { + val saved = revocationSession ?: session ?: withContext(Dispatchers.IO) { store.load(key) }?.let { json.decodeFromString(it) } + withContext(Dispatchers.IO) { store.remove(key) }; session=null; mutableState.value=FeedbackThreadConversationState() + if (saved != null) try { request("/session","DELETE",buildJsonObject {},false,saved.token) } + catch (error: FeedbackThreadException.Server) { if(error.statusCode != 401) throw error } + revocationSession=null + } + } + /** Call only while foregrounded. Cancellation closes the socket and timers. */ + public suspend fun runLive() = coroutineScope { + val http = OkHttpClient.Builder().pingInterval(20,java.util.concurrent.TimeUnit.SECONDS).build() + var failures=0 + try { + while (isActive && !closed) { + try { + refresh() + val ticket=json.parseToJsonElement(request("/live-ticket","POST",buildJsonObject {})).jsonObject["path"]!!.jsonPrimitive.content + require(ticket.matches(Regex("/v1/chat/live/[a-f0-9-]{72}"))) + val ended=CompletableDeferred() + val signals=kotlinx.coroutines.channels.Channel(kotlinx.coroutines.channels.Channel.CONFLATED) + val base=URI(configuration.baseUrl) + val url=URI(if(base.scheme=="https") "wss" else "ws",null,base.host,base.port,ticket,null,null).toString() + val socket=http.newWebSocket(Request.Builder().url(url).build(),object: WebSocketListener() { + override fun onOpen(webSocket: WebSocket,response: Response) { signals.trySend(Unit) } + override fun onMessage(webSocket: WebSocket,text: String) { if(text.contains("\"changed\"")) signals.trySend(Unit) } + override fun onFailure(webSocket: WebSocket,t: Throwable,response: Response?) { ended.complete(Unit) } + override fun onClosed(webSocket: WebSocket,code: Int,reason: String) { ended.complete(Unit) } + }); liveSocket=socket + val heartbeat=launch { while(isActive) { delay(20_000); socket.send("ping") } } + val updates=launch { for(signal in signals) { try { refresh() } catch(e: CancellationException) { throw e } catch(e: Exception) { mutableState.value=mutableState.value.copy(error="Could not refresh conversations.") } } } + try { ended.await(); failures=0 } finally { heartbeat.cancel(); updates.cancel(); signals.close(); socket.cancel() } + } catch(e: CancellationException) { throw e } catch(e: Exception) { if(!closed) mutableState.value=mutableState.value.copy(error="Could not connect to conversations.") } + delay(minOf(30_000L,1000L shl minOf(failures++,5))) + } + } finally { liveSocket?.cancel(); http.dispatcher.executorService.shutdown(); http.connectionPool.evictAll() } + } + private suspend fun request(path: String, method: String="GET", payload: JsonObject?=null, authenticated: Boolean=true, tokenOverride: String?=null): String = withContext(Dispatchers.IO) { + val base=URI(configuration.baseUrl) + require(base.userInfo==null && base.query==null && base.fragment==null && (base.path.isNullOrEmpty() || base.path=="/")) + require(base.scheme=="https" || (base.scheme=="http" && base.host in setOf("localhost","127.0.0.1","[::1]"))) + val token=tokenOverride ?: if(authenticated) { check(!closed); session?.token ?: error("Prepare conversations first.") } else null + val connection=connectionFactory(URL("${configuration.baseUrl.trimEnd('/')}/v1/projects/${URLEncoder.encode(configuration.projectKey,"UTF-8")}/chat$path")) + try { + connection.instanceFollowRedirects=false; connection.connectTimeout=configuration.connectTimeoutMillis; connection.readTimeout=configuration.readTimeoutMillis; connection.requestMethod=method + connection.setRequestProperty("Accept","application/json"); if(token!=null) connection.setRequestProperty("X-FeedbackThread-Customer",token) + if(payload!=null) { val bytes=payload.toString().toByteArray(); connection.setRequestProperty("Content-Type","application/json"); connection.doOutput=true; connection.setFixedLengthStreamingMode(bytes.size); connection.outputStream.use { it.write(bytes) } } + val status=connection.responseCode; val stream=if(status in 200..299) connection.inputStream else connection.errorStream + val bytes=stream?.use { input -> + val output = java.io.ByteArrayOutputStream(); val buffer = ByteArray(8192) + while (output.size() <= 1_048_576) { val count = input.read(buffer,0,minOf(buffer.size,1_048_577-output.size())); if(count<0) break; output.write(buffer,0,count) } + output.toByteArray() + } ?: byteArrayOf(); require(bytes.size<=1_048_576) { "Conversation response too large." }; val text=String(bytes,Charsets.UTF_8) + if(status !in 200..299) throw FeedbackThreadException.Server(status,"Conversation request failed ($status).") + if(authenticated) check(!closed) + text + } finally { connection.disconnect() } + } +} diff --git a/feedbackthread/src/main/java/com/feedbackthread/sdk/FeedbackThreadMyRequestsScreen.kt b/feedbackthread/src/main/java/com/feedbackthread/sdk/FeedbackThreadMyRequestsScreen.kt index 6257332..0b1b270 100644 --- a/feedbackthread/src/main/java/com/feedbackthread/sdk/FeedbackThreadMyRequestsScreen.kt +++ b/feedbackthread/src/main/java/com/feedbackthread/sdk/FeedbackThreadMyRequestsScreen.kt @@ -194,6 +194,10 @@ private fun MyRequestRow(request: FeedbackThreadMyRequest) { .padding(horizontal = 20.dp, vertical = 14.dp), verticalArrangement = Arrangement.spacedBy(6.dp), ) { + val conversations = LocalFeedbackThreadConversations.current + if (request.conversationAvailable && conversations != null) { + TextButton(onClick = { conversations.open(FeedbackThreadConversationRoute(request.id, "private")) }) { Text("Replies") } + } Text( text = request.title, style = MaterialTheme.typography.titleMedium, diff --git a/feedbackthread/src/test/java/com/feedbackthread/sdk/FeedbackThreadConversationsTest.kt b/feedbackthread/src/test/java/com/feedbackthread/sdk/FeedbackThreadConversationsTest.kt new file mode 100644 index 0000000..4800f80 --- /dev/null +++ b/feedbackthread/src/test/java/com/feedbackthread/sdk/FeedbackThreadConversationsTest.kt @@ -0,0 +1,66 @@ +package com.feedbackthread.sdk + +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.net.HttpURLConnection +import java.net.URL +import kotlinx.coroutines.* +import org.junit.Assert.* +import org.junit.Test + +class FeedbackThreadConversationsTest { + private val session = """{"customerId":"guest","externalUserId":"ft-guest:guest","token":"${"a".repeat(72)}"}""" + private val route = FeedbackThreadConversationRoute("FDBK-test01","private") + private class Store : FeedbackThreadConversationStore { + val values = mutableMapOf() + override fun load(key:String)=values[key] + override fun save(key:String,value:String) { values[key]=value } + override fun remove(key:String) { values.remove(key) } + } + private class Connection(url:URL, private val response:String):HttpURLConnection(url) { + val output=ByteArrayOutputStream() + override fun connect() {} + override fun disconnect() {} + override fun usingProxy()=false + override fun getOutputStream()=output + override fun getResponseCode()=200 + override fun getInputStream()=ByteArrayInputStream(response.toByteArray()) + } + @Test fun concurrentPreparationPersistsOneSession():Unit=runBlocking { + val store=Store(); var creates=0 + val factory:(URL)->HttpURLConnection={ url -> creates++; Connection(url,session) } + val config=FeedbackThreadConfiguration(projectKey="test") + val manager=FeedbackThreadConversations(config,store,connectionFactory=factory) + coroutineScope { List(5) { async { manager.prepare() } }.awaitAll() } + FeedbackThreadConversations(config,store,connectionFactory=factory).prepare() + assertEquals(1,creates); assertEquals(1,store.values.size) + FeedbackThreadConversations(config,store,"other-account",factory).prepare() + assertEquals(2,creates) + } + @Test fun historyUsesPrivateCredentialWithoutMarkingRead():Unit=runBlocking { + val paths=mutableListOf() + val manager=FeedbackThreadConversations(FeedbackThreadConfiguration(projectKey="test"),Store(),connectionFactory={url-> + paths.add(url.path) + Connection(url,if(url.path.endsWith("/session")) session else """{"thread":{"id":"thread","audience":"private","status":"waiting"},"messages":[],"unreadCount":1,"following":true,"hasMore":false}""") + }) + assertEquals(1,manager.history(route).unreadCount) + assertEquals(listOf("/v1/projects/test/chat/session","/v1/projects/test/chat/threads/FDBK-test01/private"),paths) + } + @Test fun logoutRevokesAndPermanentlyClosesManager():Unit=runBlocking { + val store=Store(); val connections=mutableListOf() + val manager=FeedbackThreadConversations(FeedbackThreadConfiguration(projectKey="test"),store,connectionFactory={url->Connection(url,session).also{connections.add(it)}}) + manager.prepare(); manager.logout() + assertEquals("DELETE",connections.last().requestMethod) + assertEquals("a".repeat(72),connections.last().getRequestProperty("X-FeedbackThread-Customer")) + assertTrue(store.values.isEmpty()) + try { manager.prepare(); fail("A logged-out manager must not create a new identity") } catch(_:IllegalStateException) {} + manager.open(route); assertNull(manager.state.value.route) + } + @Test fun explicitRetryKeepsTheSameId():Unit=runBlocking { + val messages=mutableListOf() + val manager=FeedbackThreadConversations(FeedbackThreadConfiguration(projectKey="test"),Store(),connectionFactory={url->Connection(url,if(url.path.endsWith("/session")) session else "{}").also { if(url.path.endsWith("/messages")) messages.add(it) }}) + manager.send(route,"Reply","stable-id"); manager.send(route,"Reply","stable-id") + assertEquals(messages.first().output.toString(),messages.last().output.toString()) + assertTrue(messages.first().output.toString().contains("stable-id")) + } +}