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 @@ -46,6 +46,10 @@ dependencies {
implementation(project(":core:network"))
implementation(project(":core:database"))
implementation(project(":core:datastore"))
// Depended on so its Hilt modules join the app component. "Create from…" is
// opened from the messages, lists and documents surfaces, so it has no
// navigation entry of its own here.
implementation(project(":core:materialize"))

// Features
implementation(project(":feature:auth"))
Expand Down
52 changes: 52 additions & 0 deletions core/materialize/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.ksp)
alias(libs.plugins.hilt)
}

android {
namespace = "com.interlinedlist.android.core.materialize"
compileSdk = 35

defaultConfig {
minSdk = 26
}

compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions { jvmTarget = "17" }
}

dependencies {
// `customerStatus` / `CustomerStatus.isSubscriber` — the same subscriber flag
// the rest of the app gates on.
implementation(project(":core:model"))
// ApiResult / AppError / DispatcherProvider. Materialize reuses the shared
// error type so the feature modules can keep their existing
// `AppError.toUserMessage()` / `isSubscriptionGate` helpers.
implementation(project(":core:common"))
// The shared authed Retrofit and `GET /api/user` (read by MaterializeGate).
implementation(project(":core:network"))

implementation(libs.retrofit.core)
implementation(libs.okhttp.core)
implementation(libs.kotlinx.serialization.json)

implementation(libs.hilt.android)
ksp(libs.hilt.compiler)

// No Room cache and no Compose: `POST /api/materialize` is a one-shot write
// whose result is authoritative, and the preview/confirm UI is built by the
// feature surfaces that open it.

testImplementation(libs.junit)
testImplementation(libs.kotlinx.coroutines.test)
testImplementation(libs.truth)
// The repository tests drive a real Retrofit/OkHttp stack against MockWebServer.
testImplementation(libs.okhttp.mockwebserver)
testImplementation(libs.retrofit.kotlinx.serialization)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package com.interlinedlist.android.core.materialize.data

import com.interlinedlist.android.core.common.dispatcher.DispatcherProvider
import com.interlinedlist.android.core.common.result.ApiResult
import com.interlinedlist.android.core.common.result.AppError
import com.interlinedlist.android.core.materialize.data.mapper.toDto
import com.interlinedlist.android.core.materialize.data.mapper.toOutcomeOrNull
import com.interlinedlist.android.core.materialize.data.remote.MaterializeApi
import com.interlinedlist.android.core.materialize.domain.MaterializeGate
import com.interlinedlist.android.core.materialize.domain.MaterializeOutcome
import com.interlinedlist.android.core.materialize.domain.MaterializeRequest
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import javax.inject.Inject

class DefaultMaterializeRepository @Inject constructor(
private val api: MaterializeApi,
private val gate: MaterializeGate,
private val json: Json,
private val dispatchers: DispatcherProvider,
) : MaterializeRepository {

override suspend fun materialize(
request: MaterializeRequest,
): ApiResult<MaterializeOutcome> = withContext(dispatchers.io) {
// The gate is the client-side half of the subscriber check and runs
// before anything is sent. It only blocks a target that actually
// creates, and only when the account is positively known to be free:
// an unread status falls through to the server, which is the real gate.
if (request.target.createsContent && gate.ensureResolved().isKnownFree) {
return@withContext ApiResult.Failure(
AppError.SubscriptionRequired(SUBSCRIPTION_MESSAGE),
)
}

when (val result = materializeApiCall(json) { api.materialize(request.toDto()) }) {
is ApiResult.Failure -> {
// The server just told us this account cannot create; remember it
// so the next confirm short-circuits to the upsell.
if (result.error is AppError.SubscriptionRequired) gate.recordSubscriptionRequired()
result
}

is ApiResult.Success -> result.data.toOutcomeOrNull(request)
?.let { ApiResult.Success(it) }
// A 201 that did not carry what the target promised.
?: ApiResult.Failure(AppError.Server(EMPTY_RESULT_MESSAGE))
}
}

private companion object {
const val SUBSCRIPTION_MESSAGE =
"Creating lists and documents requires an active subscription."
const val EMPTY_RESULT_MESSAGE =
"InterlinedList did not return what it created. Check your lists and documents."
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
package com.interlinedlist.android.core.materialize.data

import com.interlinedlist.android.core.common.result.ApiResult
import com.interlinedlist.android.core.common.result.AppError
import com.interlinedlist.android.core.materialize.data.remote.dto.MaterializeErrorCode
import com.interlinedlist.android.core.materialize.data.remote.dto.MaterializeErrorDto
import kotlinx.serialization.json.Json
import retrofit2.HttpException
import java.io.IOException

/**
* Runs the materialize call and normalises every failure onto the shared
* [AppError], so the feature modules that open this flow keep using the
* `toUserMessage()` / `isSubscriptionGate` helpers they already have.
*
* This is not `safeApiCall`: that helper drops `code` and decides the
* subscription case by looking for the word "subscription" in the message. This
* endpoint is documented as subscriber-only, so **its 403 is the subscriber
* gate** whatever the wording — except when the code says the account itself is
* restricted, suspended or on probation, which no subscription would fix and
* which must therefore not become an upsell.
*/
internal suspend fun <T> materializeApiCall(
json: Json,
block: suspend () -> T,
): ApiResult<T> = try {
ApiResult.Success(block())
} catch (e: HttpException) {
ApiResult.Failure(e.toAppError(json))
} catch (e: IOException) {
ApiResult.Failure(AppError.Network(e.message))
} catch (e: Exception) {
ApiResult.Failure(AppError.Unknown(e.message))
}

private fun HttpException.toAppError(json: Json): AppError {
val body = runCatching { response()?.errorBody()?.string() }.getOrNull()
val dto = body
?.takeIf { it.isNotBlank() }
?.let { runCatching { json.decodeFromString(MaterializeErrorDto.serializer(), it) }.getOrNull() }
val message = dto?.error
val code = dto?.code

return when {
// An account-status refusal is a 403 a subscription would not lift.
code?.startsWith(MaterializeErrorCode.ACCOUNT_PREFIX) == true -> AppError.Forbidden(message)
code == MaterializeErrorCode.SUBSCRIPTION_REQUIRED -> AppError.SubscriptionRequired(message)
code == MaterializeErrorCode.UNAUTHORIZED -> AppError.Unauthorized(message)
code == MaterializeErrorCode.NOT_FOUND -> AppError.NotFound(message)
code == MaterializeErrorCode.RATE_LIMITED -> AppError.RateLimited(message)
// `bad_request` / `validation_failed` carry a message worth showing
// verbatim ("Missing source", "Field 'year' has invalid type …"); the
// shared error type has no validation case, and `Unknown` renders the
// server's own words in every feature's `toUserMessage()`.
code == MaterializeErrorCode.BAD_REQUEST ||
code == MaterializeErrorCode.VALIDATION_FAILED -> AppError.Unknown(message)
code == MaterializeErrorCode.INTERNAL_ERROR -> AppError.Server(message)
// `code` is optional on the wire — fall back to the status.
else -> fromStatus(code(), message)
}
}

/** Fallback for a response that carried no `code`. */
private fun fromStatus(status: Int, message: String?): AppError = when (status) {
401 -> AppError.Unauthorized(message)
// The only 403 this endpoint documents is the subscriber gate.
403 -> AppError.SubscriptionRequired(message)
// "A referenced id is not found or not owned by you."
404 -> AppError.NotFound(message)
409 -> AppError.Conflict(message)
429 -> AppError.RateLimited(message)
in 500..599 -> AppError.Server(message)
// Includes the documented 400: show the server's own explanation.
else -> AppError.Unknown(message ?: "HTTP $status")
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package com.interlinedlist.android.core.materialize.data

import com.interlinedlist.android.core.common.result.ApiResult
import com.interlinedlist.android.core.materialize.domain.MaterializeOutcome
import com.interlinedlist.android.core.materialize.domain.MaterializeRequest

/**
* `POST /api/materialize` — the whole "Create from…" surface, in one call.
*
* There is nothing to cache: the endpoint is a one-shot write whose result is
* authoritative, so this repository has no Room database and no offline read.
*/
interface MaterializeRepository {

/**
* Runs a confirmed conversion.
*
* Enforces the subscriber gate first: when the account is positively known
* to be free and [request] would create something, this fails with
* `AppError.SubscriptionRequired` **without issuing the request**, so a free
* account cannot trigger a write. The menu that leads here is open to
* everyone; only this confirmation is gated.
*/
suspend fun materialize(request: MaterializeRequest): ApiResult<MaterializeOutcome>
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
package com.interlinedlist.android.core.materialize.data.mapper

import com.interlinedlist.android.core.materialize.data.remote.dto.DocConfigDto
import com.interlinedlist.android.core.materialize.data.remote.dto.ListConfigDto
import com.interlinedlist.android.core.materialize.data.remote.dto.ListFieldDto
import com.interlinedlist.android.core.materialize.data.remote.dto.MaterializeRequestDto
import com.interlinedlist.android.core.materialize.data.remote.dto.MaterializeResponseDto
import com.interlinedlist.android.core.materialize.data.remote.dto.MaterializeSourceDto
import com.interlinedlist.android.core.materialize.data.remote.dto.MaterializedDocumentDto
import com.interlinedlist.android.core.materialize.data.remote.dto.MaterializedListDto
import com.interlinedlist.android.core.materialize.data.remote.dto.MessageConfigDto
import com.interlinedlist.android.core.materialize.data.remote.dto.MessageDraftDto
import com.interlinedlist.android.core.materialize.domain.DocConfig
import com.interlinedlist.android.core.materialize.domain.ListConfig
import com.interlinedlist.android.core.materialize.domain.MaterializeColumn
import com.interlinedlist.android.core.materialize.domain.MaterializeOutcome
import com.interlinedlist.android.core.materialize.domain.MaterializeRequest
import com.interlinedlist.android.core.materialize.domain.MaterializeSource
import com.interlinedlist.android.core.materialize.domain.MaterializedDocument
import com.interlinedlist.android.core.materialize.domain.MaterializedList
import com.interlinedlist.android.core.materialize.domain.MessageDraft
import com.interlinedlist.android.core.materialize.domain.MessageDraftConfig
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonPrimitive

/**
* Builds the request body. Each destination contributes only its own config, so
* the body never carries a section the target ignores.
*/
internal fun MaterializeRequest.toDto(): MaterializeRequestDto = MaterializeRequestDto(
target = target.apiValue,
source = source.toDto(),
listConfig = when (this) {
is MaterializeRequest.ToList -> listConfig.toDto()
is MaterializeRequest.ToListAndDocument -> listConfig.toDto()
else -> null
},
docConfig = when (this) {
is MaterializeRequest.ToDocument -> docConfig?.toDto()
is MaterializeRequest.ToListAndDocument -> docConfig?.toDto()
else -> null
},
messageConfig = (this as? MaterializeRequest.ToMessageDraft)?.messageConfig?.toDto(),
)

/** Ids only — see [MaterializeSource]. */
internal fun MaterializeSource.toDto(): MaterializeSourceDto = when (this) {
is MaterializeSource.Messages -> MaterializeSourceDto(kind = kind, messageIds = messageIds)
is MaterializeSource.Lists -> MaterializeSourceDto(kind = kind, listIds = listIds)
is MaterializeSource.Rows -> MaterializeSourceDto(kind = kind, listId = listId, rowIds = rowIds)
is MaterializeSource.Document -> MaterializeSourceDto(kind = kind, documentId = documentId)
is MaterializeSource.DocumentSelection ->
MaterializeSourceDto(kind = kind, documentId = documentId, markdown = markdown)
}

internal fun ListConfig.toDto(): ListConfigDto = ListConfigDto(
title = title,
description = description,
isPublic = isPublic,
fields = fields?.map { it.toDto() },
includeData = includeData,
)

internal fun MaterializeColumn.toDto(): ListFieldDto = ListFieldDto(
propertyKey = propertyKey,
propertyName = propertyName,
propertyType = propertyType.apiValue,
// Explicit `null` — a user-added empty column, not an omitted key.
sourceKey = sourceKey?.let { JsonPrimitive(it) } ?: JsonNull,
isRequired = isRequired,
options = options,
)

internal fun DocConfig.toDto(): DocConfigDto = DocConfigDto(
title = title,
relativePath = relativePath,
isPublic = isPublic,
listStyle = listStyle?.apiValue,
rowDataStyle = rowDataStyle?.apiValue,
)

internal fun MessageDraftConfig.toDto(): MessageConfigDto = MessageConfigDto(
content = content,
crossPostTargets = crossPostTargets?.map { it.apiValue },
allowThread = allowThread,
publiclyVisible = publiclyVisible,
tags = tags,
scheduledAt = scheduledAt,
)

/**
* Reads the result against the target that was asked for.
*
* Returns null when the 201 did not carry what the target promised — a contract
* violation the caller has to see as a failure rather than as an empty success.
*/
internal fun MaterializeResponseDto.toOutcomeOrNull(
request: MaterializeRequest,
): MaterializeOutcome? = when (request) {
is MaterializeRequest.ToList ->
list?.toDomain()?.let { MaterializeOutcome.ListCreated(it) }

is MaterializeRequest.ToDocument ->
document?.toDomain()?.let { MaterializeOutcome.DocumentCreated(it) }

is MaterializeRequest.ToListAndDocument -> {
val createdList = list?.toDomain()
val createdDocument = document?.toDomain()
if (createdList != null && createdDocument != null) {
MaterializeOutcome.ListAndDocumentCreated(createdList, createdDocument)
} else {
null
}
}

is MaterializeRequest.ToMessageDraft ->
message?.toDomain()?.let { MaterializeOutcome.DraftReady(it) }
}

/** The id is the part that matters — a titleless response still opens. */
internal fun MaterializedListDto.toDomain(): MaterializedList = MaterializedList(
id = id,
title = title.orEmpty(),
description = description,
isPublic = isPublic,
)

internal fun MaterializedDocumentDto.toDomain(): MaterializedDocument = MaterializedDocument(
id = id,
title = title.orEmpty(),
relativePath = relativePath,
isPublic = isPublic,
)

internal fun MessageDraftDto.toDomain(): MessageDraft = MessageDraft(
content = content,
// A server that only sent `content` still yields a one-part draft.
thread = thread.ifEmpty { listOf(content) },
isThread = isThread,
charLimit = charLimit,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package com.interlinedlist.android.core.materialize.data.remote

import com.interlinedlist.android.core.materialize.data.remote.dto.MaterializeRequestDto
import com.interlinedlist.android.core.materialize.data.remote.dto.MaterializeResponseDto
import retrofit2.http.Body
import retrofit2.http.POST

/**
* Retrofit description of `POST /api/materialize`, built from the shared authed
* Retrofit (base URL and `Authorization: Bearer …` already applied).
*
* One endpoint backs all four destinations; the `target` in the body chooses.
*/
interface MaterializeApi {

/**
* Creates a list, a document, or both from an id-only source — or, for
* `target: "message"`, returns a draft and creates nothing.
*/
@POST("api/materialize")
suspend fun materialize(@Body request: MaterializeRequestDto): MaterializeResponseDto
}
Loading
Loading