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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
74 changes: 68 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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.
4 changes: 3 additions & 1 deletion feedbackthread/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ plugins {
}

group = "com.feedbackthread"
version = "0.4.1"
version = "0.5.0"

android {
namespace = "com.feedbackthread.sdk"
Expand All @@ -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")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)

/**
Expand All @@ -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. */
Expand Down Expand Up @@ -321,6 +323,20 @@ public class FeedbackThreadClient private constructor(
public suspend fun acknowledgeUpdates(ids: List<String>, 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()

Expand All @@ -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,
Expand All @@ -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.")
},
Expand Down Expand Up @@ -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,
),
)
Expand All @@ -404,6 +424,7 @@ private class FeedbackThreadHTTPTransport(

try {
connection.requestMethod = "POST"
configureConnection(connection)
connection.connectTimeout = configuration.connectTimeoutMillis
connection.readTimeout = configuration.readTimeoutMillis
connection.doOutput = true
Expand Down Expand Up @@ -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")
Expand All @@ -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('/')
Expand Down
Loading
Loading