diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 3f4747f..f5e34fb 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -57,6 +57,10 @@ dependencies { implementation(project(":feature:notifications")) implementation(project(":feature:organizations")) implementation(project(":feature:integrations")) + // Depended on so its Hilt modules join the app component. The AI surfaces + // themselves live in the feature modules that use them, so there is no + // navigation entry here. + implementation(project(":feature:ai")) // Compose implementation(platform(libs.androidx.compose.bom)) diff --git a/feature/ai/build.gradle.kts b/feature/ai/build.gradle.kts new file mode 100644 index 0000000..a5edf4c --- /dev/null +++ b/feature/ai/build.gradle.kts @@ -0,0 +1,79 @@ +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.compose) + alias(libs.plugins.kotlin.serialization) + alias(libs.plugins.ksp) + alias(libs.plugins.hilt) +} + +android { + namespace = "com.interlinedlist.android.feature.ai" + compileSdk = 35 + + defaultConfig { + minSdk = 26 + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + buildFeatures { compose = true } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + kotlinOptions { jvmTarget = "17" } +} + +dependencies { + implementation(project(":core:model")) + implementation(project(":core:common")) + implementation(project(":core:designsystem")) + // The shared authed Retrofit, safeApiCall, and InterlinedListApi (used to read + // `customerStatus` when /api/ai/status omits its `subscriber` flag). + implementation(project(":core:network")) + + // The AI wire format is only partly modelled: artifacts and the `context` + // hint bag stay raw JsonObjects that the sibling AI surfaces decode, so the + // type leaks into this module's public API and has to be `api`. + api(libs.kotlinx.serialization.json) + + // This module has no Room cache: /suggest and /generate are live, one-shot + // calls and /status is a cheap read, so there is nothing worth persisting. + + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.compose.ui) + implementation(libs.androidx.compose.material3) + implementation(libs.androidx.compose.material.icons.extended) + implementation(libs.androidx.compose.ui.tooling.preview) + debugImplementation(libs.androidx.compose.ui.tooling) + implementation(libs.androidx.lifecycle.viewmodel.compose) + implementation(libs.androidx.lifecycle.runtime.compose) + + implementation(libs.hilt.android) + ksp(libs.hilt.compiler) + implementation(libs.androidx.hilt.navigation.compose) + + implementation(libs.retrofit.core) + implementation(libs.okhttp.core) + + // Unit tests + testImplementation(libs.junit) + testImplementation(libs.kotlinx.coroutines.test) + testImplementation(libs.turbine) + testImplementation(libs.truth) + // Repository tests hit a MockWebServer through the real Retrofit stack. + testImplementation(libs.okhttp.mockwebserver) + testImplementation(libs.retrofit.core) + testImplementation(libs.retrofit.kotlinx.serialization) + testImplementation(libs.okhttp.core) + testImplementation(libs.kotlinx.serialization.json) + + // Instrumented / UI tests + androidTestImplementation(libs.androidx.test.ext.junit) + androidTestImplementation(libs.androidx.test.runner) + androidTestImplementation(platform(libs.androidx.compose.bom)) + androidTestImplementation(libs.androidx.compose.ui.test.junit4) + androidTestImplementation(libs.truth) + debugImplementation(libs.androidx.compose.ui.test.manifest) +} diff --git a/feature/ai/src/main/kotlin/com/interlinedlist/android/feature/ai/data/AiApiCall.kt b/feature/ai/src/main/kotlin/com/interlinedlist/android/feature/ai/data/AiApiCall.kt new file mode 100644 index 0000000..43b9e83 --- /dev/null +++ b/feature/ai/src/main/kotlin/com/interlinedlist/android/feature/ai/data/AiApiCall.kt @@ -0,0 +1,67 @@ +package com.interlinedlist.android.feature.ai.data + +import com.interlinedlist.android.feature.ai.data.remote.dto.AiErrorCode +import com.interlinedlist.android.feature.ai.data.remote.dto.AiErrorDto +import com.interlinedlist.android.feature.ai.domain.AiError +import com.interlinedlist.android.feature.ai.domain.AiResult +import kotlinx.serialization.json.Json +import retrofit2.HttpException +import java.io.IOException + +/** + * Runs an AI call and normalises every failure into an [AiError]. + * + * The AI routes always answer `{ "error": …, "code": … }`, so the `code` is the + * primary key and the HTTP status is only the fallback for a route that omitted + * it. This is the one place a `/api/ai/…` failure is interpreted. + */ +internal suspend fun aiApiCall(json: Json, block: suspend () -> T): AiResult = try { + AiResult.Success(block()) +} catch (e: HttpException) { + AiResult.Failure(e.toAiError(json)) +} catch (e: IOException) { + AiResult.Failure(AiError.Network(e.message)) +} catch (e: Exception) { + AiResult.Failure(AiError.Unknown(e.message)) +} + +private fun HttpException.toAiError(json: Json): AiError { + val body = runCatching { response()?.errorBody()?.string() }.getOrNull() + val dto = body + ?.takeIf { it.isNotBlank() } + ?.let { runCatching { json.decodeFromString(AiErrorDto.serializer(), it) }.getOrNull() } + val message = dto?.error + val code = dto?.code + // Present on the 429 rate-limit response; absent on the daily-quota one. + val retryAfter = response()?.headers()?.get("Retry-After")?.trim()?.toIntOrNull() + + return when { + code == AiErrorCode.UNAUTHORIZED -> AiError.NotAuthenticated(message) + code == AiErrorCode.SUBSCRIPTION_REQUIRED -> AiError.NotSubscribed(message) + code == AiErrorCode.NO_PROVIDER_CONFIGURED -> AiError.ProviderUnconfigured(message) + code == AiErrorCode.QUOTA_EXCEEDED -> AiError.QuotaExceeded(message) + code == AiErrorCode.RATE_LIMITED -> AiError.RateLimited(message, retryAfter) + code == AiErrorCode.INVALID_INPUT -> AiError.InvalidInput(message) + code == AiErrorCode.INVALID_AI_OUTPUT || code == AiErrorCode.REFUSED -> + AiError.InvalidOutput(message) + code == AiErrorCode.PROVIDER_ERROR -> AiError.ProviderFailure(message) + // A restricted/suspended/probation account is forbidden for a reason a + // subscription would not fix, so it must not become an upsell. + code?.startsWith(AiErrorCode.ACCOUNT_PREFIX) == true -> AiError.Forbidden(message) + else -> fromStatus(code(), message, retryAfter) + } +} + +/** Fallback for a response that carried no `code`. */ +private fun fromStatus(status: Int, message: String?, retryAfter: Int?): AiError = when (status) { + 401 -> AiError.NotAuthenticated(message) + // The only 403 the AI routes document is the subscriber gate. + 403 -> AiError.NotSubscribed(message) + 409 -> AiError.ProviderUnconfigured(message) + 422 -> AiError.InvalidInput(message) + // Both 429s are code-tagged in practice; `Retry-After` is what separates the + // short-window limiter from the daily allowance when they are not. + 429 -> if (retryAfter != null) AiError.RateLimited(message, retryAfter) else AiError.QuotaExceeded(message) + in 500..599 -> AiError.ProviderFailure(message) + else -> AiError.Unknown(message ?: "HTTP $status") +} diff --git a/feature/ai/src/main/kotlin/com/interlinedlist/android/feature/ai/data/AiRepository.kt b/feature/ai/src/main/kotlin/com/interlinedlist/android/feature/ai/data/AiRepository.kt new file mode 100644 index 0000000..bb07a9b --- /dev/null +++ b/feature/ai/src/main/kotlin/com/interlinedlist/android/feature/ai/data/AiRepository.kt @@ -0,0 +1,46 @@ +package com.interlinedlist.android.feature.ai.data + +import com.interlinedlist.android.feature.ai.domain.AiAvailability +import com.interlinedlist.android.feature.ai.domain.AiFeature +import com.interlinedlist.android.feature.ai.domain.AiGenerateOptions +import com.interlinedlist.android.feature.ai.domain.AiGeneration +import com.interlinedlist.android.feature.ai.domain.AiPreview +import com.interlinedlist.android.feature.ai.domain.AiResult +import com.interlinedlist.android.feature.ai.domain.AiSuggestInput +import com.interlinedlist.android.feature.ai.domain.ConfirmedPreview + +/** + * The three `/api/ai/…` endpoints as one flow. Everything here is a live read or + * write — nothing about an AI action is worth caching, and a stale quota would + * be actively misleading. + * + * AI surfaces should not call [suggest]/[generate] directly unless they already + * know AI is enabled; `AiGate` is the gate for that. + */ +interface AiRepository { + + /** + * Reads `GET /api/ai/status` and resolves whether AI may be offered at all. + * Never fails: anything unreadable resolves to [AiAvailability.Unavailable] + * so the AI surfaces hide instead of erroring. + */ + suspend fun availability(): AiAvailability + + /** + * Runs [feature] against [input] and returns a **preview**. Writes nothing — + * the returned [AiPreview] must be confirmed by the user before [generate] + * can be reached. Counts against the daily quota. + */ + suspend fun suggest(feature: AiFeature, input: AiSuggestInput): AiResult + + /** + * Persists a preview the user confirmed. Takes a [ConfirmedPreview] rather + * than a feature + artifact so an unapproved suggestion cannot be written; + * the discriminator comes from the preview itself and cannot drift. + * Counts against the daily quota as a second action. + */ + suspend fun generate( + confirmed: ConfirmedPreview, + options: AiGenerateOptions = AiGenerateOptions(), + ): AiResult +} diff --git a/feature/ai/src/main/kotlin/com/interlinedlist/android/feature/ai/data/DefaultAiRepository.kt b/feature/ai/src/main/kotlin/com/interlinedlist/android/feature/ai/data/DefaultAiRepository.kt new file mode 100644 index 0000000..fa1c9c6 --- /dev/null +++ b/feature/ai/src/main/kotlin/com/interlinedlist/android/feature/ai/data/DefaultAiRepository.kt @@ -0,0 +1,83 @@ +package com.interlinedlist.android.feature.ai.data + +import com.interlinedlist.android.core.common.dispatcher.DispatcherProvider +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.core.network.api.InterlinedListApi +import com.interlinedlist.android.core.network.dto.toDomain +import com.interlinedlist.android.core.network.error.safeApiCall +import com.interlinedlist.android.feature.ai.data.mapper.toAvailability +import com.interlinedlist.android.feature.ai.data.mapper.toDomain +import com.interlinedlist.android.feature.ai.data.remote.AiApi +import com.interlinedlist.android.feature.ai.data.remote.dto.AiGenerateRequest +import com.interlinedlist.android.feature.ai.data.remote.dto.AiSuggestRequest +import com.interlinedlist.android.feature.ai.domain.AiAvailability +import com.interlinedlist.android.feature.ai.domain.AiFeature +import com.interlinedlist.android.feature.ai.domain.AiGenerateOptions +import com.interlinedlist.android.feature.ai.domain.AiGeneration +import com.interlinedlist.android.feature.ai.domain.AiPreview +import com.interlinedlist.android.feature.ai.domain.AiResult +import com.interlinedlist.android.feature.ai.domain.AiSuggestInput +import com.interlinedlist.android.feature.ai.domain.ConfirmedPreview +import com.interlinedlist.android.feature.ai.domain.map +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json +import javax.inject.Inject + +class DefaultAiRepository @Inject constructor( + private val api: AiApi, + /** Shared current-user endpoint, used only for the `customerStatus` fallback. */ + private val userApi: InterlinedListApi, + private val json: Json, + private val dispatchers: DispatcherProvider, +) : AiRepository { + + override suspend fun availability(): AiAvailability = withContext(dispatchers.io) { + when (val status = aiApiCall(json) { api.getStatus() }) { + // Status unreadable (offline, 401, unexpected body) — hide AI rather + // than offering a control whose action would fail. + is AiResult.Failure -> AiAvailability.Unavailable + is AiResult.Success -> + status.data.toAvailability(status.data.subscriber ?: currentUserIsSubscriber()) + } + } + + override suspend fun suggest( + feature: AiFeature, + input: AiSuggestInput, + ): AiResult = withContext(dispatchers.io) { + val request = AiSuggestRequest( + feature = feature.apiValue, + input = input.input, + context = input.context, + model = input.model, + maxOutputTokens = input.maxOutputTokens, + ) + aiApiCall(json) { api.suggest(request) }.map { it.toDomain(feature) } + } + + override suspend fun generate( + confirmed: ConfirmedPreview, + options: AiGenerateOptions, + ): AiResult = withContext(dispatchers.io) { + val request = AiGenerateRequest( + feature = confirmed.feature.apiValue, + // Sent back exactly as confirmed; the server re-validates it. + artifact = confirmed.artifact.payload, + model = options.model, + scheduleImmediately = options.scheduleImmediately, + crossPost = options.crossPost, + ) + aiApiCall(json) { api.generate(request) }.map { it.toDomain(confirmed.feature) } + } + + /** + * `/api/ai/status` reports `subscriber` itself; this covers a deployment that + * omits it, reusing the same `customerStatus` the rest of the app gates on. + * Null means the subscription could not be established at all. + */ + private suspend fun currentUserIsSubscriber(): Boolean? = + when (val result = safeApiCall(json) { userApi.getCurrentUser().user }) { + is ApiResult.Success -> result.data.toDomain().customerStatus.isSubscriber + is ApiResult.Failure -> null + } +} diff --git a/feature/ai/src/main/kotlin/com/interlinedlist/android/feature/ai/data/mapper/AiMappers.kt b/feature/ai/src/main/kotlin/com/interlinedlist/android/feature/ai/data/mapper/AiMappers.kt new file mode 100644 index 0000000..0323d76 --- /dev/null +++ b/feature/ai/src/main/kotlin/com/interlinedlist/android/feature/ai/data/mapper/AiMappers.kt @@ -0,0 +1,82 @@ +package com.interlinedlist.android.feature.ai.data.mapper + +import com.interlinedlist.android.feature.ai.data.remote.dto.AiCreatedDto +import com.interlinedlist.android.feature.ai.data.remote.dto.AiGenerateResponse +import com.interlinedlist.android.feature.ai.data.remote.dto.AiQuotaDto +import com.interlinedlist.android.feature.ai.data.remote.dto.AiStatusDto +import com.interlinedlist.android.feature.ai.data.remote.dto.AiSuggestResponse +import com.interlinedlist.android.feature.ai.data.remote.dto.AiUsageDto +import com.interlinedlist.android.feature.ai.domain.AiArtifact +import com.interlinedlist.android.feature.ai.domain.AiAvailability +import com.interlinedlist.android.feature.ai.domain.AiCreated +import com.interlinedlist.android.feature.ai.domain.AiFeature +import com.interlinedlist.android.feature.ai.domain.AiGeneration +import com.interlinedlist.android.feature.ai.domain.AiPreview +import com.interlinedlist.android.feature.ai.domain.AiQuota +import com.interlinedlist.android.feature.ai.domain.AiUsage +import kotlinx.serialization.json.JsonObject + +internal fun AiQuotaDto.toDomain(): AiQuota = AiQuota( + usedToday = usedToday, + dailyLimit = dailyLimit, + remaining = remaining, +) + +internal fun AiUsageDto.toDomain(): AiUsage = AiUsage( + inputTokens = inputTokens, + outputTokens = outputTokens, + model = model, +) + +/** + * Resolves `/api/ai/status` into the gate state. + * + * [subscriber] is the caller's resolved subscription flag — the body's own + * `subscriber` when it sent one, otherwise the account's `customerStatus`, and + * null when neither could be read. + * + * Precedence: an explicitly empty `providers` array means the deployment has no + * AI key at all and hides AI for everyone, subscriber or not. An *absent* + * `providers` field is not the same claim, so it does not hide anything. A + * subscription that cannot be confirmed hides AI too — a free account must + * never see an AI control. + */ +internal fun AiStatusDto.toAvailability(subscriber: Boolean?): AiAvailability = when { + providers?.isEmpty() == true -> AiAvailability.Unavailable + subscriber == null -> AiAvailability.Unavailable + !subscriber -> AiAvailability.NotSubscribed + else -> AiAvailability.Available(quota?.toDomain()) +} + +/** Builds the preview, defaulting the feature to the one that was requested. */ +internal fun AiSuggestResponse.toDomain(requested: AiFeature): AiPreview = AiPreview( + feature = AiFeature.fromApiValue(feature) ?: requested, + artifact = AiArtifact(artifact ?: JsonObject(emptyMap())), + usage = usage?.toDomain(), + quota = quota?.toDomain(), +) + +internal fun AiGenerateResponse.toDomain(requested: AiFeature): AiGeneration = AiGeneration( + feature = AiFeature.fromApiValue(feature) ?: requested, + created = created?.toDomain() ?: AiCreated.Unrecognised, + quota = quota?.toDomain(), +) + +/** + * Picks the one populated group. Scheduled posts and a document folder are + * checked first because those responses also carry the ids of what they wrap. + */ +internal fun AiCreatedDto.toDomain(): AiCreated = when { + !scheduledMessageIds.isNullOrEmpty() -> AiCreated.ScheduledMessagesCreated( + messageIds = scheduledMessageIds, + firstScheduledAt = firstScheduledAt, + lastScheduledAt = lastScheduledAt, + ) + folderId != null -> AiCreated.DocumentSeriesCreated( + folderId = folderId, + documentIds = documentIds.orEmpty(), + ) + listId != null -> AiCreated.ListCreated(listId) + documentId != null -> AiCreated.DocumentCreated(documentId) + else -> AiCreated.Unrecognised +} diff --git a/feature/ai/src/main/kotlin/com/interlinedlist/android/feature/ai/data/remote/AiApi.kt b/feature/ai/src/main/kotlin/com/interlinedlist/android/feature/ai/data/remote/AiApi.kt new file mode 100644 index 0000000..9b06978 --- /dev/null +++ b/feature/ai/src/main/kotlin/com/interlinedlist/android/feature/ai/data/remote/AiApi.kt @@ -0,0 +1,33 @@ +package com.interlinedlist.android.feature.ai.data.remote + +import com.interlinedlist.android.feature.ai.data.remote.dto.AiGenerateRequest +import com.interlinedlist.android.feature.ai.data.remote.dto.AiGenerateResponse +import com.interlinedlist.android.feature.ai.data.remote.dto.AiStatusDto +import com.interlinedlist.android.feature.ai.data.remote.dto.AiSuggestRequest +import com.interlinedlist.android.feature.ai.data.remote.dto.AiSuggestResponse +import retrofit2.http.Body +import retrofit2.http.GET +import retrofit2.http.POST + +/** + * Retrofit description of `/api/ai/…`, built from the shared authed Retrofit + * (base URL and `Authorization: Bearer …` are already applied). + * + * The three endpoints are one flow: [getStatus] decides whether AI is offered at + * all, [suggest] returns a preview and writes nothing, and [generate] persists a + * preview the user confirmed. + */ +interface AiApi { + + /** Configured providers, the caller's subscriber flag, and the daily quota. */ + @GET("api/ai/status") + suspend fun getStatus(): AiStatusDto + + /** Runs a feature and returns a validated preview artifact. Writes nothing. */ + @POST("api/ai/suggest") + suspend fun suggest(@Body request: AiSuggestRequest): AiSuggestResponse + + /** Persists a confirmed artifact and returns the created resource id(s). */ + @POST("api/ai/generate") + suspend fun generate(@Body request: AiGenerateRequest): AiGenerateResponse +} diff --git a/feature/ai/src/main/kotlin/com/interlinedlist/android/feature/ai/data/remote/dto/AiErrorDto.kt b/feature/ai/src/main/kotlin/com/interlinedlist/android/feature/ai/data/remote/dto/AiErrorDto.kt new file mode 100644 index 0000000..4118a56 --- /dev/null +++ b/feature/ai/src/main/kotlin/com/interlinedlist/android/feature/ai/data/remote/dto/AiErrorDto.kt @@ -0,0 +1,31 @@ +package com.interlinedlist.android.feature.ai.data.remote.dto + +import kotlinx.serialization.Serializable + +/** + * The AI routes' error envelope — `{ "error": "human message", "code": "machine_code" }`. + * The shared `ErrorDto` in `:core:network` drops `code`, and the AI surfaces have + * to tell `quota_exceeded` from `rate_limited` (both 429) and + * `no_provider_configured` from any other conflict, so this module reads it. + */ +@Serializable +data class AiErrorDto( + val error: String? = null, + val code: String? = null, +) + +/** The machine-readable codes documented for `/api/ai/…`. */ +internal object AiErrorCode { + const val UNAUTHORIZED = "unauthorized" + const val SUBSCRIPTION_REQUIRED = "subscription_required" + const val NO_PROVIDER_CONFIGURED = "no_provider_configured" + const val QUOTA_EXCEEDED = "quota_exceeded" + const val RATE_LIMITED = "rate_limited" + const val INVALID_INPUT = "invalid_input" + const val INVALID_AI_OUTPUT = "invalid_ai_output" + const val REFUSED = "refused" + const val PROVIDER_ERROR = "provider_error" + + /** Account-status codes (`account_restricted`, `account_suspended`, …) share this prefix. */ + const val ACCOUNT_PREFIX = "account_" +} diff --git a/feature/ai/src/main/kotlin/com/interlinedlist/android/feature/ai/data/remote/dto/AiGenerateDtos.kt b/feature/ai/src/main/kotlin/com/interlinedlist/android/feature/ai/data/remote/dto/AiGenerateDtos.kt new file mode 100644 index 0000000..2f6be49 --- /dev/null +++ b/feature/ai/src/main/kotlin/com/interlinedlist/android/feature/ai/data/remote/dto/AiGenerateDtos.kt @@ -0,0 +1,47 @@ +package com.interlinedlist.android.feature.ai.data.remote.dto + +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonObject + +/** + * `POST /api/ai/generate` body: the same `feature` discriminator plus the + * confirmed artifact from `/suggest`. `scheduleImmediately` and `crossPost` are + * honoured for `message_series` only; `model` is recorded for auditing. + */ +@Serializable +data class AiGenerateRequest( + val feature: String, + val artifact: JsonObject, + val model: String? = null, + val scheduleImmediately: Boolean? = null, + val crossPost: JsonObject? = null, +) + +/** + * `201 Created` from `/generate`: + * `{ "ok": true, "feature": "powered_document", "created": { … }, "quota": { … } }`. + */ +@Serializable +data class AiGenerateResponse( + val ok: Boolean? = null, + val feature: String? = null, + val created: AiCreatedDto? = null, + val quota: AiQuotaDto? = null, +) + +/** + * The union of the four documented `created` shapes: `{ listId }`, + * `{ documentId }`, `{ folderId, documentIds }`, and + * `{ scheduledMessageIds, firstScheduledAt, lastScheduledAt }`. Only one group + * is populated per response. + */ +@Serializable +data class AiCreatedDto( + val listId: String? = null, + val documentId: String? = null, + val folderId: String? = null, + val documentIds: List? = null, + val scheduledMessageIds: List? = null, + val firstScheduledAt: String? = null, + val lastScheduledAt: String? = null, +) diff --git a/feature/ai/src/main/kotlin/com/interlinedlist/android/feature/ai/data/remote/dto/AiStatusDto.kt b/feature/ai/src/main/kotlin/com/interlinedlist/android/feature/ai/data/remote/dto/AiStatusDto.kt new file mode 100644 index 0000000..eb143b7 --- /dev/null +++ b/feature/ai/src/main/kotlin/com/interlinedlist/android/feature/ai/data/remote/dto/AiStatusDto.kt @@ -0,0 +1,31 @@ +package com.interlinedlist.android.feature.ai.data.remote.dto + +import kotlinx.serialization.Serializable + +/** + * `GET /api/ai/status`: + * ``` + * { "subscriber": true, + * "providers": ["anthropic"], + * "defaultModels": { "anthropic": "claude-sonnet-5" }, + * "quota": { "usedToday": 8, "dailyLimit": 50, "remaining": 42 } } + * ``` + * Bare object, no envelope. Every field is nullable so an older or trimmed + * deployment cannot fail the read; the distinction between "absent" and + * "empty list" matters for [providers] and is preserved. + */ +@Serializable +data class AiStatusDto( + val subscriber: Boolean? = null, + val providers: List? = null, + val defaultModels: Map? = null, + val quota: AiQuotaDto? = null, +) + +/** The `quota` object, reported by `/status`, `/suggest` and `/generate` alike. */ +@Serializable +data class AiQuotaDto( + val usedToday: Int? = null, + val dailyLimit: Int? = null, + val remaining: Int? = null, +) diff --git a/feature/ai/src/main/kotlin/com/interlinedlist/android/feature/ai/data/remote/dto/AiSuggestDtos.kt b/feature/ai/src/main/kotlin/com/interlinedlist/android/feature/ai/data/remote/dto/AiSuggestDtos.kt new file mode 100644 index 0000000..893ea48 --- /dev/null +++ b/feature/ai/src/main/kotlin/com/interlinedlist/android/feature/ai/data/remote/dto/AiSuggestDtos.kt @@ -0,0 +1,44 @@ +package com.interlinedlist.android.feature.ai.data.remote.dto + +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonObject + +/** + * `POST /api/ai/suggest` body. `feature` and `input` are required; the rest are + * omitted when null (the shared Json is configured with `explicitNulls = false`). + */ +@Serializable +data class AiSuggestRequest( + val feature: String, + val input: String, + val context: JsonObject? = null, + val model: String? = null, + val maxOutputTokens: Int? = null, +) + +/** + * `200 OK` from `/suggest`: + * ``` + * { "ok": true, "feature": "writing_assist", + * "artifact": { "kind": "message", "content": "…" }, + * "usage": { "inputTokens": 412, "outputTokens": 96, "model": "claude-sonnet-5" }, + * "quota": { "usedToday": 7, "dailyLimit": 50 } } + * ``` + * The artifact stays a raw [JsonObject]: its shape depends on the feature and it + * has to be handed back to `/generate` byte-for-byte. + */ +@Serializable +data class AiSuggestResponse( + val ok: Boolean? = null, + val feature: String? = null, + val artifact: JsonObject? = null, + val usage: AiUsageDto? = null, + val quota: AiQuotaDto? = null, +) + +@Serializable +data class AiUsageDto( + val inputTokens: Int? = null, + val outputTokens: Int? = null, + val model: String? = null, +) diff --git a/feature/ai/src/main/kotlin/com/interlinedlist/android/feature/ai/di/AiModule.kt b/feature/ai/src/main/kotlin/com/interlinedlist/android/feature/ai/di/AiModule.kt new file mode 100644 index 0000000..44163e3 --- /dev/null +++ b/feature/ai/src/main/kotlin/com/interlinedlist/android/feature/ai/di/AiModule.kt @@ -0,0 +1,32 @@ +package com.interlinedlist.android.feature.ai.di + +import com.interlinedlist.android.feature.ai.data.AiRepository +import com.interlinedlist.android.feature.ai.data.DefaultAiRepository +import com.interlinedlist.android.feature.ai.data.remote.AiApi +import dagger.Binds +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import retrofit2.Retrofit +import javax.inject.Singleton + +/** Binds the repository interface to its default implementation. */ +@Module +@InstallIn(SingletonComponent::class) +abstract class AiRepositoryModule { + + @Binds + @Singleton + abstract fun bindAiRepository(impl: DefaultAiRepository): AiRepository +} + +/** Provides this feature's API off the shared authed Retrofit. */ +@Module +@InstallIn(SingletonComponent::class) +object AiDataModule { + + @Provides + @Singleton + fun provideAiApi(retrofit: Retrofit): AiApi = retrofit.create(AiApi::class.java) +} diff --git a/feature/ai/src/main/kotlin/com/interlinedlist/android/feature/ai/domain/AiArtifact.kt b/feature/ai/src/main/kotlin/com/interlinedlist/android/feature/ai/domain/AiArtifact.kt new file mode 100644 index 0000000..4870203 --- /dev/null +++ b/feature/ai/src/main/kotlin/com/interlinedlist/android/feature/ai/domain/AiArtifact.kt @@ -0,0 +1,26 @@ +package com.interlinedlist.android.feature.ai.domain + +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.contentOrNull + +/** + * The typed envelope the API calls an "artifact": the content an AI action + * produced. `/suggest` returns one and `/generate` takes the same one back, so + * it is held as the raw [payload] object — the bytes round-trip untouched + * (including any key this client does not know about), and each AI surface + * decodes the shape it asked for. + */ +data class AiArtifact(val payload: JsonObject) { + + /** `list`, `document`, `message_series`, `doc_series`, `message`, `thread`, or `tags`. */ + val kind: String? + get() = (payload["kind"] as? JsonPrimitive)?.contentOrNull +} + +/** Token accounting returned alongside a preview. Purely informational. */ +data class AiUsage( + val inputTokens: Int? = null, + val outputTokens: Int? = null, + val model: String? = null, +) diff --git a/feature/ai/src/main/kotlin/com/interlinedlist/android/feature/ai/domain/AiAvailability.kt b/feature/ai/src/main/kotlin/com/interlinedlist/android/feature/ai/domain/AiAvailability.kt new file mode 100644 index 0000000..2222a21 --- /dev/null +++ b/feature/ai/src/main/kotlin/com/interlinedlist/android/feature/ai/domain/AiAvailability.kt @@ -0,0 +1,37 @@ +package com.interlinedlist.android.feature.ai.domain + +/** + * Whether the AI affordances may be shown at all. Resolved from + * `GET /api/ai/status` (see `AiRepository.availability`) and observed through + * [AiGate]. + * + * Only [Available] permits an AI control to be drawn: a free account, an + * account whose subscription could not be confirmed, and a deployment with no + * AI provider configured all hide the AI surfaces entirely rather than offering + * a control that would fail. + */ +sealed interface AiAvailability { + + /** Status has not been read yet. Treated as "hide" until [AiGate.refresh] resolves it. */ + data object Unknown : AiAvailability + + /** + * AI cannot be offered: the deployment has no provider configured, or the + * status/subscription could not be read at all. + */ + data object Unavailable : AiAvailability + + /** The provider is configured but this account is not a subscriber. */ + data object NotSubscribed : AiAvailability + + /** AI may be used. [quota] is null when the server did not report one. */ + data class Available(val quota: AiQuota? = null) : AiAvailability + + /** The single check an AI surface makes before drawing anything. */ + val isEnabled: Boolean + get() = this is Available + + /** True when AI is enabled but today's 50-action allowance is spent. */ + val isQuotaExhausted: Boolean + get() = this is Available && quota?.isExhausted == true +} diff --git a/feature/ai/src/main/kotlin/com/interlinedlist/android/feature/ai/domain/AiError.kt b/feature/ai/src/main/kotlin/com/interlinedlist/android/feature/ai/domain/AiError.kt new file mode 100644 index 0000000..52079a0 --- /dev/null +++ b/feature/ai/src/main/kotlin/com/interlinedlist/android/feature/ai/domain/AiError.kt @@ -0,0 +1,68 @@ +package com.interlinedlist.android.feature.ai.domain + +/** + * A failure from an AI endpoint, keyed off the `code` in the API's + * `{ "error": "…", "code": "…" }` envelope and falling back to the HTTP status + * when the route did not send one. + * + * The AI routes distinguish cases the shared `AppError` cannot express — a + * deployment with no provider key (409 `no_provider_configured`) versus the + * daily quota being spent (429 `quota_exceeded`) versus the short-window rate + * limit (429 `rate_limited`) — and the UI has to say something different for + * each, so this module carries its own error type. + */ +sealed interface AiError { + + val message: String? + + /** 401 `unauthorized` — no valid session or bearer token. */ + data class NotAuthenticated(override val message: String? = null) : AiError + + /** 403 `subscription_required` — authenticated, but not a subscriber. */ + data class NotSubscribed(override val message: String? = null) : AiError + + /** 403 for a reason other than the subscription (restricted/suspended/probation account). */ + data class Forbidden(override val message: String? = null) : AiError + + /** 409 `no_provider_configured` — server-side misconfiguration; hide the AI surfaces. */ + data class ProviderUnconfigured(override val message: String? = null) : AiError + + /** 429 `quota_exceeded` — the 50-per-day allowance is spent. */ + data class QuotaExceeded(override val message: String? = null) : AiError + + /** 429 `rate_limited` — 15 requests/60s tripped; honour [retryAfterSeconds]. */ + data class RateLimited( + override val message: String? = null, + val retryAfterSeconds: Int? = null, + ) : AiError + + /** 422 `invalid_input` — empty/over-length input, under the 10-word series gate, bad reference. */ + data class InvalidInput(override val message: String? = null) : AiError + + /** 422 `invalid_ai_output` / `refused` — the model's output failed validation. */ + data class InvalidOutput(override val message: String? = null) : AiError + + /** 500 / 502 `provider_error` — upstream failure or timeout. */ + data class ProviderFailure(override val message: String? = null) : AiError + + /** No usable connection. */ + data class Network(override val message: String? = null) : AiError + + /** Anything not recognised. */ + data class Unknown(override val message: String? = null) : AiError +} + +/** + * Outcome of an AI call. Mirrors `ApiResult` from `:core:common` but carries the + * AI-specific [AiError] instead of the shared `AppError`. + */ +sealed interface AiResult { + data class Success(val data: T) : AiResult + data class Failure(val error: AiError) : AiResult +} + +/** Transforms the success value, propagating failures unchanged. */ +inline fun AiResult.map(transform: (T) -> R): AiResult = when (this) { + is AiResult.Success -> AiResult.Success(transform(data)) + is AiResult.Failure -> this +} diff --git a/feature/ai/src/main/kotlin/com/interlinedlist/android/feature/ai/domain/AiFeature.kt b/feature/ai/src/main/kotlin/com/interlinedlist/android/feature/ai/domain/AiFeature.kt new file mode 100644 index 0000000..b34d6c0 --- /dev/null +++ b/feature/ai/src/main/kotlin/com/interlinedlist/android/feature/ai/domain/AiFeature.kt @@ -0,0 +1,35 @@ +package com.interlinedlist.android.feature.ai.domain + +/** + * The `feature` discriminator both `POST /api/ai/suggest` and + * `POST /api/ai/generate` require in their body. This is the single place the + * wire strings live; an AI surface names its case here instead of spelling the + * string out at the call site. + * + * Each feature produces its own artifact kind (see [AiArtifact]): + * `writing_assist` → `message`/`thread`/`tags` (never persistable), + * `powered_template` → `list`, `powered_document` → `document`, + * `message_series` → `message_series`, `article_series` → `doc_series`. + */ +enum class AiFeature(val apiValue: String) { + /** Rewrite/tighten/expand/grammar/thread/tag a composer draft. */ + WRITING_ASSIST("writing_assist"), + + /** Generate a list (schema + starter rows) from a description. */ + POWERED_TEMPLATE("powered_template"), + + /** Draft a single markdown document (article / from list / from doc / URL). */ + POWERED_DOCUMENT("powered_document"), + + /** Plan a series of short, connected messages. */ + MESSAGE_SERIES("message_series"), + + /** Plan and write a coherent series of documents. */ + ARTICLE_SERIES("article_series"); + + companion object { + /** Maps a wire string (or null) back to a feature, or null when unknown. */ + fun fromApiValue(value: String?): AiFeature? = + entries.firstOrNull { it.apiValue == value } + } +} diff --git a/feature/ai/src/main/kotlin/com/interlinedlist/android/feature/ai/domain/AiGate.kt b/feature/ai/src/main/kotlin/com/interlinedlist/android/feature/ai/domain/AiGate.kt new file mode 100644 index 0000000..1bfe179 --- /dev/null +++ b/feature/ai/src/main/kotlin/com/interlinedlist/android/feature/ai/domain/AiGate.kt @@ -0,0 +1,69 @@ +package com.interlinedlist.android.feature.ai.domain + +import com.interlinedlist.android.feature.ai.data.AiRepository +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import javax.inject.Inject +import javax.inject.Singleton + +/** + * The one observable every AI surface consults before drawing anything. Composer + * toolbars, the Powered Templates tab and the Powered Document button all + * collect [availability] and render nothing unless it [AiAvailability.isEnabled]. + * + * Application-scoped so the five surfaces share a single `/api/ai/status` read + * and a single quota figure. + */ +@Singleton +class AiGate @Inject constructor( + private val repository: AiRepository, +) { + + private val _availability = MutableStateFlow(AiAvailability.Unknown) + + /** Starts at [AiAvailability.Unknown], which hides the AI surfaces too. */ + val availability: StateFlow = _availability.asStateFlow() + + /** Re-reads `/api/ai/status`. */ + suspend fun refresh(): AiAvailability = + repository.availability().also { _availability.value = it } + + /** Reads status once; a no-op after the first successful resolution. */ + suspend fun ensureResolved(): AiAvailability = + if (_availability.value is AiAvailability.Unknown) refresh() else _availability.value + + /** + * Folds the quota echoed by `/suggest` and `/generate` back into the gate, so + * every surface sees the same remaining allowance without re-reading status. + */ + fun recordQuota(quota: AiQuota?) { + val current = _availability.value + if (quota != null && current is AiAvailability.Available) { + _availability.value = AiAvailability.Available(quota) + } + } + + /** + * Applies what a failed AI action revealed about availability: an + * unconfigured provider or an expired/absent subscription takes the AI + * surfaces down immediately instead of leaving a control that keeps failing. + * Every other error is transient and leaves the gate alone. + */ + fun recordFailure(error: AiError) { + when (error) { + is AiError.ProviderUnconfigured -> _availability.value = AiAvailability.Unavailable + is AiError.NotSubscribed -> _availability.value = AiAvailability.NotSubscribed + is AiError.QuotaExceeded -> { + val current = _availability.value + if (current is AiAvailability.Available) { + // Keep whatever limit we knew; the remainder is now zero. + _availability.value = AiAvailability.Available( + (current.quota ?: AiQuota()).copy(remaining = 0), + ) + } + } + else -> Unit + } + } +} diff --git a/feature/ai/src/main/kotlin/com/interlinedlist/android/feature/ai/domain/AiGeneration.kt b/feature/ai/src/main/kotlin/com/interlinedlist/android/feature/ai/domain/AiGeneration.kt new file mode 100644 index 0000000..18fdf0e --- /dev/null +++ b/feature/ai/src/main/kotlin/com/interlinedlist/android/feature/ai/domain/AiGeneration.kt @@ -0,0 +1,39 @@ +package com.interlinedlist.android.feature.ai.domain + +/** What `POST /api/ai/generate` persisted, plus the quota left afterwards. */ +data class AiGeneration( + val feature: AiFeature?, + val created: AiCreated, + val quota: AiQuota? = null, +) + +/** + * The `created` object, which takes one of four documented shapes depending on + * the artifact that was persisted. [Unrecognised] covers a body this client + * does not know how to read rather than failing the call — the write already + * happened server-side. + */ +sealed interface AiCreated { + + /** `{ "listId": "…" }` — a `list` or a `message_series` persisted as a list. */ + data class ListCreated(val listId: String) : AiCreated + + /** `{ "documentId": "…" }` — a single document. */ + data class DocumentCreated(val documentId: String) : AiCreated + + /** `{ "folderId": "…", "documentIds": [...] }` — a `doc_series`. */ + data class DocumentSeriesCreated( + val folderId: String, + val documentIds: List, + ) : AiCreated + + /** `{ "scheduledMessageIds": [...], … }` — a series sent with `scheduleImmediately`. */ + data class ScheduledMessagesCreated( + val messageIds: List, + val firstScheduledAt: String? = null, + val lastScheduledAt: String? = null, + ) : AiCreated + + /** The server reported no id this client recognises. */ + data object Unrecognised : AiCreated +} diff --git a/feature/ai/src/main/kotlin/com/interlinedlist/android/feature/ai/domain/AiPreview.kt b/feature/ai/src/main/kotlin/com/interlinedlist/android/feature/ai/domain/AiPreview.kt new file mode 100644 index 0000000..eaa9720 --- /dev/null +++ b/feature/ai/src/main/kotlin/com/interlinedlist/android/feature/ai/domain/AiPreview.kt @@ -0,0 +1,64 @@ +package com.interlinedlist.android.feature.ai.domain + +import kotlinx.serialization.json.JsonObject + +/** + * The input half of `POST /api/ai/suggest`. [context] is the endpoint's + * feature-specific hint bag (`action`, `mode`, `listId`, `count`, …) — unknown + * keys are ignored server-side and numeric hints are clamped, so each AI + * surface builds the object it needs. + */ +data class AiSuggestInput( + val input: String, + val context: JsonObject? = null, + /** Optional Anthropic model override; the server picks a default otherwise. */ + val model: String? = null, + /** Requested output budget. Clamped down to the feature's ceiling, never up. */ + val maxOutputTokens: Int? = null, +) + +/** + * A preview produced by `/suggest`. **Nothing has been written.** The only way + * to reach `/generate` is [confirm], which is what makes "nothing is saved + * until the user approves it" a compile-time property rather than a convention + * each AI surface has to remember. + */ +data class AiPreview( + val feature: AiFeature, + val artifact: AiArtifact, + val usage: AiUsage? = null, + val quota: AiQuota? = null, +) { + /** + * Marks this preview as approved by the user, optionally replacing the + * artifact with an [edited] copy of it (the server re-validates either way). + */ + fun confirm(edited: AiArtifact = artifact): ConfirmedPreview = + ConfirmedPreview(preview = this, artifact = edited) +} + +/** + * An [AiPreview] the user has explicitly approved — the only thing + * `AiRepository.generate` accepts. Its constructor is module-private, so no + * caller outside this module can fabricate one without going through + * [AiPreview.confirm]. + */ +class ConfirmedPreview internal constructor( + val preview: AiPreview, + /** The artifact as confirmed — the preview's own, or the user's edit of it. */ + val artifact: AiArtifact, +) { + /** The feature the artifact was produced for; sent back as the discriminator. */ + val feature: AiFeature get() = preview.feature +} + +/** + * Optional extras `POST /api/ai/generate` accepts. All of them are ignorable: + * [scheduleImmediately] and [crossPost] only mean anything for + * [AiFeature.MESSAGE_SERIES], and [model] is recorded in the audit ledger only. + */ +data class AiGenerateOptions( + val scheduleImmediately: Boolean? = null, + val crossPost: JsonObject? = null, + val model: String? = null, +) diff --git a/feature/ai/src/main/kotlin/com/interlinedlist/android/feature/ai/domain/AiQuota.kt b/feature/ai/src/main/kotlin/com/interlinedlist/android/feature/ai/domain/AiQuota.kt new file mode 100644 index 0000000..d2557f9 --- /dev/null +++ b/feature/ai/src/main/kotlin/com/interlinedlist/android/feature/ai/domain/AiQuota.kt @@ -0,0 +1,24 @@ +package com.interlinedlist.android.feature.ai.domain + +/** + * The daily AI allowance reported by `/api/ai/status` (and echoed by `/suggest` + * and `/generate`): 50 actions per rolling 24 hours per account, where a preview + * and its confirmation each count as one. + * + * Every field is nullable because the server may omit the `quota` object or any + * of its members; [remainingActions] reconciles the two ways the remainder can + * be known. + */ +data class AiQuota( + val usedToday: Int? = null, + val dailyLimit: Int? = null, + val remaining: Int? = null, +) { + /** `remaining` when the server sends it, else `dailyLimit - usedToday`. */ + val remainingActions: Int? + get() = remaining ?: dailyLimit?.let { limit -> usedToday?.let { used -> limit - used } } + + /** True only when the remainder is known to be spent — unknown never blocks the UI. */ + val isExhausted: Boolean + get() = remainingActions?.let { it <= 0 } == true +} diff --git a/feature/ai/src/main/kotlin/com/interlinedlist/android/feature/ai/ui/AiErrorMessages.kt b/feature/ai/src/main/kotlin/com/interlinedlist/android/feature/ai/ui/AiErrorMessages.kt new file mode 100644 index 0000000..ffb1018 --- /dev/null +++ b/feature/ai/src/main/kotlin/com/interlinedlist/android/feature/ai/ui/AiErrorMessages.kt @@ -0,0 +1,23 @@ +package com.interlinedlist.android.feature.ai.ui + +import com.interlinedlist.android.feature.ai.domain.AiError + +/** + * The one place an [AiError] becomes something a person can read, so all five AI + * surfaces word the daily-limit and provider cases identically. + */ +fun AiError.toUserMessage(): String = when (this) { + is AiError.Network -> "No connection. Check your network and try again." + is AiError.NotAuthenticated -> message ?: "Please sign in again." + is AiError.NotSubscribed -> message ?: "AI writing assistance requires an active subscription." + is AiError.Forbidden -> message ?: "Your account can't use this right now." + is AiError.ProviderUnconfigured -> "AI writing assistance is unavailable right now." + is AiError.QuotaExceeded -> "Daily AI limit reached. Try again tomorrow." + is AiError.RateLimited -> retryAfterSeconds + ?.let { "Too many AI requests. Try again in $it seconds." } + ?: "Too many AI requests. Please wait a moment and try again." + is AiError.InvalidInput -> message ?: "That input can't be used. Try rewording it." + is AiError.InvalidOutput -> "The AI response couldn't be used. Try again." + is AiError.ProviderFailure -> "The AI service is having trouble right now. Try again shortly." + is AiError.Unknown -> message ?: "Something went wrong. Please try again." +} diff --git a/feature/ai/src/main/kotlin/com/interlinedlist/android/feature/ai/ui/AiPreviewSession.kt b/feature/ai/src/main/kotlin/com/interlinedlist/android/feature/ai/ui/AiPreviewSession.kt new file mode 100644 index 0000000..6de4950 --- /dev/null +++ b/feature/ai/src/main/kotlin/com/interlinedlist/android/feature/ai/ui/AiPreviewSession.kt @@ -0,0 +1,94 @@ +package com.interlinedlist.android.feature.ai.ui + +import com.interlinedlist.android.feature.ai.data.AiRepository +import com.interlinedlist.android.feature.ai.domain.AiArtifact +import com.interlinedlist.android.feature.ai.domain.AiFeature +import com.interlinedlist.android.feature.ai.domain.AiGate +import com.interlinedlist.android.feature.ai.domain.AiGenerateOptions +import com.interlinedlist.android.feature.ai.domain.AiResult +import com.interlinedlist.android.feature.ai.domain.AiSuggestInput +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +/** + * Drives one AI action through preview → confirm, so the five AI surfaces share + * the rule rather than each re-implementing it. A view model owns one of these + * per surface and passes its own `viewModelScope`: + * + * ``` + * private val ai = AiPreviewSession(repository, gate, viewModelScope) + * ``` + * + * [confirm] is the only path to `/generate`, and it only works from + * [AiPreviewState.Previewing] — there is no way to write straight from a + * suggestion, and no second write while one is in flight. + */ +class AiPreviewSession( + private val repository: AiRepository, + private val gate: AiGate, + private val scope: CoroutineScope, +) { + + private val _state = MutableStateFlow(AiPreviewState.Idle) + val state: StateFlow = _state.asStateFlow() + + /** + * Runs `/suggest` for [feature]. Ignored while another action is in flight, + * returning false so the caller knows nothing started. + */ + fun suggest(feature: AiFeature, input: AiSuggestInput): Boolean { + if (_state.value.isBusy) return false + _state.value = AiPreviewState.Suggesting + scope.launch { + when (val result = repository.suggest(feature, input)) { + is AiResult.Success -> { + gate.recordQuota(result.data.quota) + _state.value = AiPreviewState.Previewing(result.data) + } + is AiResult.Failure -> { + gate.recordFailure(result.error) + _state.value = AiPreviewState.Failed(result.error) + } + } + } + return true + } + + /** + * Approves the preview currently on screen and persists it, optionally + * replacing the artifact with the user's [edited] version. + * + * Returns false — and calls nothing — unless a preview is actually being + * shown, which is what stops a write without approval. + */ + fun confirm( + edited: AiArtifact? = null, + options: AiGenerateOptions = AiGenerateOptions(), + ): Boolean { + val previewing = _state.value as? AiPreviewState.Previewing ?: return false + val preview = previewing.preview + val confirmed = preview.confirm(edited ?: preview.artifact) + _state.value = AiPreviewState.Generating(preview) + scope.launch { + when (val result = repository.generate(confirmed, options)) { + is AiResult.Success -> { + gate.recordQuota(result.data.quota) + _state.value = AiPreviewState.Generated(result.data) + } + is AiResult.Failure -> { + gate.recordFailure(result.error) + _state.value = AiPreviewState.Failed(result.error, preview) + } + } + } + return true + } + + /** Discards the preview without writing anything, and clears any error. */ + fun discard() { + if (!_state.value.isBusy) _state.value = AiPreviewState.Idle + } +} diff --git a/feature/ai/src/main/kotlin/com/interlinedlist/android/feature/ai/ui/AiPreviewState.kt b/feature/ai/src/main/kotlin/com/interlinedlist/android/feature/ai/ui/AiPreviewState.kt new file mode 100644 index 0000000..8acd061 --- /dev/null +++ b/feature/ai/src/main/kotlin/com/interlinedlist/android/feature/ai/ui/AiPreviewState.kt @@ -0,0 +1,35 @@ +package com.interlinedlist.android.feature.ai.ui + +import com.interlinedlist.android.feature.ai.domain.AiError +import com.interlinedlist.android.feature.ai.domain.AiGeneration +import com.interlinedlist.android.feature.ai.domain.AiPreview + +/** + * The preview → confirm contract every AI surface renders. Its shape is the + * guarantee the help centre makes: an action can only reach [Generating] by way + * of [Previewing], so nothing is written until the user approves what they see. + */ +sealed interface AiPreviewState { + + /** No AI action in flight; nothing to show. */ + data object Idle : AiPreviewState + + /** `/suggest` is running. */ + data object Suggesting : AiPreviewState + + /** A preview is on screen awaiting approval. **Nothing has been written.** */ + data class Previewing(val preview: AiPreview) : AiPreviewState + + /** The user approved; `/generate` is running. */ + data class Generating(val preview: AiPreview) : AiPreviewState + + /** The artifact was persisted. [AiGeneration.created] carries the new id(s). */ + data class Generated(val generation: AiGeneration) : AiPreviewState + + /** The action failed. [preview] is kept when one was already on screen. */ + data class Failed(val error: AiError, val preview: AiPreview? = null) : AiPreviewState + + /** True while either leg of the flow is in flight. */ + val isBusy: Boolean + get() = this is Suggesting || this is Generating +} diff --git a/feature/ai/src/test/kotlin/com/interlinedlist/android/feature/ai/data/AiAvailabilityTest.kt b/feature/ai/src/test/kotlin/com/interlinedlist/android/feature/ai/data/AiAvailabilityTest.kt new file mode 100644 index 0000000..f0a5732 --- /dev/null +++ b/feature/ai/src/test/kotlin/com/interlinedlist/android/feature/ai/data/AiAvailabilityTest.kt @@ -0,0 +1,156 @@ +package com.interlinedlist.android.feature.ai.data + +import com.google.common.truth.Truth.assertThat +import com.interlinedlist.android.feature.ai.domain.AiAvailability +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.runTest +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import okhttp3.mockwebserver.SocketPolicy +import org.junit.After +import org.junit.Before +import org.junit.Test + +/** + * `GET /api/ai/status` → [AiAvailability], every branch: provider configured or + * not, subscriber or not, and the `customerStatus` fallback for a body that + * omits the `subscriber` flag. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class AiAvailabilityTest { + + private lateinit var server: MockWebServer + private val dispatcher = StandardTestDispatcher() + + @Before + fun setUp() { + server = MockWebServer().also { it.start() } + } + + @After + fun tearDown() = server.shutdown() + + private fun repository() = repositoryFor(server, dispatcher) + + @Test + fun `subscriber with a configured provider is available, with quota`() = runTest(dispatcher) { + server.enqueue( + jsonResponse( + 200, + """ + { + "subscriber": true, + "providers": ["anthropic"], + "defaultModels": { "anthropic": "claude-sonnet-5" }, + "quota": { "usedToday": 8, "dailyLimit": 50, "remaining": 42 } + } + """.trimIndent(), + ), + ) + + val availability = repository().availability() + + assertThat(server.takeRequest().path).isEqualTo("/api/ai/status") + assertThat(availability).isInstanceOf(AiAvailability.Available::class.java) + val quota = (availability as AiAvailability.Available).quota + assertThat(quota?.remainingActions).isEqualTo(42) + assertThat(availability.isEnabled).isTrue() + assertThat(availability.isQuotaExhausted).isFalse() + } + + @Test + fun `an unconfigured provider hides AI even for a subscriber`() = runTest(dispatcher) { + server.enqueue( + jsonResponse(200, """{ "subscriber": true, "providers": [], "quota": null }"""), + ) + + assertThat(repository().availability()).isEqualTo(AiAvailability.Unavailable) + } + + @Test + fun `a free account is NotSubscribed`() = runTest(dispatcher) { + server.enqueue( + jsonResponse(200, """{ "subscriber": false, "providers": ["anthropic"] }"""), + ) + + val availability = repository().availability() + + assertThat(availability).isEqualTo(AiAvailability.NotSubscribed) + assertThat(availability.isEnabled).isFalse() + } + + @Test + fun `a spent allowance stays available but reports the quota as exhausted`() = runTest(dispatcher) { + server.enqueue( + jsonResponse( + 200, + """ + { "subscriber": true, "providers": ["anthropic"], + "quota": { "usedToday": 50, "dailyLimit": 50, "remaining": 0 } } + """.trimIndent(), + ), + ) + + val availability = repository().availability() + + assertThat(availability.isEnabled).isTrue() + assertThat(availability.isQuotaExhausted).isTrue() + } + + @Test + fun `a body without the subscriber flag falls back to customerStatus`() = runTest(dispatcher) { + server.enqueue(jsonResponse(200, """{ "providers": ["anthropic"] }""")) + server.enqueue( + jsonResponse( + 200, + """{ "user": { "id": "u1", "username": "adron", "customerStatus": "subscriber:annual" } }""", + ), + ) + + val availability = repository().availability() + + assertThat(server.takeRequest().path).isEqualTo("/api/ai/status") + assertThat(server.takeRequest().path).isEqualTo("/api/user") + assertThat(availability).isInstanceOf(AiAvailability.Available::class.java) + } + + @Test + fun `the customerStatus fallback keeps a free account out`() = runTest(dispatcher) { + server.enqueue(jsonResponse(200, """{ "providers": ["anthropic"] }""")) + server.enqueue( + jsonResponse(200, """{ "user": { "id": "u1", "username": "adron", "customerStatus": "free" } }"""), + ) + + assertThat(repository().availability()).isEqualTo(AiAvailability.NotSubscribed) + } + + @Test + fun `an unverifiable subscription hides AI rather than guessing`() = runTest(dispatcher) { + server.enqueue(jsonResponse(200, """{ "providers": ["anthropic"] }""")) + server.enqueue(jsonResponse(401, """{ "error": "Unauthorized", "code": "unauthorized" }""")) + + assertThat(repository().availability()).isEqualTo(AiAvailability.Unavailable) + } + + @Test + fun `an absent providers field is not treated as unconfigured`() = runTest(dispatcher) { + server.enqueue(jsonResponse(200, """{ "subscriber": true }""")) + + assertThat(repository().availability()).isEqualTo(AiAvailability.Available(null)) + } + + @Test + fun `an unauthenticated status read hides AI`() = runTest(dispatcher) { + server.enqueue(jsonResponse(401, """{ "error": "Unauthorized", "code": "unauthorized" }""")) + + assertThat(repository().availability()).isEqualTo(AiAvailability.Unavailable) + } + + @Test + fun `a network failure hides AI`() = runTest(dispatcher) { + server.enqueue(MockResponse().setSocketPolicy(SocketPolicy.DISCONNECT_AT_START)) + + assertThat(repository().availability()).isEqualTo(AiAvailability.Unavailable) + } +} diff --git a/feature/ai/src/test/kotlin/com/interlinedlist/android/feature/ai/data/AiErrorMappingTest.kt b/feature/ai/src/test/kotlin/com/interlinedlist/android/feature/ai/data/AiErrorMappingTest.kt new file mode 100644 index 0000000..757ed67 --- /dev/null +++ b/feature/ai/src/test/kotlin/com/interlinedlist/android/feature/ai/data/AiErrorMappingTest.kt @@ -0,0 +1,140 @@ +package com.interlinedlist.android.feature.ai.data + +import com.google.common.truth.Truth.assertThat +import com.interlinedlist.android.feature.ai.domain.AiError +import com.interlinedlist.android.feature.ai.domain.AiFeature +import com.interlinedlist.android.feature.ai.domain.AiResult +import com.interlinedlist.android.feature.ai.domain.AiSuggestInput +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.runTest +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import okhttp3.mockwebserver.SocketPolicy +import org.junit.After +import org.junit.Before +import org.junit.Test + +/** + * Failure mapping for `/suggest` and `/generate`. The AI routes answer + * `{ error, code }`, and the code is what separates the two 429s and the + * unconfigured-provider 409 from anything else. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class AiErrorMappingTest { + + private lateinit var server: MockWebServer + private val dispatcher = StandardTestDispatcher() + + @Before + fun setUp() { + server = MockWebServer().also { it.start() } + } + + @After + fun tearDown() = server.shutdown() + + private suspend fun suggestError(response: MockResponse): AiError { + server.enqueue(response) + val result = repositoryFor(server, dispatcher) + .suggest(AiFeature.WRITING_ASSIST, AiSuggestInput("draft")) + return (result as AiResult.Failure).error + } + + @Test + fun `403 subscription_required is the subscriber gate`() = runTest(dispatcher) { + val error = suggestError( + jsonResponse(403, """{ "error": "This feature requires an active subscription.", "code": "subscription_required" }"""), + ) + + assertThat(error).isInstanceOf(AiError.NotSubscribed::class.java) + assertThat(error.message).isEqualTo("This feature requires an active subscription.") + } + + @Test + fun `409 no_provider_configured is a site misconfiguration, not a user error`() = runTest(dispatcher) { + val error = suggestError( + jsonResponse(409, """{ "error": "AI is not configured", "code": "no_provider_configured" }"""), + ) + + assertThat(error).isInstanceOf(AiError.ProviderUnconfigured::class.java) + } + + @Test + fun `429 quota_exceeded is the daily allowance`() = runTest(dispatcher) { + val error = suggestError( + jsonResponse(429, """{ "error": "Daily AI limit reached", "code": "quota_exceeded" }"""), + ) + + assertThat(error).isInstanceOf(AiError.QuotaExceeded::class.java) + } + + @Test + fun `429 rate_limited keeps Retry-After and stays distinct from the quota`() = runTest(dispatcher) { + val error = suggestError( + jsonResponse(429, """{ "error": "Too many requests", "code": "rate_limited" }""") + .setHeader("Retry-After", "37"), + ) + + assertThat(error).isEqualTo(AiError.RateLimited("Too many requests", 37)) + } + + @Test + fun `an uncoded 429 falls back on Retry-After to tell the two apart`() = runTest(dispatcher) { + assertThat(suggestError(jsonResponse(429, """{ "error": "Too many requests" }"""))) + .isInstanceOf(AiError.QuotaExceeded::class.java) + assertThat( + suggestError( + jsonResponse(429, """{ "error": "Too many requests" }""").setHeader("Retry-After", "5"), + ), + ).isEqualTo(AiError.RateLimited("Too many requests", 5)) + } + + @Test + fun `422 separates bad input from unusable model output`() = runTest(dispatcher) { + assertThat(suggestError(jsonResponse(422, """{ "error": "Input too short", "code": "invalid_input" }"""))) + .isInstanceOf(AiError.InvalidInput::class.java) + assertThat(suggestError(jsonResponse(422, """{ "error": "Bad output", "code": "invalid_ai_output" }"""))) + .isInstanceOf(AiError.InvalidOutput::class.java) + assertThat(suggestError(jsonResponse(422, """{ "error": "Refused", "code": "refused" }"""))) + .isInstanceOf(AiError.InvalidOutput::class.java) + // No code at all: the status alone still says "your request". + assertThat(suggestError(jsonResponse(422, """{ "error": "Nope" }"""))) + .isInstanceOf(AiError.InvalidInput::class.java) + } + + @Test + fun `provider_error covers both the 502 and the 500`() = runTest(dispatcher) { + assertThat(suggestError(jsonResponse(502, """{ "error": "Upstream timeout", "code": "provider_error" }"""))) + .isInstanceOf(AiError.ProviderFailure::class.java) + assertThat(suggestError(jsonResponse(500, """{ "error": "Server error" }"""))) + .isInstanceOf(AiError.ProviderFailure::class.java) + } + + @Test + fun `an account-status 403 is not an upsell`() = runTest(dispatcher) { + val error = suggestError( + jsonResponse(403, """{ "error": "Your account is restricted.", "code": "account_restricted" }"""), + ) + + assertThat(error).isInstanceOf(AiError.Forbidden::class.java) + } + + @Test + fun `401 is a sign-in problem`() = runTest(dispatcher) { + assertThat(suggestError(jsonResponse(401, """{ "error": "Unauthorized", "code": "unauthorized" }"""))) + .isInstanceOf(AiError.NotAuthenticated::class.java) + } + + @Test + fun `a dropped connection is a network error`() = runTest(dispatcher) { + assertThat(suggestError(MockResponse().setSocketPolicy(SocketPolicy.DISCONNECT_AT_START))) + .isInstanceOf(AiError.Network::class.java) + } + + @Test + fun `an unparseable error body still maps by status`() = runTest(dispatcher) { + assertThat(suggestError(jsonResponse(409, "gateway"))) + .isInstanceOf(AiError.ProviderUnconfigured::class.java) + } +} diff --git a/feature/ai/src/test/kotlin/com/interlinedlist/android/feature/ai/data/AiRepositoryTestFixtures.kt b/feature/ai/src/test/kotlin/com/interlinedlist/android/feature/ai/data/AiRepositoryTestFixtures.kt new file mode 100644 index 0000000..e79d377 --- /dev/null +++ b/feature/ai/src/test/kotlin/com/interlinedlist/android/feature/ai/data/AiRepositoryTestFixtures.kt @@ -0,0 +1,63 @@ +package com.interlinedlist.android.feature.ai.data + +import com.interlinedlist.android.core.common.dispatcher.DispatcherProvider +import com.interlinedlist.android.core.network.api.InterlinedListApi +import com.interlinedlist.android.feature.ai.data.remote.AiApi +import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.serialization.json.Json +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import retrofit2.Retrofit + +/** The same Json configuration `NetworkModule` installs in the app. */ +internal fun testJson(): Json = Json { + ignoreUnknownKeys = true + explicitNulls = false + coerceInputValues = true +} + +internal fun testDispatchers(dispatcher: CoroutineDispatcher): DispatcherProvider = + object : DispatcherProvider { + override val io: CoroutineDispatcher get() = dispatcher + override val default: CoroutineDispatcher get() = dispatcher + override val main: CoroutineDispatcher get() = dispatcher + } + +/** + * Builds the repository over a real Retrofit/OkHttp stack pointed at [server], + * exactly as Hilt wires it (one Retrofit for both the AI and the shared + * current-user API). + */ +internal fun repositoryFor( + server: MockWebServer, + dispatcher: CoroutineDispatcher, + json: Json = testJson(), +): DefaultAiRepository { + val retrofit = Retrofit.Builder() + .baseUrl(server.url("/")) + .addConverterFactory(json.asConverterFactory("application/json".toMediaType())) + .build() + return DefaultAiRepository( + api = retrofit.create(AiApi::class.java), + userApi = retrofit.create(InterlinedListApi::class.java), + json = json, + dispatchers = testDispatchers(dispatcher), + ) +} + +internal fun jsonResponse(code: Int, body: String): MockResponse = MockResponse() + .setResponseCode(code) + .setHeader("Content-Type", "application/json") + .setBody(body) + +/** A preview as `/suggest` would have returned it, for tests that only exercise `/generate`. */ +internal fun previewOf( + feature: com.interlinedlist.android.feature.ai.domain.AiFeature, + payload: kotlinx.serialization.json.JsonObject, +): com.interlinedlist.android.feature.ai.domain.AiPreview = + com.interlinedlist.android.feature.ai.domain.AiPreview( + feature = feature, + artifact = com.interlinedlist.android.feature.ai.domain.AiArtifact(payload), + ) diff --git a/feature/ai/src/test/kotlin/com/interlinedlist/android/feature/ai/data/AiSuggestAndGenerateTest.kt b/feature/ai/src/test/kotlin/com/interlinedlist/android/feature/ai/data/AiSuggestAndGenerateTest.kt new file mode 100644 index 0000000..96d6495 --- /dev/null +++ b/feature/ai/src/test/kotlin/com/interlinedlist/android/feature/ai/data/AiSuggestAndGenerateTest.kt @@ -0,0 +1,225 @@ +package com.interlinedlist.android.feature.ai.data + +import com.google.common.truth.Truth.assertThat +import com.interlinedlist.android.feature.ai.domain.AiArtifact +import com.interlinedlist.android.feature.ai.domain.AiCreated +import com.interlinedlist.android.feature.ai.domain.AiFeature +import com.interlinedlist.android.feature.ai.domain.AiGenerateOptions +import com.interlinedlist.android.feature.ai.domain.AiResult +import com.interlinedlist.android.feature.ai.domain.AiSuggestInput +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import okhttp3.mockwebserver.MockWebServer +import org.junit.After +import org.junit.Before +import org.junit.Test + +/** + * The write half of the flow: that `/suggest` and `/generate` carry the right + * `feature` discriminator and body, that a preview parses, and that the four + * `created` shapes map. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class AiSuggestAndGenerateTest { + + private lateinit var server: MockWebServer + private val dispatcher = StandardTestDispatcher() + private val json = Json { ignoreUnknownKeys = true } + + @Before + fun setUp() { + server = MockWebServer().also { it.start() } + } + + @After + fun tearDown() = server.shutdown() + + private fun repository() = repositoryFor(server, dispatcher) + + private fun bodyOf(request: okhttp3.mockwebserver.RecordedRequest): JsonObject = + json.decodeFromString(JsonObject.serializer(), request.body.readUtf8()) + + @Test + fun `suggest posts the feature discriminator, input and context`() = runTest(dispatcher) { + server.enqueue( + jsonResponse( + 200, + """ + { "ok": true, "feature": "writing_assist", + "artifact": { "kind": "message", "content": "Rewritten draft" }, + "usage": { "inputTokens": 412, "outputTokens": 96, "model": "claude-sonnet-5" }, + "quota": { "usedToday": 7, "dailyLimit": 50 } } + """.trimIndent(), + ), + ) + + val result = repository().suggest( + feature = AiFeature.WRITING_ASSIST, + input = AiSuggestInput( + input = "we shipped the new thing today", + context = buildJsonObject { + put("action", "rewrite") + put("targetPlatform", "mastodon") + }, + ), + ) + + val request = server.takeRequest() + assertThat(request.path).isEqualTo("/api/ai/suggest") + assertThat(request.method).isEqualTo("POST") + val body = bodyOf(request) + assertThat(body["feature"].toString()).isEqualTo("\"writing_assist\"") + assertThat(body["input"].toString()).isEqualTo("\"we shipped the new thing today\"") + assertThat(body["context"]?.toString()).contains("rewrite") + + val preview = (result as AiResult.Success).data + assertThat(preview.feature).isEqualTo(AiFeature.WRITING_ASSIST) + assertThat(preview.artifact.kind).isEqualTo("message") + assertThat(preview.usage?.model).isEqualTo("claude-sonnet-5") + // usedToday/dailyLimit with no explicit remainder still yields one. + assertThat(preview.quota?.remainingActions).isEqualTo(43) + } + + @Test + fun `every feature sends its own discriminator`() = runTest(dispatcher) { + AiFeature.entries.forEach { feature -> + server.enqueue(jsonResponse(200, """{ "ok": true, "artifact": { "kind": "list" } }""")) + + val result = repository().suggest(feature, AiSuggestInput("ten words of input for the series gate here")) + + val body = bodyOf(server.takeRequest()) + assertThat(body["feature"].toString()).isEqualTo("\"${feature.apiValue}\"") + // The response omitted `feature`; the requested one is kept. + assertThat((result as AiResult.Success).data.feature).isEqualTo(feature) + } + } + + @Test + fun `generate sends the confirmed artifact back verbatim under the same feature`() = runTest(dispatcher) { + server.enqueue( + jsonResponse( + 200, + """{ "ok": true, "artifact": { "kind": "document", "title": "Draft", "markdown": "# Draft" } }""", + ), + ) + server.enqueue( + jsonResponse( + 201, + """ + { "ok": true, "feature": "powered_document", + "created": { "documentId": "doc_1" }, + "quota": { "usedToday": 9, "dailyLimit": 50, "remaining": 41 } } + """.trimIndent(), + ), + ) + val repository = repository() + + val preview = (repository.suggest( + AiFeature.POWERED_DOCUMENT, + AiSuggestInput("draft something about widgets"), + ) as AiResult.Success).data + server.takeRequest() + + val result = repository.generate(preview.confirm()) + + val request = server.takeRequest() + assertThat(request.path).isEqualTo("/api/ai/generate") + val body = bodyOf(request) + assertThat(body["feature"].toString()).isEqualTo("\"powered_document\"") + assertThat(body["artifact"]).isEqualTo(preview.artifact.payload) + + val generation = (result as AiResult.Success).data + assertThat(generation.created).isEqualTo(AiCreated.DocumentCreated("doc_1")) + assertThat(generation.quota?.remainingActions).isEqualTo(41) + } + + @Test + fun `generate sends the user's edit instead of the suggestion when given one`() = runTest(dispatcher) { + server.enqueue(jsonResponse(201, """{ "ok": true, "created": { "listId": "list_1" } }""")) + val preview = previewOf(AiFeature.POWERED_TEMPLATE, buildJsonObject { put("kind", "list"); put("title", "Model's title") }) + val edited = AiArtifact(buildJsonObject { put("kind", "list"); put("title", "My title") }) + + val result = repository().generate(preview.confirm(edited)) + + val body = bodyOf(server.takeRequest()) + assertThat(body["artifact"]).isEqualTo(edited.payload) + assertThat((result as AiResult.Success).data.created).isEqualTo(AiCreated.ListCreated("list_1")) + } + + @Test + fun `generate options ride along only when set`() = runTest(dispatcher) { + server.enqueue(jsonResponse(201, """{ "ok": true, "created": { "listId": "list_1" } }""")) + val preview = previewOf(AiFeature.MESSAGE_SERIES, buildJsonObject { put("kind", "message_series") }) + + repository().generate( + preview.confirm(), + AiGenerateOptions( + scheduleImmediately = true, + crossPost = buildJsonObject { put("crossPostToBluesky", true) }, + ), + ) + + val body = bodyOf(server.takeRequest()) + assertThat(body["scheduleImmediately"].toString()).isEqualTo("true") + assertThat(body["crossPost"]?.toString()).contains("crossPostToBluesky") + // Unset optionals are omitted rather than sent as null. + assertThat(body.containsKey("model")).isFalse() + } + + @Test + fun `a scheduled message series maps to its own created shape`() = runTest(dispatcher) { + server.enqueue( + jsonResponse( + 201, + """ + { "ok": true, "created": { "scheduledMessageIds": ["m1", "m2"], + "firstScheduledAt": "2026-09-16T10:00:00.000Z", + "lastScheduledAt": "2026-09-16T10:08:00.000Z" } } + """.trimIndent(), + ), + ) + val preview = previewOf(AiFeature.MESSAGE_SERIES, buildJsonObject { put("kind", "message_series") }) + + val result = repository().generate(preview.confirm(), AiGenerateOptions(scheduleImmediately = true)) + + assertThat((result as AiResult.Success).data.created).isEqualTo( + AiCreated.ScheduledMessagesCreated( + messageIds = listOf("m1", "m2"), + firstScheduledAt = "2026-09-16T10:00:00.000Z", + lastScheduledAt = "2026-09-16T10:08:00.000Z", + ), + ) + } + + @Test + fun `an article series maps to the folder shape`() = runTest(dispatcher) { + server.enqueue( + jsonResponse( + 201, + """{ "ok": true, "created": { "folderId": "f1", "documentIds": ["d1", "d2"] } }""", + ), + ) + val preview = previewOf(AiFeature.ARTICLE_SERIES, buildJsonObject { put("kind", "doc_series") }) + + val result = repository().generate(preview.confirm()) + + assertThat((result as AiResult.Success).data.created).isEqualTo( + AiCreated.DocumentSeriesCreated("f1", listOf("d1", "d2")), + ) + } + + @Test + fun `an unrecognised created body is not a failure`() = runTest(dispatcher) { + server.enqueue(jsonResponse(201, """{ "ok": true, "created": { "somethingNew": "x" } }""")) + val preview = previewOf(AiFeature.POWERED_DOCUMENT, buildJsonObject { put("kind", "document") }) + + val result = repository().generate(preview.confirm()) + + assertThat((result as AiResult.Success).data.created).isEqualTo(AiCreated.Unrecognised) + } +} diff --git a/feature/ai/src/test/kotlin/com/interlinedlist/android/feature/ai/domain/AiFeatureTest.kt b/feature/ai/src/test/kotlin/com/interlinedlist/android/feature/ai/domain/AiFeatureTest.kt new file mode 100644 index 0000000..fe88831 --- /dev/null +++ b/feature/ai/src/test/kotlin/com/interlinedlist/android/feature/ai/domain/AiFeatureTest.kt @@ -0,0 +1,33 @@ +package com.interlinedlist.android.feature.ai.domain + +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +/** The `feature` discriminator is the one string both write endpoints key on. */ +class AiFeatureTest { + + @Test + fun `wire values match the API's five features`() { + assertThat(AiFeature.entries.map { it.apiValue }) + .containsExactly( + "writing_assist", + "powered_template", + "powered_document", + "message_series", + "article_series", + ) + } + + @Test + fun `round-trips through its wire value`() { + AiFeature.entries.forEach { feature -> + assertThat(AiFeature.fromApiValue(feature.apiValue)).isEqualTo(feature) + } + } + + @Test + fun `an unknown or missing value is not guessed at`() { + assertThat(AiFeature.fromApiValue("something_new")).isNull() + assertThat(AiFeature.fromApiValue(null)).isNull() + } +} diff --git a/feature/ai/src/test/kotlin/com/interlinedlist/android/feature/ai/domain/AiGateTest.kt b/feature/ai/src/test/kotlin/com/interlinedlist/android/feature/ai/domain/AiGateTest.kt new file mode 100644 index 0000000..d3dcc43 --- /dev/null +++ b/feature/ai/src/test/kotlin/com/interlinedlist/android/feature/ai/domain/AiGateTest.kt @@ -0,0 +1,98 @@ +package com.interlinedlist.android.feature.ai.domain + +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.Test + +/** The gate every AI surface collects: it must stay off unless AI is truly usable. */ +@OptIn(ExperimentalCoroutinesApi::class) +class AiGateTest { + + private val repository = FakeAiRepository() + private val gate = AiGate(repository) + + @Test + fun `AI is hidden until status has been read`() { + assertThat(gate.availability.value).isEqualTo(AiAvailability.Unknown) + assertThat(gate.availability.value.isEnabled).isFalse() + } + + @Test + fun `refresh turns the surfaces on for a subscriber`() = runTest { + repository.availability = AiAvailability.Available(AiQuota(8, 50, 42)) + + gate.availability.test { + assertThat(awaitItem()).isEqualTo(AiAvailability.Unknown) + gate.refresh() + assertThat(awaitItem().isEnabled).isTrue() + } + } + + @Test + fun `refresh flips the surfaces off for a free account`() = runTest { + repository.availability = AiAvailability.NotSubscribed + + gate.refresh() + + assertThat(gate.availability.value).isEqualTo(AiAvailability.NotSubscribed) + assertThat(gate.availability.value.isEnabled).isFalse() + } + + @Test + fun `ensureResolved reads status once`() = runTest { + gate.ensureResolved() + gate.ensureResolved() + + assertThat(repository.availabilityCalls).isEqualTo(1) + } + + @Test + fun `a quota echoed by an action refreshes the gate`() = runTest { + gate.refresh() + + gate.recordQuota(AiQuota(49, 50, 1)) + + assertThat(gate.availability.value).isEqualTo(AiAvailability.Available(AiQuota(49, 50, 1))) + } + + @Test + fun `an unconfigured provider discovered mid-action takes the surfaces down`() = runTest { + gate.refresh() + + gate.recordFailure(AiError.ProviderUnconfigured()) + + assertThat(gate.availability.value).isEqualTo(AiAvailability.Unavailable) + } + + @Test + fun `a subscription that lapsed mid-session takes the surfaces down`() = runTest { + gate.refresh() + + gate.recordFailure(AiError.NotSubscribed()) + + assertThat(gate.availability.value).isEqualTo(AiAvailability.NotSubscribed) + } + + @Test + fun `a quota_exceeded failure marks the allowance spent without hiding AI`() = runTest { + repository.availability = AiAvailability.Available(AiQuota(50, 50, 3)) + gate.refresh() + + gate.recordFailure(AiError.QuotaExceeded()) + + assertThat(gate.availability.value.isEnabled).isTrue() + assertThat(gate.availability.value.isQuotaExhausted).isTrue() + } + + @Test + fun `a transient failure leaves the gate alone`() = runTest { + gate.refresh() + val before = gate.availability.value + + gate.recordFailure(AiError.Network("offline")) + + assertThat(gate.availability.value).isEqualTo(before) + } +} diff --git a/feature/ai/src/test/kotlin/com/interlinedlist/android/feature/ai/domain/AiQuotaTest.kt b/feature/ai/src/test/kotlin/com/interlinedlist/android/feature/ai/domain/AiQuotaTest.kt new file mode 100644 index 0000000..ce9dd9f --- /dev/null +++ b/feature/ai/src/test/kotlin/com/interlinedlist/android/feature/ai/domain/AiQuotaTest.kt @@ -0,0 +1,29 @@ +package com.interlinedlist.android.feature.ai.domain + +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +class AiQuotaTest { + + @Test + fun `remaining is derived when the server only reports usage`() { + assertThat(AiQuota(usedToday = 7, dailyLimit = 50).remainingActions).isEqualTo(43) + } + + @Test + fun `an explicit remaining wins`() { + assertThat(AiQuota(usedToday = 7, dailyLimit = 50, remaining = 41).remainingActions) + .isEqualTo(41) + } + + @Test + fun `a spent allowance is exhausted`() { + assertThat(AiQuota(usedToday = 50, dailyLimit = 50).isExhausted).isTrue() + } + + @Test + fun `an unknown allowance never blocks the UI`() { + assertThat(AiQuota().remainingActions).isNull() + assertThat(AiQuota().isExhausted).isFalse() + } +} diff --git a/feature/ai/src/test/kotlin/com/interlinedlist/android/feature/ai/domain/FakeAiRepository.kt b/feature/ai/src/test/kotlin/com/interlinedlist/android/feature/ai/domain/FakeAiRepository.kt new file mode 100644 index 0000000..b9585c6 --- /dev/null +++ b/feature/ai/src/test/kotlin/com/interlinedlist/android/feature/ai/domain/FakeAiRepository.kt @@ -0,0 +1,34 @@ +package com.interlinedlist.android.feature.ai.domain + +import com.interlinedlist.android.feature.ai.data.AiRepository + +/** Records what the AI surfaces asked for and replays canned outcomes. */ +internal class FakeAiRepository : AiRepository { + + var availability: AiAvailability = AiAvailability.Available(AiQuota(0, 50, 50)) + var suggestResult: AiResult = AiResult.Failure(AiError.Unknown("not stubbed")) + var generateResult: AiResult = + AiResult.Success(AiGeneration(null, AiCreated.Unrecognised)) + + var availabilityCalls = 0 + var suggestCalls = mutableListOf>() + var generateCalls = mutableListOf() + + override suspend fun availability(): AiAvailability { + availabilityCalls++ + return availability + } + + override suspend fun suggest(feature: AiFeature, input: AiSuggestInput): AiResult { + suggestCalls += feature to input + return suggestResult + } + + override suspend fun generate( + confirmed: ConfirmedPreview, + options: AiGenerateOptions, + ): AiResult { + generateCalls += confirmed + return generateResult + } +} diff --git a/feature/ai/src/test/kotlin/com/interlinedlist/android/feature/ai/ui/AiErrorMessagesTest.kt b/feature/ai/src/test/kotlin/com/interlinedlist/android/feature/ai/ui/AiErrorMessagesTest.kt new file mode 100644 index 0000000..82f5a82 --- /dev/null +++ b/feature/ai/src/test/kotlin/com/interlinedlist/android/feature/ai/ui/AiErrorMessagesTest.kt @@ -0,0 +1,33 @@ +package com.interlinedlist.android.feature.ai.ui + +import com.google.common.truth.Truth.assertThat +import com.interlinedlist.android.feature.ai.domain.AiError +import org.junit.Test + +class AiErrorMessagesTest { + + @Test + fun `a spent allowance says so in the words the help centre uses`() { + assertThat(AiError.QuotaExceeded().toUserMessage()).isEqualTo("Daily AI limit reached. Try again tomorrow.") + } + + @Test + fun `a rate limit tells the user how long to wait when the server said`() { + assertThat(AiError.RateLimited(retryAfterSeconds = 30).toUserMessage()).contains("30 seconds") + assertThat(AiError.RateLimited().toUserMessage()).doesNotContain("seconds.") + } + + @Test + fun `an unconfigured provider never blames the user or offers an upsell`() { + val message = AiError.ProviderUnconfigured("no key").toUserMessage() + + assertThat(message).isEqualTo("AI writing assistance is unavailable right now.") + assertThat(message).doesNotContain("subscription") + } + + @Test + fun `the API's own wording is preferred where it is user-facing`() { + assertThat(AiError.NotSubscribed("Subscribe to use AI.").toUserMessage()) + .isEqualTo("Subscribe to use AI.") + } +} diff --git a/feature/ai/src/test/kotlin/com/interlinedlist/android/feature/ai/ui/AiPreviewSessionTest.kt b/feature/ai/src/test/kotlin/com/interlinedlist/android/feature/ai/ui/AiPreviewSessionTest.kt new file mode 100644 index 0000000..bc29e5f --- /dev/null +++ b/feature/ai/src/test/kotlin/com/interlinedlist/android/feature/ai/ui/AiPreviewSessionTest.kt @@ -0,0 +1,174 @@ +package com.interlinedlist.android.feature.ai.ui + +import com.google.common.truth.Truth.assertThat +import com.interlinedlist.android.feature.ai.domain.AiArtifact +import com.interlinedlist.android.feature.ai.domain.AiCreated +import com.interlinedlist.android.feature.ai.domain.AiError +import com.interlinedlist.android.feature.ai.domain.AiFeature +import com.interlinedlist.android.feature.ai.domain.AiGate +import com.interlinedlist.android.feature.ai.domain.AiGeneration +import com.interlinedlist.android.feature.ai.domain.AiPreview +import com.interlinedlist.android.feature.ai.domain.AiQuota +import com.interlinedlist.android.feature.ai.domain.AiResult +import com.interlinedlist.android.feature.ai.domain.AiSuggestInput +import com.interlinedlist.android.feature.ai.domain.FakeAiRepository +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import org.junit.Test + +/** + * The preview → confirm rule, enforced once here for all five AI surfaces: + * nothing reaches `/generate` that the user has not approved. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class AiPreviewSessionTest { + + private val dispatcher = StandardTestDispatcher() + private val repository = FakeAiRepository() + private val gate = AiGate(repository) + + private fun session(scope: TestScope) = AiPreviewSession(repository, gate, scope) + + private val preview = AiPreview( + feature = AiFeature.POWERED_DOCUMENT, + artifact = AiArtifact(buildJsonObject { put("kind", "document"); put("title", "Draft") }), + quota = AiQuota(9, 50, 41), + ) + + @Test + fun `confirming with nothing previewed writes nothing`() = runTest(dispatcher) { + val session = session(this) + + val started = session.confirm() + advanceUntilIdle() + + assertThat(started).isFalse() + assertThat(repository.generateCalls).isEmpty() + assertThat(session.state.value).isEqualTo(AiPreviewState.Idle) + } + + @Test + fun `a suggestion alone writes nothing`() = runTest(dispatcher) { + repository.suggestResult = AiResult.Success(preview) + val session = session(this) + + session.suggest(AiFeature.POWERED_DOCUMENT, AiSuggestInput("about widgets")) + advanceUntilIdle() + + assertThat(session.state.value).isEqualTo(AiPreviewState.Previewing(preview)) + assertThat(repository.generateCalls).isEmpty() + } + + @Test + fun `confirming after a preview persists exactly that artifact`() = runTest(dispatcher) { + repository.suggestResult = AiResult.Success(preview) + repository.generateResult = + AiResult.Success(AiGeneration(AiFeature.POWERED_DOCUMENT, AiCreated.DocumentCreated("doc_1"))) + val session = session(this) + + session.suggest(AiFeature.POWERED_DOCUMENT, AiSuggestInput("about widgets")) + advanceUntilIdle() + val started = session.confirm() + advanceUntilIdle() + + assertThat(started).isTrue() + assertThat(repository.generateCalls).hasSize(1) + assertThat(repository.generateCalls.single().artifact).isEqualTo(preview.artifact) + assertThat(repository.generateCalls.single().feature).isEqualTo(AiFeature.POWERED_DOCUMENT) + assertThat(session.state.value) + .isEqualTo(AiPreviewState.Generated(AiGeneration(AiFeature.POWERED_DOCUMENT, AiCreated.DocumentCreated("doc_1")))) + } + + @Test + fun `an edited preview is what gets written`() = runTest(dispatcher) { + repository.suggestResult = AiResult.Success(preview) + val edited = AiArtifact(buildJsonObject { put("kind", "document"); put("title", "My title") }) + val session = session(this) + + session.suggest(AiFeature.POWERED_DOCUMENT, AiSuggestInput("about widgets")) + advanceUntilIdle() + session.confirm(edited) + advanceUntilIdle() + + assertThat(repository.generateCalls.single().artifact).isEqualTo(edited) + } + + @Test + fun `discarding a preview writes nothing and cannot be confirmed afterwards`() = runTest(dispatcher) { + repository.suggestResult = AiResult.Success(preview) + val session = session(this) + + session.suggest(AiFeature.POWERED_DOCUMENT, AiSuggestInput("about widgets")) + advanceUntilIdle() + session.discard() + val started = session.confirm() + advanceUntilIdle() + + assertThat(started).isFalse() + assertThat(repository.generateCalls).isEmpty() + assertThat(session.state.value).isEqualTo(AiPreviewState.Idle) + } + + @Test + fun `a second confirm while the first is in flight is ignored`() = runTest(dispatcher) { + repository.suggestResult = AiResult.Success(preview) + val session = session(this) + + session.suggest(AiFeature.POWERED_DOCUMENT, AiSuggestInput("about widgets")) + advanceUntilIdle() + session.confirm() + val second = session.confirm() + advanceUntilIdle() + + assertThat(second).isFalse() + assertThat(repository.generateCalls).hasSize(1) + } + + @Test + fun `a failed suggestion surfaces the error and reports it to the gate`() = runTest(dispatcher) { + repository.suggestResult = AiResult.Failure(AiError.QuotaExceeded("Daily AI limit reached")) + gate.refresh() + val session = session(this) + + session.suggest(AiFeature.WRITING_ASSIST, AiSuggestInput("draft")) + advanceUntilIdle() + + assertThat(session.state.value) + .isEqualTo(AiPreviewState.Failed(AiError.QuotaExceeded("Daily AI limit reached"))) + assertThat(gate.availability.value.isQuotaExhausted).isTrue() + } + + @Test + fun `a failed generate keeps the preview on screen`() = runTest(dispatcher) { + repository.suggestResult = AiResult.Success(preview) + repository.generateResult = AiResult.Failure(AiError.ProviderFailure("upstream")) + val session = session(this) + + session.suggest(AiFeature.POWERED_DOCUMENT, AiSuggestInput("about widgets")) + advanceUntilIdle() + session.confirm() + advanceUntilIdle() + + assertThat(session.state.value) + .isEqualTo(AiPreviewState.Failed(AiError.ProviderFailure("upstream"), preview)) + } + + @Test + fun `a fresh quota from an action reaches the gate`() = runTest(dispatcher) { + repository.suggestResult = AiResult.Success(preview) + gate.refresh() + val session = session(this) + + session.suggest(AiFeature.POWERED_DOCUMENT, AiSuggestInput("about widgets")) + advanceUntilIdle() + + assertThat(gate.availability.value).isEqualTo( + com.interlinedlist.android.feature.ai.domain.AiAvailability.Available(AiQuota(9, 50, 41)), + ) + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index e3a23ef..f2c9550 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -43,3 +43,4 @@ include(":feature:notifications") include(":feature:organizations") include(":feature:integrations") include(":feature:directmessages") +include(":feature:ai")