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
4 changes: 4 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
79 changes: 79 additions & 0 deletions feature/ai/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -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)
}
Original file line number Diff line number Diff line change
@@ -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 <T> aiApiCall(json: Json, block: suspend () -> T): AiResult<T> = 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")
}
Original file line number Diff line number Diff line change
@@ -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<AiPreview>

/**
* 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<AiGeneration>
}
Original file line number Diff line number Diff line change
@@ -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<AiPreview> = 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<AiGeneration> = 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
}
}
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading