feat: data foundation (models, secure storage, Retrofit API, repository, L10n)

- java.time via core library desugaring (minSdk 24)
- Domain models: CalEvent, AppSettings, account/calendar models
- CredentialStore (EncryptedSharedPreferences) + SettingsStore
- Retrofit CalendarrApi covering all server endpoints
- CalendarRepository: auth, settings, accounts, events (org.json parsing
  for mixed-type payloads), writable calendars, visibility toggles
- Hilt DI (Moshi), dynamic-baseUrl ApiProvider, AuthInterceptor
- L10n (de/en) ported from iOS

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Guido Schmit
2026-05-31 11:53:34 +02:00
parent a015a45265
commit 676b7ee2c6
17 changed files with 1450 additions and 0 deletions

View File

@@ -0,0 +1,348 @@
package com.scarriffle.calendarr.data
import com.scarriffle.calendarr.data.remote.ApiException
import com.scarriffle.calendarr.data.remote.ApiProvider
import com.scarriffle.calendarr.data.remote.TwoFactorRequiredException
import com.scarriffle.calendarr.data.remote.UnauthorizedException
import com.scarriffle.calendarr.data.remote.ensureSuccess
import com.scarriffle.calendarr.data.remote.errorDetail
import com.scarriffle.calendarr.data.remote.jsonBody
import com.scarriffle.calendarr.domain.model.AppSettings
import com.scarriffle.calendarr.domain.model.CalDAVAccount
import com.scarriffle.calendarr.domain.model.CalEvent
import com.scarriffle.calendarr.domain.model.GoogleAccount
import com.scarriffle.calendarr.domain.model.HomeAssistantAccount
import com.scarriffle.calendarr.domain.model.ICalSubscription
import com.scarriffle.calendarr.domain.model.LocalCalendar
import com.scarriffle.calendarr.domain.model.UserProfile
import com.scarriffle.calendarr.domain.model.WritableCalendar
import com.scarriffle.calendarr.util.Dates
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.json.JSONObject
import retrofit2.HttpException
import java.time.Instant
import javax.inject.Inject
import javax.inject.Singleton
data class LoginResult(val token: String, val username: String, val isAdmin: Boolean)
data class TotpSetup(val secret: String, val qrUrl: String)
/**
* Single entry point for all server interaction. Wraps [com.scarriffle.calendarr.data.remote.CalendarrApi],
* converts HTTP failures into [ApiException]s carrying the server's `detail`
* message, and parses the mixed-type event payloads with org.json.
*/
@Singleton
class CalendarRepository @Inject constructor(
private val apiProvider: ApiProvider,
private val credentialStore: CredentialStore,
private val settingsStore: SettingsStore,
) {
private val api get() = apiProvider.api()
private suspend fun <T> guarded(block: suspend () -> T): T = withContext(Dispatchers.IO) {
try {
block()
} catch (e: HttpException) {
if (e.code() == 401) throw UnauthorizedException()
throw ApiException(errorDetail(e.response()?.errorBody(), e.code()))
}
}
// ---- Auth ----
suspend fun setupRequired(baseUrl: String): Boolean = withContext(Dispatchers.IO) {
val resp = apiProvider.apiFor(baseUrl).setupRequired()
if (!resp.isSuccessful) return@withContext false
val raw = resp.body()?.string() ?: return@withContext false
runCatching { JSONObject(raw).optBoolean("required", false) }.getOrDefault(false)
}
suspend fun login(
baseUrl: String,
username: String,
password: String,
totpCode: String?,
rememberMe: Boolean,
): LoginResult = withContext(Dispatchers.IO) {
val body = jsonBody(
"username" to username,
"password" to password,
"remember_me" to rememberMe,
"totp_code" to totpCode,
)
val resp = apiProvider.apiFor(baseUrl).login(body)
if (resp.code() == 401) {
val detail = runCatching {
JSONObject(resp.errorBody()?.string() ?: "").optString("detail")
}.getOrNull()
if (detail == "2fa_required") throw TwoFactorRequiredException()
throw UnauthorizedException()
}
if (!resp.isSuccessful) throw ApiException(errorDetail(resp.errorBody(), resp.code()))
val json = JSONObject(resp.body()?.string() ?: throw ApiException("Leere Antwort"))
val token = json.optString("access_token").takeIf { it.isNotBlank() }
?: throw ApiException("Antwort konnte nicht verarbeitet werden")
val user = json.optJSONObject("user")
val uname = user?.optString("username") ?: username
val isAdmin = user?.optBoolean("is_admin", false) ?: false
credentialStore.serverUrl = ApiProvider.normalize(baseUrl)
credentialStore.saveLogin(token, uname, isAdmin)
apiProvider.invalidate()
LoginResult(token, uname, isAdmin)
}
// ---- Settings / profile ----
suspend fun getSettings(): AppSettings = guarded { api.getSettings() }
suspend fun updateSettings(s: AppSettings) = guarded {
api.updateSettings(
jsonBody(
"default_view" to s.defaultView,
"week_start_day" to s.weekStartDay,
"primary_color" to s.primaryColor,
"accent_color" to s.accentColor,
"today_color" to s.todayColor,
"dim_past_events" to s.dimPastEvents,
"text_contrast" to s.textContrast,
"line_contrast" to s.lineContrast,
"hour_height" to s.hourHeight,
"language" to s.language,
"month_divider_color" to s.monthDividerColor,
"month_label_color" to s.monthLabelColor,
)
).ensureSuccess()
}
suspend fun getProfile(): UserProfile = guarded { api.getProfile() }
suspend fun updateEmail(email: String) = guarded {
api.updateEmail(jsonBody("email" to email)).ensureSuccess()
}
suspend fun changePassword(current: String, new: String) = guarded {
api.changePassword(
jsonBody("current_password" to current, "new_password" to new)
).ensureSuccess()
}
suspend fun setup2fa(): TotpSetup = guarded {
val resp = api.setup2fa()
resp.ensureSuccess()
val json = JSONObject(resp.body()?.string() ?: "{}")
TotpSetup(json.optString("secret"), json.optString("qr_url"))
}
suspend fun enable2fa(code: String) = guarded {
api.enable2fa(jsonBody("code" to code)).ensureSuccess()
}
suspend fun disable2fa(password: String) = guarded {
api.disable2fa(jsonBody("password" to password)).ensureSuccess()
}
// ---- Accounts ----
suspend fun getCalDAVAccounts(): List<CalDAVAccount> = guarded { api.getCalDAVAccounts() }
suspend fun addCalDAVAccount(name: String, url: String, username: String, password: String, color: String) =
guarded {
api.addCalDAVAccount(
jsonBody(
"name" to name, "url" to url, "username" to username,
"password" to password, "color" to color,
)
)
}
suspend fun deleteCalDAVAccount(id: Int) = guarded { api.deleteCalDAVAccount(id).ensureSuccess() }
suspend fun getLocalCalendars(): List<LocalCalendar> = guarded { api.getLocalCalendars() }
suspend fun addLocalCalendar(name: String, color: String) =
guarded { api.addLocalCalendar(jsonBody("name" to name, "color" to color)) }
suspend fun deleteLocalCalendar(id: Int) = guarded { api.deleteLocalCalendar(id).ensureSuccess() }
suspend fun getICalSubscriptions(): List<ICalSubscription> = guarded { api.getICalSubscriptions() }
suspend fun addICalSubscription(name: String, url: String, color: String, refreshMinutes: Int) =
guarded {
api.addICalSubscription(
jsonBody(
"name" to name, "url" to url, "color" to color,
"refresh_minutes" to refreshMinutes,
)
)
}
suspend fun deleteICalSubscription(id: Int) = guarded { api.deleteICalSubscription(id).ensureSuccess() }
suspend fun getGoogleAccounts(): List<GoogleAccount> = guarded { api.getGoogleAccounts() }
suspend fun deleteGoogleAccount(id: Int) = guarded { api.deleteGoogleAccount(id).ensureSuccess() }
suspend fun getHomeAssistantAccounts(): List<HomeAssistantAccount> =
guarded { api.getHomeAssistantAccounts() }
suspend fun addHomeAssistantAccount(name: String, url: String, token: String) =
guarded {
api.addHomeAssistantAccount(
jsonBody("name" to name, "url" to url, "token" to token, "auth_method" to "token")
)
}
suspend fun deleteHomeAssistantAccount(id: Int) =
guarded { api.deleteHomeAssistantAccount(id).ensureSuccess() }
/** Toggle a calendar's server-side visibility (caldav/google/homeassistant only). */
suspend fun setCalendarSidebarHidden(source: String, calendarId: Int, hidden: Boolean) = guarded {
val body = jsonBody("enabled" to !hidden, "sidebar_hidden" to hidden)
when (source) {
"caldav" -> api.updateCalDAVCalendar(calendarId, body).ensureSuccess()
"google" -> api.updateGoogleCalendar(calendarId, body).ensureSuccess()
"homeassistant" -> api.updateHACalendar(calendarId, body).ensureSuccess()
else -> Unit
}
}
/** Resolve all calendars the user can create events in. */
suspend fun getWritableCalendars(): List<WritableCalendar> = withContext(Dispatchers.IO) {
val result = mutableListOf<WritableCalendar>()
runCatching { api.getLocalCalendars() }.getOrDefault(emptyList()).forEach { cal ->
result += WritableCalendar("local-${cal.id}", cal.name, cal.color, "local", cal.id)
}
runCatching { api.getCalDAVAccounts() }.getOrDefault(emptyList())
.filter { it.enabled }
.forEach { acc ->
acc.calendars.orEmpty().filter { it.enabled }.forEach { cal ->
result += WritableCalendar(
"caldav-${cal.id}", "${acc.name} ${cal.name}",
cal.color ?: acc.color, "caldav", cal.id,
)
}
}
runCatching { api.getGoogleAccounts() }.getOrDefault(emptyList()).forEach { acc ->
acc.calendars.orEmpty().filter { it.enabled }.forEach { cal ->
result += WritableCalendar(
"google-${cal.id}", "${acc.email} ${cal.name}",
cal.color ?: "#4285f4", "google", cal.id,
)
}
}
runCatching { api.getHomeAssistantAccounts() }.getOrDefault(emptyList()).forEach { acc ->
acc.calendars.orEmpty().filter { it.enabled }.forEach { cal ->
result += WritableCalendar(
"ha-${cal.id}", "${acc.name} ${cal.name}",
cal.color ?: "#46bdc6", "homeassistant", cal.id,
)
}
}
result
}
// ---- Events ----
suspend fun fetchEvents(start: Instant, end: Instant): List<CalEvent> = withContext(Dispatchers.IO) {
val resp = api.fetchEvents(Dates.isoUtc(start), Dates.isoUtc(end))
resp.ensureSuccess()
val raw = resp.body()?.string() ?: return@withContext emptyList()
val root = JSONObject(raw)
val arr = root.optJSONArray("events") ?: return@withContext emptyList()
buildList {
for (i in 0 until arr.length()) {
val obj = arr.optJSONObject(i) ?: continue
CalEvent.fromJson(obj)?.let { add(it) }
}
}
}
suspend fun createLocalEvent(
calendarId: Int, title: String, start: Instant, end: Instant,
isAllDay: Boolean, location: String, description: String, color: String?,
) = guarded {
api.createLocalEvent(eventBody(calendarId, title, start, end, isAllDay, location, description, color))
.ensureSuccess()
}
suspend fun updateLocalEvent(
uid: String, title: String, start: Instant, end: Instant,
isAllDay: Boolean, location: String, description: String, color: String?,
) = guarded {
api.updateLocalEvent(uid, eventBody(null, title, start, end, isAllDay, location, description, color))
.ensureSuccess()
}
suspend fun deleteLocalEvent(uid: String) = guarded { api.deleteLocalEvent(uid).ensureSuccess() }
suspend fun createCalDAVEvent(
calendarId: Int, title: String, start: Instant, end: Instant,
isAllDay: Boolean, location: String, description: String, color: String?,
) = guarded {
api.createCalDAVEvent(eventBody(calendarId, title, start, end, isAllDay, location, description, color))
.ensureSuccess()
}
suspend fun updateCalDAVEvent(
uid: String, url: String, calendarId: Int?, title: String, start: Instant, end: Instant,
isAllDay: Boolean, location: String, description: String, color: String?,
) = guarded {
api.updateCalDAVEvent(
uid, url, calendarId,
eventBody(null, title, start, end, isAllDay, location, description, color),
).ensureSuccess()
}
suspend fun deleteCalDAVEvent(uid: String, url: String, calendarId: Int?) =
guarded { api.deleteCalDAVEvent(uid, url, calendarId).ensureSuccess() }
suspend fun createGoogleEvent(
calendarDbId: Int, title: String, start: Instant, end: Instant,
isAllDay: Boolean, location: String, description: String,
) = guarded {
api.createGoogleEvent(
jsonBody(
"calendar_db_id" to calendarDbId, "title" to title,
"start" to Dates.format(start, isAllDay), "end" to Dates.format(end, isAllDay),
"allDay" to isAllDay, "location" to location, "description" to description,
)
).ensureSuccess()
}
suspend fun createHAEvent(
calendarId: Int, title: String, start: Instant, end: Instant,
isAllDay: Boolean, location: String, description: String,
) = guarded {
api.createHAEvent(
jsonBody(
"calendar_id" to calendarId, "title" to title,
"start" to Dates.format(start, isAllDay), "end" to Dates.format(end, isAllDay),
"allDay" to isAllDay, "location" to location, "description" to description,
)
).ensureSuccess()
}
suspend fun deleteHAEvent(calendarId: Int, uid: String) =
guarded { api.deleteHAEvent(calendarId, uid).ensureSuccess() }
private fun eventBody(
calendarId: Int?, title: String, start: Instant, end: Instant,
isAllDay: Boolean, location: String, description: String, color: String?,
) = jsonBody(
buildMap {
calendarId?.let { put("calendar_id", it) }
put("title", title)
put("start", Dates.format(start, isAllDay))
put("end", Dates.format(end, isAllDay))
put("allDay", isAllDay)
put("location", location)
put("description", description)
if (!color.isNullOrBlank()) put("color", color)
}
)
}

View File

@@ -0,0 +1,83 @@
package com.scarriffle.calendarr.data
import android.content.Context
import android.content.SharedPreferences
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import javax.inject.Singleton
/**
* Securely stores the server URL and auth token in EncryptedSharedPreferences
* (Android's equivalent of the iOS Keychain). The iOS app keeps these in
* UserDefaults; on Android we encrypt them at rest.
*/
@Singleton
class CredentialStore @Inject constructor(
@ApplicationContext context: Context,
) {
private val prefs: SharedPreferences = run {
val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
EncryptedSharedPreferences.create(
context,
"calendarr_secure_prefs",
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
)
}
var serverUrl: String?
get() = prefs.getString(KEY_SERVER_URL, null)
set(value) = prefs.edit().putString(KEY_SERVER_URL, value).apply()
var token: String?
get() = prefs.getString(KEY_TOKEN, null)
set(value) = prefs.edit().putString(KEY_TOKEN, value).apply()
var username: String?
get() = prefs.getString(KEY_USERNAME, null)
set(value) = prefs.edit().putString(KEY_USERNAME, value).apply()
var isAdmin: Boolean
get() = prefs.getBoolean(KEY_IS_ADMIN, false)
set(value) = prefs.edit().putBoolean(KEY_IS_ADMIN, value).apply()
/** True once a server URL has been entered (setup step complete). */
val isConfigured: Boolean get() = !serverUrl.isNullOrBlank()
/** True once we hold an auth token (logged in). */
val isLoggedIn: Boolean get() = !token.isNullOrBlank()
fun saveLogin(token: String, username: String, isAdmin: Boolean) {
prefs.edit()
.putString(KEY_TOKEN, token)
.putString(KEY_USERNAME, username)
.putBoolean(KEY_IS_ADMIN, isAdmin)
.apply()
}
/** Clear the token (logout) but keep the server URL. */
fun clearToken() {
prefs.edit()
.remove(KEY_TOKEN)
.remove(KEY_USERNAME)
.remove(KEY_IS_ADMIN)
.apply()
}
/** Full reset, including the server URL ("switch server"). */
fun clearAll() {
prefs.edit().clear().apply()
}
private companion object {
const val KEY_SERVER_URL = "server_url"
const val KEY_TOKEN = "auth_token"
const val KEY_USERNAME = "username"
const val KEY_IS_ADMIN = "is_admin"
}
}

View File

@@ -0,0 +1,90 @@
package com.scarriffle.calendarr.data
import android.content.Context
import android.content.SharedPreferences
import com.scarriffle.calendarr.domain.model.AppSettings
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import javax.inject.Singleton
/**
* Local persistence for cached appearance settings and the per-device
* calendar visibility sets (hidden / banished). Mirrors the UserDefaults
* usage in the iOS `CalendarStore`.
*/
@Singleton
class SettingsStore @Inject constructor(
@ApplicationContext context: Context,
) {
private val prefs: SharedPreferences =
context.getSharedPreferences("calendarr_prefs", Context.MODE_PRIVATE)
// --- Appearance settings cache (mirror of the server) ---
fun loadSettings(): AppSettings = AppSettings(
defaultView = prefs.getString(K_DEFAULT_VIEW, null) ?: "month",
weekStartDay = prefs.getString(K_WEEK_START, null) ?: "monday",
primaryColor = prefs.getString(K_PRIMARY, null) ?: "#4285f4",
accentColor = prefs.getString(K_ACCENT, null) ?: "#ea4335",
todayColor = prefs.getString(K_TODAY, null) ?: "#4285f4",
dimPastEvents = prefs.getBoolean(K_DIM_PAST, false),
textContrast = prefs.getInt(K_TEXT_CONTRAST, 3),
lineContrast = prefs.getInt(K_LINE_CONTRAST, 3),
hourHeight = prefs.getInt(K_HOUR_HEIGHT, 60),
language = prefs.getString(K_LANGUAGE, null) ?: "de",
monthDividerColor = prefs.getString(K_DIVIDER, null) ?: "#7090c0",
monthLabelColor = prefs.getString(K_LABEL, null) ?: "#7090c0",
)
fun saveSettings(s: AppSettings) {
prefs.edit()
.putString(K_DEFAULT_VIEW, s.defaultView)
.putString(K_WEEK_START, s.weekStartDay)
.putString(K_PRIMARY, s.primaryColor)
.putString(K_ACCENT, s.accentColor)
.putString(K_TODAY, s.todayColor)
.putBoolean(K_DIM_PAST, s.dimPastEvents)
.putInt(K_TEXT_CONTRAST, s.textContrast)
.putInt(K_LINE_CONTRAST, s.lineContrast)
.putInt(K_HOUR_HEIGHT, s.hourHeight)
.putString(K_LANGUAGE, s.language)
.putString(K_DIVIDER, s.monthDividerColor)
.putString(K_LABEL, s.monthLabelColor)
.apply()
}
/** Device-local cache range in months around today (default 3). */
var cacheMonths: Int
get() = prefs.getInt(K_CACHE_MONTHS, 3)
set(value) = prefs.edit().putInt(K_CACHE_MONTHS, value).apply()
// --- Hidden calendars ("source:id") ---
var hiddenCalendarKeys: Set<String>
get() = prefs.getStringSet(K_HIDDEN, emptySet())?.toSet() ?: emptySet()
set(value) = prefs.edit().putStringSet(K_HIDDEN, value).apply()
// --- Banished calendars ("source:id") ---
var banishedCalendarKeys: Set<String>
get() = prefs.getStringSet(K_BANISHED, emptySet())?.toSet() ?: emptySet()
set(value) = prefs.edit().putStringSet(K_BANISHED, value).apply()
private companion object {
const val K_DEFAULT_VIEW = "default_view"
const val K_WEEK_START = "week_start_day"
const val K_PRIMARY = "primary_color"
const val K_ACCENT = "accent_color"
const val K_TODAY = "today_color"
const val K_DIM_PAST = "dim_past_events"
const val K_TEXT_CONTRAST = "text_contrast"
const val K_LINE_CONTRAST = "line_contrast"
const val K_HOUR_HEIGHT = "hour_height"
const val K_LANGUAGE = "language"
const val K_DIVIDER = "month_divider_color"
const val K_LABEL = "month_label_color"
const val K_CACHE_MONTHS = "cache_months"
const val K_HIDDEN = "hidden_calendar_keys"
const val K_BANISHED = "banished_calendar_keys"
}
}

View File

@@ -0,0 +1,34 @@
package com.scarriffle.calendarr.data.remote
import okhttp3.ResponseBody
import org.json.JSONObject
import retrofit2.Response
/** Generic server error carrying the `detail` message when available. */
open class ApiException(message: String) : Exception(message)
/** Credentials rejected (HTTP 401, not a 2FA prompt). */
class UnauthorizedException : ApiException("Benutzername oder Passwort falsch")
/** Server requires a TOTP code to finish login (HTTP 401, detail "2fa_required"). */
class TwoFactorRequiredException : ApiException("2FA-Code erforderlich")
/** Extract the `detail` field from an error response body, with a fallback. */
fun errorDetail(errorBody: ResponseBody?, status: Int): String {
val raw = runCatching { errorBody?.string() }.getOrNull()
if (!raw.isNullOrBlank()) {
runCatching {
val detail = JSONObject(raw).optString("detail")
if (detail.isNotBlank()) return detail
}
}
return "Fehler $status"
}
/** Throw an [ApiException] if the response is unsuccessful. */
fun Response<*>.ensureSuccess() {
if (!isSuccessful) {
if (code() == 401) throw UnauthorizedException()
throw ApiException(errorDetail(errorBody(), code()))
}
}

View File

@@ -0,0 +1,71 @@
package com.scarriffle.calendarr.data.remote
import com.scarriffle.calendarr.data.CredentialStore
import com.squareup.moshi.Moshi
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import retrofit2.converter.moshi.MoshiConverterFactory
import java.util.concurrent.TimeUnit
import javax.inject.Inject
import javax.inject.Singleton
/**
* Builds [CalendarrApi] instances. The base URL is dynamic (the user's server),
* so the cached instance is rebuilt whenever the stored server URL changes.
*/
@Singleton
class ApiProvider @Inject constructor(
private val credentialStore: CredentialStore,
private val moshi: Moshi,
private val authInterceptor: AuthInterceptor,
) {
private val client: OkHttpClient by lazy {
OkHttpClient.Builder()
.addInterceptor(authInterceptor)
.addInterceptor(
HttpLoggingInterceptor().apply { level = HttpLoggingInterceptor.Level.BASIC }
)
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(60, TimeUnit.SECONDS)
.build()
}
@Volatile private var cached: Pair<String, CalendarrApi>? = null
/** API bound to the currently stored server URL. Requires a configured server. */
fun api(): CalendarrApi {
val base = normalize(credentialStore.serverUrl ?: error("No server URL configured"))
cached?.let { (url, api) -> if (url == base) return api }
val api = build(base)
cached = base to api
return api
}
/** API for an explicit base URL (used during server setup / login). */
fun apiFor(baseUrl: String): CalendarrApi = build(normalize(baseUrl))
/** Force the cached instance to be rebuilt (e.g. after switching servers). */
fun invalidate() { cached = null }
private fun build(baseUrl: String): CalendarrApi =
Retrofit.Builder()
.baseUrl(baseUrl)
.client(client)
.addConverterFactory(MoshiConverterFactory.create(moshi))
.build()
.create(CalendarrApi::class.java)
companion object {
/** Ensure https:// prefix, strip trailing slashes, then add exactly one. */
fun normalize(raw: String): String {
var url = raw.trim()
if (url.isEmpty()) return url
if (!url.startsWith("http://") && !url.startsWith("https://")) {
url = "https://$url"
}
url = url.trimEnd('/')
return "$url/"
}
}
}

View File

@@ -0,0 +1,27 @@
package com.scarriffle.calendarr.data.remote
import com.scarriffle.calendarr.data.CredentialStore
import okhttp3.Interceptor
import okhttp3.Response
import javax.inject.Inject
/**
* Attaches the current bearer token (read dynamically from the
* [CredentialStore]) to every request. Requests made before login simply
* carry no Authorization header.
*/
class AuthInterceptor @Inject constructor(
private val credentialStore: CredentialStore,
) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val token = credentialStore.token
val request = if (!token.isNullOrBlank()) {
chain.request().newBuilder()
.header("Authorization", "Bearer $token")
.build()
} else {
chain.request()
}
return chain.proceed(request)
}
}

View File

@@ -0,0 +1,181 @@
package com.scarriffle.calendarr.data.remote
import com.scarriffle.calendarr.domain.model.AppSettings
import com.scarriffle.calendarr.domain.model.CalDAVAccount
import com.scarriffle.calendarr.domain.model.GoogleAccount
import com.scarriffle.calendarr.domain.model.HomeAssistantAccount
import com.scarriffle.calendarr.domain.model.ICalSubscription
import com.scarriffle.calendarr.domain.model.LocalCalendar
import com.scarriffle.calendarr.domain.model.UserProfile
import okhttp3.RequestBody
import okhttp3.ResponseBody
import retrofit2.Response
import retrofit2.http.Body
import retrofit2.http.DELETE
import retrofit2.http.GET
import retrofit2.http.HTTP
import retrofit2.http.PATCH
import retrofit2.http.POST
import retrofit2.http.PUT
import retrofit2.http.Path
import retrofit2.http.Query
/**
* Retrofit definition of the Calendarr server HTTP API.
*
* Endpoints whose responses contain mixed-type fields (event `id` /
* `calendar_id` arrive as either String or Int) return a raw [ResponseBody]
* and are parsed manually with org.json, mirroring the iOS client.
*/
interface CalendarrApi {
// ---- Auth ----
@GET("api/auth/setup-required")
suspend fun setupRequired(): Response<ResponseBody>
@POST("api/auth/login")
suspend fun login(@Body body: RequestBody): Response<ResponseBody>
@GET("api/auth/me")
suspend fun getProfile(): UserProfile
// ---- Settings ----
@GET("api/settings/")
suspend fun getSettings(): AppSettings
@PUT("api/settings/")
suspend fun updateSettings(@Body body: RequestBody): Response<ResponseBody>
// ---- Profile ----
@PATCH("api/profile/")
suspend fun updateEmail(@Body body: RequestBody): Response<ResponseBody>
@POST("api/profile/password")
suspend fun changePassword(@Body body: RequestBody): Response<ResponseBody>
@POST("api/profile/2fa/setup")
suspend fun setup2fa(): Response<ResponseBody>
@POST("api/profile/2fa/enable")
suspend fun enable2fa(@Body body: RequestBody): Response<ResponseBody>
@POST("api/profile/2fa/disable")
suspend fun disable2fa(@Body body: RequestBody): Response<ResponseBody>
// ---- CalDAV ----
@GET("api/caldav/accounts")
suspend fun getCalDAVAccounts(): List<CalDAVAccount>
@POST("api/caldav/accounts")
suspend fun addCalDAVAccount(@Body body: RequestBody): CalDAVAccount
@DELETE("api/caldav/accounts/{id}")
suspend fun deleteCalDAVAccount(@Path("id") id: Int): Response<ResponseBody>
@POST("api/caldav/accounts/{id}/sync")
suspend fun syncCalDAVAccount(@Path("id") id: Int): Response<ResponseBody>
@PUT("api/caldav/calendars/{id}")
suspend fun updateCalDAVCalendar(@Path("id") id: Int, @Body body: RequestBody): Response<ResponseBody>
// ---- Local ----
@GET("api/local/calendars")
suspend fun getLocalCalendars(): List<LocalCalendar>
@POST("api/local/calendars")
suspend fun addLocalCalendar(@Body body: RequestBody): LocalCalendar
@DELETE("api/local/calendars/{id}")
suspend fun deleteLocalCalendar(@Path("id") id: Int): Response<ResponseBody>
// ---- iCal subscriptions ----
@GET("api/ical/subscriptions")
suspend fun getICalSubscriptions(): List<ICalSubscription>
@POST("api/ical/subscriptions")
suspend fun addICalSubscription(@Body body: RequestBody): ICalSubscription
@DELETE("api/ical/subscriptions/{id}")
suspend fun deleteICalSubscription(@Path("id") id: Int): Response<ResponseBody>
@POST("api/ical/subscriptions/{id}/refresh")
suspend fun refreshICalSubscription(@Path("id") id: Int): Response<ResponseBody>
// ---- Google ----
@GET("api/google/accounts")
suspend fun getGoogleAccounts(): List<GoogleAccount>
@DELETE("api/google/accounts/{id}")
suspend fun deleteGoogleAccount(@Path("id") id: Int): Response<ResponseBody>
@PUT("api/google/calendars/{id}")
suspend fun updateGoogleCalendar(@Path("id") id: Int, @Body body: RequestBody): Response<ResponseBody>
// ---- Home Assistant ----
@GET("api/homeassistant/accounts")
suspend fun getHomeAssistantAccounts(): List<HomeAssistantAccount>
@POST("api/homeassistant/accounts")
suspend fun addHomeAssistantAccount(@Body body: RequestBody): HomeAssistantAccount
@DELETE("api/homeassistant/accounts/{id}")
suspend fun deleteHomeAssistantAccount(@Path("id") id: Int): Response<ResponseBody>
@PUT("api/homeassistant/calendars/{id}")
suspend fun updateHACalendar(@Path("id") id: Int, @Body body: RequestBody): Response<ResponseBody>
// ---- Events ----
@GET("api/caldav/events")
suspend fun fetchEvents(
@Query("start") start: String,
@Query("end") end: String,
): Response<ResponseBody>
@POST("api/local/events")
suspend fun createLocalEvent(@Body body: RequestBody): Response<ResponseBody>
@PUT("api/local/events/{uid}")
suspend fun updateLocalEvent(@Path("uid") uid: String, @Body body: RequestBody): Response<ResponseBody>
@DELETE("api/local/events/{uid}")
suspend fun deleteLocalEvent(@Path("uid") uid: String): Response<ResponseBody>
@POST("api/caldav/events")
suspend fun createCalDAVEvent(@Body body: RequestBody): Response<ResponseBody>
@PUT("api/caldav/events/{uid}")
suspend fun updateCalDAVEvent(
@Path("uid") uid: String,
@Query("event_url") eventUrl: String,
@Query("calendar_id") calendarId: Int?,
@Body body: RequestBody,
): Response<ResponseBody>
@HTTP(method = "DELETE", path = "api/caldav/events/{uid}", hasBody = false)
suspend fun deleteCalDAVEvent(
@Path("uid") uid: String,
@Query("event_url") eventUrl: String,
@Query("calendar_id") calendarId: Int?,
): Response<ResponseBody>
@POST("api/google/events")
suspend fun createGoogleEvent(@Body body: RequestBody): Response<ResponseBody>
@POST("api/homeassistant/events")
suspend fun createHAEvent(@Body body: RequestBody): Response<ResponseBody>
@DELETE("api/homeassistant/events/{calendarId}/{uid}")
suspend fun deleteHAEvent(
@Path("calendarId") calendarId: Int,
@Path("uid") uid: String,
): Response<ResponseBody>
}

View File

@@ -0,0 +1,26 @@
package com.scarriffle.calendarr.data.remote
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.RequestBody
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONObject
private val JSON = "application/json; charset=utf-8".toMediaType()
/** Build a JSON request body from key/value pairs, dropping null values. */
fun jsonBody(vararg pairs: Pair<String, Any?>): RequestBody {
val obj = JSONObject()
for ((k, v) in pairs) {
if (v != null) obj.put(k, v)
}
return obj.toString().toRequestBody(JSON)
}
/** Build a JSON request body from a map, dropping null values. */
fun jsonBody(map: Map<String, Any?>): RequestBody {
val obj = JSONObject()
for ((k, v) in map) {
if (v != null) obj.put(k, v)
}
return obj.toString().toRequestBody(JSON)
}