diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml
index 7a084140..a68fd3cb 100644
--- a/gradle/verification-metadata.xml
+++ b/gradle/verification-metadata.xml
@@ -76,6 +76,7 @@
+
@@ -97,6 +98,7 @@
+
@@ -104,6 +106,11 @@
+
+
+
+
+
@@ -173,6 +180,7 @@
+
@@ -244,6 +252,7 @@
+
@@ -280,6 +289,7 @@
+
@@ -295,6 +305,7 @@
+
@@ -334,6 +345,11 @@
+
+
+
+
+
@@ -19360,6 +19376,11 @@
+
+
+
+
+
@@ -20855,6 +20876,11 @@
+
+
+
+
+
diff --git a/material-color-utilities/build.gradle b/material-color-utilities/build.gradle
index 83e301dd..119c41b4 100644
--- a/material-color-utilities/build.gradle
+++ b/material-color-utilities/build.gradle
@@ -1,3 +1,4 @@
+
/*
* Nextcloud Android Common Library
*
diff --git a/sample/build.gradle b/sample/build.gradle
index 72fd3597..f5fc90ba 100644
--- a/sample/build.gradle
+++ b/sample/build.gradle
@@ -45,6 +45,7 @@ dependencies {
implementation 'androidx.constraintlayout:constraintlayout:2.2.1'
implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.11.0'
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.11.0'
+ implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0'
implementation project(path: ':ui')
testImplementation 'junit:junit:4.13.2'
androidTestImplementation 'androidx.test.ext:junit:1.3.0'
diff --git a/sample/src/main/AndroidManifest.xml b/sample/src/main/AndroidManifest.xml
index bc5dab68..153a8ae0 100644
--- a/sample/src/main/AndroidManifest.xml
+++ b/sample/src/main/AndroidManifest.xml
@@ -12,6 +12,7 @@
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:supportsRtl="true"
+ android:usesCleartextTraffic="true"
android:theme="@style/Theme.Androidcommon">
+ Toast.makeText(this, result, Toast.LENGTH_LONG).show()
+ }
+
setSupportActionBar(binding.toolbar)
supportActionBar?.setDisplayHomeAsUpEnabled(true)
mainViewModel.color.observe(this) { applyTheme(it) }
diff --git a/sample/src/main/java/com/nextcloud/android/common/sample/MainViewModel.kt b/sample/src/main/java/com/nextcloud/android/common/sample/MainViewModel.kt
index 457600c7..58e6bdf4 100644
--- a/sample/src/main/java/com/nextcloud/android/common/sample/MainViewModel.kt
+++ b/sample/src/main/java/com/nextcloud/android/common/sample/MainViewModel.kt
@@ -9,7 +9,41 @@ package com.nextcloud.android.common.sample
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.nextcloud.android.common.ui.network.auth.ServerCredentials
+import com.nextcloud.android.common.ui.network.model.NetworkResult
+import com.nextcloud.android.common.ui.network.http.NextcloudHttpClient
+import com.nextcloud.android.common.ui.network.UserStatusRepository
+import kotlinx.coroutines.launch
class MainViewModel : ViewModel() {
val color = MutableLiveData()
+ val apiTestResult = MutableLiveData()
+
+ fun testPredefinedStatuses(
+ baseUrl: String,
+ username: String,
+ token: String
+ ) {
+ viewModelScope.launch {
+ val credentials = ServerCredentials(baseUrl, username, token)
+ val client = NextcloudHttpClient.create(credentials)
+ val service = UserStatusRepository(client)
+
+ when (val result = service.fetchPredefinedStatuses()) {
+ is NetworkResult.Success ->
+ apiTestResult.value =
+ "✅ Success (${result.data.size} statuses):\n" +
+ result.data.joinToString("\n") { "${it.icon} ${it.message}" }
+
+ is NetworkResult.ServerError ->
+ apiTestResult.value =
+ "❌ Error ${result.response.ocs.meta.statusCode}: ${result.response.ocs.meta.message}"
+
+ is NetworkResult.NetworkException ->
+ apiTestResult.value =
+ "❌ Exception: ${result.throwable.message}"
+ }
+ }
+ }
}
diff --git a/sample/src/main/res/layout/activity_main.xml b/sample/src/main/res/layout/activity_main.xml
index 23447605..2e4f232b 100644
--- a/sample/src/main/res/layout/activity_main.xml
+++ b/sample/src/main/res/layout/activity_main.xml
@@ -193,6 +193,67 @@
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/circular_progress_bar" />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/sample/src/main/res/values/strings.xml b/sample/src/main/res/values/strings.xml
index 1b957aa2..f7db289e 100644
--- a/sample/src/main/res/values/strings.xml
+++ b/sample/src/main/res/values/strings.xml
@@ -19,6 +19,10 @@
Suggestion Chip
Filter Chip
Color
+ Base URL
+ Username
+ App token
+ Test User Status API
Theming
UI Module
\ No newline at end of file
diff --git a/ui/build.gradle b/ui/build.gradle
index 3d1b4d99..9c6c77a3 100644
--- a/ui/build.gradle
+++ b/ui/build.gradle
@@ -7,6 +7,7 @@
plugins {
id 'org.jetbrains.kotlin.plugin.compose'
+ id 'org.jetbrains.kotlin.plugin.serialization' version "$kotlinVersion"
id 'com.android.library'
id 'com.android.built-in-kotlin'
id 'com.android.legacy-kapt'
@@ -15,7 +16,7 @@ plugins {
android {
defaultConfig {
- minSdk = 21
+ minSdk = 26
compileSdk = 37
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
@@ -23,6 +24,7 @@ android {
buildFeatures {
compose = true
+ buildConfig = true
}
compileOptions {
@@ -45,12 +47,21 @@ android {
}
dependencies {
+ // region Jetpack Compose
+ implementation 'androidx.compose.ui:ui-tooling-preview:1.11.4'
+ debugImplementation 'androidx.compose.ui:ui-tooling:1.11.4'
kapt "org.jetbrains.kotlin:kotlin-metadata-jvm:${kotlinVersion}"
implementation(platform("androidx.compose:compose-bom:2026.06.01"))
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.ui:ui-graphics")
implementation("androidx.compose.material3:material3")
+ implementation("androidx.compose.material:material-icons-core")
+ // endregion
+
+ implementation("io.coil-kt.coil3:coil-compose:3.5.0")
+ implementation("io.coil-kt.coil3:coil-network-okhttp:3.5.0")
+ implementation("io.coil-kt.coil3:coil-svg:3.5.0")
implementation("com.vanniktech:ui:0.10.0")
@@ -60,6 +71,14 @@ dependencies {
androidTestImplementation 'androidx.test.ext:junit:1.3.0'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.7.0'
+ // region okhttp
+ implementation(platform("com.squareup.okhttp3:okhttp-bom:5.4.0"))
+ implementation("com.squareup.okhttp3:okhttp")
+ implementation("com.squareup.okhttp3:logging-interceptor")
+ // endregion
+
+ implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.11.0")
+
implementation project(':core')
api project(':material-color-utilities')
diff --git a/ui/src/main/AndroidManifest.xml b/ui/src/main/AndroidManifest.xml
index b830d265..447589b1 100644
--- a/ui/src/main/AndroidManifest.xml
+++ b/ui/src/main/AndroidManifest.xml
@@ -5,6 +5,6 @@
~ SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
~ SPDX-License-Identifier: MIT
-->
-
+
diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/component/ContentUnavailableView.kt b/ui/src/main/java/com/nextcloud/android/common/ui/component/ContentUnavailableView.kt
new file mode 100644
index 00000000..3d28ffc2
--- /dev/null
+++ b/ui/src/main/java/com/nextcloud/android/common/ui/component/ContentUnavailableView.kt
@@ -0,0 +1,64 @@
+/*
+ * Nextcloud Android Common Library
+ *
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: MIT
+ */
+
+package com.nextcloud.android.common.ui.component
+
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+
+@Composable
+fun ContentUnavailableView(title: String, description: String? = null, iconId: Int? = null) {
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(16.dp),
+ verticalArrangement = Arrangement.Center,
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ iconId?.let {
+ Image(
+ painter = painterResource(iconId),
+ modifier = Modifier.size(48.dp),
+ contentDescription = "",
+ )
+ }
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ Text(
+ text = title,
+ style = MaterialTheme.typography.headlineMedium,
+ textAlign = TextAlign.Center
+ )
+
+ description?.let {
+ Spacer(modifier = Modifier.height(8.dp))
+
+ Text(
+ text = description,
+ style = MaterialTheme.typography.headlineSmall,
+ textAlign = TextAlign.Center
+ )
+ }
+
+ Spacer(modifier = Modifier.height(32.dp))
+ }
+}
diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/network/PredefinedStatus.kt b/ui/src/main/java/com/nextcloud/android/common/ui/network/PredefinedStatus.kt
new file mode 100644
index 00000000..bc1eb79b
--- /dev/null
+++ b/ui/src/main/java/com/nextcloud/android/common/ui/network/PredefinedStatus.kt
@@ -0,0 +1,43 @@
+/*
+ * Nextcloud Android Common Library
+ *
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: MIT
+ */
+
+package com.nextcloud.android.common.ui.network
+
+import kotlinx.serialization.KSerializer
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.descriptors.PrimitiveKind
+import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
+import kotlinx.serialization.encoding.Decoder
+import kotlinx.serialization.encoding.Encoder
+import kotlinx.serialization.json.JsonDecoder
+import kotlinx.serialization.json.JsonPrimitive
+
+object ClearAtTimeSerializer : KSerializer {
+ override val descriptor = PrimitiveSerialDescriptor("ClearAtTime", PrimitiveKind.STRING)
+
+ override fun deserialize(decoder: Decoder): String {
+ val jsonDecoder = decoder as? JsonDecoder ?: return decoder.decodeString()
+ return (jsonDecoder.decodeJsonElement() as? JsonPrimitive)?.content ?: ""
+ }
+
+ override fun serialize(encoder: Encoder, value: String) = encoder.encodeString(value)
+}
+
+@Serializable
+data class ClearAt(
+ val type: String,
+ @Serializable(with = ClearAtTimeSerializer::class)
+ val time: String
+)
+
+@Serializable
+data class PredefinedStatus(
+ val id: String,
+ val icon: String,
+ val message: String,
+ val clearAt: ClearAt? = null
+)
diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/network/UserStatusRepository.kt b/ui/src/main/java/com/nextcloud/android/common/ui/network/UserStatusRepository.kt
new file mode 100644
index 00000000..7c36bdf9
--- /dev/null
+++ b/ui/src/main/java/com/nextcloud/android/common/ui/network/UserStatusRepository.kt
@@ -0,0 +1,30 @@
+/*
+ * Nextcloud Android Common Library
+ *
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: MIT
+ */
+
+package com.nextcloud.android.common.ui.network
+
+import com.nextcloud.android.common.ui.network.http.HttpMethod
+import com.nextcloud.android.common.ui.network.http.NextcloudHttpClient
+import com.nextcloud.android.common.ui.network.model.NetworkResult
+import com.nextcloud.android.common.ui.network.model.OcsResponse
+import com.nextcloud.android.common.ui.network.serialization.OCSSerializer
+
+class UserStatusRepository(private val client: NextcloudHttpClient) {
+
+ private companion object {
+ private const val PREDEFINED_STATUSES_ENDPOINT =
+ "/ocs/v2.php/apps/user_status/api/v1/predefined_statuses"
+ }
+
+ suspend fun fetchPredefinedStatuses(): NetworkResult> =
+ client.executeRequest(
+ endpoint = PREDEFINED_STATUSES_ENDPOINT,
+ method = HttpMethod.GET
+ ) { body ->
+ OCSSerializer.json.decodeFromString>>(body).ocs.data
+ }
+}
diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/network/auth/AuthInterceptor.kt b/ui/src/main/java/com/nextcloud/android/common/ui/network/auth/AuthInterceptor.kt
new file mode 100644
index 00000000..07c7b485
--- /dev/null
+++ b/ui/src/main/java/com/nextcloud/android/common/ui/network/auth/AuthInterceptor.kt
@@ -0,0 +1,49 @@
+/*
+ * Nextcloud Android Common Library
+ *
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: MIT
+ */
+
+package com.nextcloud.android.common.ui.network.auth
+
+import okhttp3.Credentials
+import okhttp3.Interceptor
+import okhttp3.Response
+
+class AuthInterceptor(private val credentials: ServerCredentials) : Interceptor {
+
+ private companion object {
+ private const val HTTP_PREFIX = "http://"
+ private const val HTTPS_PREFIX = "https://"
+ private const val DELIMITER = '/'
+
+ private const val HEADER_AUTHORIZATION = "Authorization"
+ private const val HEADER_OCS_REQUEST = "OCS-APIRequest"
+ private const val HEADER_OCS_REQUEST_VALUE = "true"
+ }
+
+ override fun intercept(chain: Interceptor.Chain): Response {
+ val basicCredentials = Credentials.basic(credentials.username, credentials.token)
+
+ val request = chain.request()
+ .newBuilder()
+ .header(HEADER_AUTHORIZATION, basicCredentials)
+ .header(HEADER_OCS_REQUEST, HEADER_OCS_REQUEST_VALUE)
+ .url(resolveUrl(chain.request().url.toString()))
+ .build()
+
+ return chain.proceed(request)
+ }
+
+ /**
+ * Prepends [ServerCredentials.baseURL] to relative URLs.
+ * Absolute URLs (starting with http/https) are passed through unchanged.
+ */
+ private fun resolveUrl(requestUrl: String): String =
+ if (requestUrl.startsWith(HTTP_PREFIX) || requestUrl.startsWith(HTTPS_PREFIX)) {
+ requestUrl
+ } else {
+ "${credentials.baseURL.trimEnd(DELIMITER)}/${requestUrl.trimStart(DELIMITER)}"
+ }
+}
diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/network/auth/ServerCredentials.kt b/ui/src/main/java/com/nextcloud/android/common/ui/network/auth/ServerCredentials.kt
new file mode 100644
index 00000000..b299353c
--- /dev/null
+++ b/ui/src/main/java/com/nextcloud/android/common/ui/network/auth/ServerCredentials.kt
@@ -0,0 +1,14 @@
+/*
+ * Nextcloud Android Common Library
+ *
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: MIT
+ */
+
+package com.nextcloud.android.common.ui.network.auth
+
+data class ServerCredentials(
+ val baseURL: String,
+ val username: String,
+ val token: String
+)
\ No newline at end of file
diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/network/http/HttpMethod.kt b/ui/src/main/java/com/nextcloud/android/common/ui/network/http/HttpMethod.kt
new file mode 100644
index 00000000..f5901b8d
--- /dev/null
+++ b/ui/src/main/java/com/nextcloud/android/common/ui/network/http/HttpMethod.kt
@@ -0,0 +1,15 @@
+/*
+ * Nextcloud Android Common Library
+ *
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: MIT
+ */
+
+package com.nextcloud.android.common.ui.network.http
+
+enum class HttpMethod(val type: String) {
+ GET("GET"),
+ POST("POST"),
+ PUT("PUT"),
+ DELETE("DELETE")
+}
\ No newline at end of file
diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/network/http/NextcloudHttpClient.kt b/ui/src/main/java/com/nextcloud/android/common/ui/network/http/NextcloudHttpClient.kt
new file mode 100644
index 00000000..29b462e6
--- /dev/null
+++ b/ui/src/main/java/com/nextcloud/android/common/ui/network/http/NextcloudHttpClient.kt
@@ -0,0 +1,87 @@
+/*
+ * Nextcloud Android Common Library
+ *
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: MIT
+ */
+
+package com.nextcloud.android.common.ui.network.http
+
+import com.nextcloud.android.common.ui.BuildConfig
+import com.nextcloud.android.common.ui.network.auth.AuthInterceptor
+import com.nextcloud.android.common.ui.network.auth.ServerCredentials
+import com.nextcloud.android.common.ui.network.model.NetworkResult
+import com.nextcloud.android.common.ui.network.model.Ocs
+import com.nextcloud.android.common.ui.network.model.OcsResponse
+import com.nextcloud.android.common.ui.network.serialization.OCSSerializer
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import kotlinx.serialization.json.JsonElement
+import okhttp3.OkHttpClient
+import okhttp3.RequestBody
+import java.util.concurrent.TimeUnit
+
+class NextcloudHttpClient private constructor(
+ val okHttpClient: OkHttpClient,
+ val credentials: ServerCredentials,
+ private val debugLogger: NextcloudHttpClientLogger
+) {
+ companion object {
+ private const val CONNECT_TIMEOUT_SECONDS = 90L
+ private const val READ_TIMEOUT_SECONDS = 90L
+ private const val WRITE_TIMEOUT_SECONDS = 90L
+ private const val OCS_OK = "ok"
+
+ fun create(
+ credentials: ServerCredentials
+ ): NextcloudHttpClient {
+ val okHttpClient = OkHttpClient.Builder()
+ .connectTimeout(CONNECT_TIMEOUT_SECONDS, TimeUnit.SECONDS)
+ .readTimeout(READ_TIMEOUT_SECONDS, TimeUnit.SECONDS)
+ .writeTimeout(WRITE_TIMEOUT_SECONDS, TimeUnit.SECONDS)
+ .addInterceptor(AuthInterceptor(credentials))
+ .build()
+
+ return NextcloudHttpClient(okHttpClient, credentials, NextcloudHttpClientLogger(BuildConfig.DEBUG))
+ }
+ }
+
+ private val baseUrl get() = credentials.baseURL.trimEnd('/')
+
+ suspend fun executeRequest(
+ endpoint: String,
+ method: HttpMethod,
+ body: RequestBody? = null,
+ parse: (String) -> T
+ ): NetworkResult = withContext(Dispatchers.IO) {
+ val request = buildOcsRequest("$baseUrl$endpoint", method, body)
+ try {
+ val response = okHttpClient.newCall(request).execute()
+ val responseBody = response.body.string()
+
+ if (!response.isSuccessful) {
+ debugLogger.logResponse(endpoint, method, response.code, responseBody, isError = true)
+ return@withContext NetworkResult.ServerError(
+ OCSSerializer.json.decodeFromString>(responseBody)
+ )
+ }
+
+ if (responseBody.isBlank()) {
+ debugLogger.logResponse(endpoint, method, response.code, responseBody, isError = false)
+ return@withContext NetworkResult.Success(parse(responseBody))
+ }
+
+ val envelope = OCSSerializer.json.decodeFromString>(responseBody)
+ if (envelope.ocs.meta.status != OCS_OK) {
+ debugLogger.logResponse(endpoint, method, response.code, responseBody, isError = true)
+ NetworkResult.ServerError(OcsResponse(Ocs(envelope.ocs.meta, envelope.ocs.meta.message)))
+ } else {
+ debugLogger.logResponse(endpoint, method, response.code, responseBody, isError = false)
+ NetworkResult.Success(parse(responseBody))
+ }
+ } catch (e: Exception) {
+ debugLogger.logException(endpoint, method, e)
+ NetworkResult.fromException(e)
+ }
+ }
+}
diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/network/http/NextcloudHttpClientLogger.kt b/ui/src/main/java/com/nextcloud/android/common/ui/network/http/NextcloudHttpClientLogger.kt
new file mode 100644
index 00000000..98d66e0c
--- /dev/null
+++ b/ui/src/main/java/com/nextcloud/android/common/ui/network/http/NextcloudHttpClientLogger.kt
@@ -0,0 +1,92 @@
+/*
+ * Nextcloud Android Common Library
+ *
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: MIT
+ */
+
+package com.nextcloud.android.common.ui.network.http
+
+import android.util.Log
+import kotlinx.serialization.json.Json
+import okhttp3.RequestBody
+import okio.Buffer
+
+internal class NextcloudHttpClientLogger(private val enabled: Boolean) {
+
+ companion object {
+ private const val TAG = "NextcloudHttpClient"
+
+ private val prettyJson = Json {
+ prettyPrint = true
+ prettyPrintIndent = " "
+ ignoreUnknownKeys = true
+ }
+ }
+
+ fun logRequest(endpoint: String, method: HttpMethod, body: RequestBody?) {
+ if (!enabled) return
+
+ Log.d(
+ TAG,
+ """
+ |➡️ REQUEST [$method] $endpoint
+ |Body:
+ |${body.readableString().prettyPrintJsonOrRaw()}
+ """.trimMargin()
+ )
+ }
+
+ fun logResponse(
+ endpoint: String,
+ method: HttpMethod,
+ code: Int,
+ responseBody: String,
+ isError: Boolean
+ ) {
+ if (!enabled) return
+
+ Log.d(
+ TAG,
+ """
+ |${statusEmojiFor(code, isError)} RESPONSE [$method] $endpoint (HTTP $code)
+ |Body:
+ |${responseBody.ifBlank { "" }.prettyPrintJsonOrRaw()}
+ """.trimMargin()
+ )
+ }
+
+ fun logException(endpoint: String, method: HttpMethod, e: Exception) {
+ if (!enabled) return
+
+ Log.e(TAG, "💥 EXCEPTION [$method] $endpoint -> ${e.javaClass.simpleName}: ${e.message}", e)
+ }
+
+ private fun statusEmojiFor(code: Int, isError: Boolean): String = when {
+ isError -> "🔴"
+ code in 200..299 -> "🟢"
+ code in 300..399 -> "🟡"
+ else -> "🔴"
+ }
+
+ private fun RequestBody?.readableString(): String {
+ if (this == null) return ""
+ return try {
+ val buffer = Buffer()
+ writeTo(buffer)
+ buffer.readUtf8()
+ } catch (e: Exception) {
+ ""
+ }
+ }
+
+ private fun String.prettyPrintJsonOrRaw(): String {
+ if (this.isBlank() || this == "" || this == "") return this
+ return try {
+ val element = Json.parseToJsonElement(this)
+ prettyJson.encodeToString(kotlinx.serialization.json.JsonElement.serializer(), element)
+ } catch (_: Exception) {
+ this
+ }
+ }
+}
diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/network/http/RequestBuilder.kt b/ui/src/main/java/com/nextcloud/android/common/ui/network/http/RequestBuilder.kt
new file mode 100644
index 00000000..7e7560cd
--- /dev/null
+++ b/ui/src/main/java/com/nextcloud/android/common/ui/network/http/RequestBuilder.kt
@@ -0,0 +1,27 @@
+/*
+ * Nextcloud Android Common Library
+ *
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: MIT
+ */
+
+package com.nextcloud.android.common.ui.network.http
+
+import okhttp3.MediaType.Companion.toMediaType
+import okhttp3.Request
+import okhttp3.RequestBody
+
+val JSON_CONTENT_TYPE = "application/json; charset=utf-8".toMediaType()
+
+private const val HEADER_ACCEPT = "Accept"
+private const val HEADER_ACCEPT_VALUE = "application/json"
+
+fun buildOcsRequest(
+ url: String,
+ method: HttpMethod,
+ body: RequestBody? = null
+): Request = Request.Builder()
+ .url(url)
+ .header(HEADER_ACCEPT, HEADER_ACCEPT_VALUE)
+ .method(method.type, body)
+ .build()
diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/network/model/NetworkResult.kt b/ui/src/main/java/com/nextcloud/android/common/ui/network/model/NetworkResult.kt
new file mode 100644
index 00000000..ef05856b
--- /dev/null
+++ b/ui/src/main/java/com/nextcloud/android/common/ui/network/model/NetworkResult.kt
@@ -0,0 +1,32 @@
+/*
+ * Nextcloud Android Common Library
+ *
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: MIT
+ */
+
+package com.nextcloud.android.common.ui.network.model
+
+sealed class NetworkResult {
+ data class Success(val data: T) : NetworkResult()
+
+ data class ServerError(val response: OcsResponse) : NetworkResult()
+
+ data class NetworkException(val throwable: Throwable) : NetworkResult()
+
+ val isSuccess: Boolean get() = this is Success
+
+ companion object {
+ fun fromException(e: Throwable): NetworkException = NetworkException(e)
+ }
+}
+
+inline fun NetworkResult.dataOrElse(onError: () -> Unit): T? =
+ when (this) {
+ is NetworkResult.Success -> data
+ is NetworkResult.ServerError,
+ is NetworkResult.NetworkException -> {
+ onError()
+ null
+ }
+ }
diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/network/model/OcsResponse.kt b/ui/src/main/java/com/nextcloud/android/common/ui/network/model/OcsResponse.kt
new file mode 100644
index 00000000..0b5c39db
--- /dev/null
+++ b/ui/src/main/java/com/nextcloud/android/common/ui/network/model/OcsResponse.kt
@@ -0,0 +1,38 @@
+/*
+ * Nextcloud Android Common Library
+ *
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: MIT
+ */
+
+package com.nextcloud.android.common.ui.network.model
+
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class OcsResponse(
+ val ocs: Ocs
+)
+
+@Serializable
+data class Ocs(
+ val meta: Meta,
+ val data: T
+)
+
+@Serializable
+data class Meta(
+ val status: String,
+
+ @SerialName("statuscode")
+ val statusCode: Int,
+
+ val message: String,
+
+ @SerialName("totalitems")
+ val totalItems: String = "",
+
+ @SerialName("itemsperpage")
+ val itemsPerPage: String = ""
+)
diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/network/serialization/OCSSerializer.kt b/ui/src/main/java/com/nextcloud/android/common/ui/network/serialization/OCSSerializer.kt
new file mode 100644
index 00000000..aae4ab75
--- /dev/null
+++ b/ui/src/main/java/com/nextcloud/android/common/ui/network/serialization/OCSSerializer.kt
@@ -0,0 +1,14 @@
+/*
+ * Nextcloud Android Common Library
+ *
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: MIT
+ */
+
+package com.nextcloud.android.common.ui.network.serialization
+
+import kotlinx.serialization.json.Json
+
+object OCSSerializer {
+ val json = Json { ignoreUnknownKeys = true }
+}
diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareScreen.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareScreen.kt
new file mode 100644
index 00000000..f5411ae4
--- /dev/null
+++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareScreen.kt
@@ -0,0 +1,379 @@
+/*
+ * Nextcloud Android Common Library
+ *
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: MIT
+ */
+
+package com.nextcloud.android.common.ui.share
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.combinedClickable
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.offset
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.itemsIndexed
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.Send
+import androidx.compose.material.icons.filled.Delete
+import androidx.compose.material.icons.filled.Edit
+import androidx.compose.material.icons.filled.KeyboardArrowDown
+import androidx.compose.material.icons.filled.MoreVert
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.ColorScheme
+import androidx.compose.material3.DropdownMenu
+import androidx.compose.material3.DropdownMenuItem
+import androidx.compose.material3.FloatingActionButton
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.ListItem
+import androidx.compose.material3.ListItemDefaults
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.SnackbarHost
+import androidx.compose.material3.SnackbarHostState
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.hapticfeedback.HapticFeedbackType
+import androidx.compose.ui.platform.ComposeView
+import androidx.compose.ui.platform.LocalHapticFeedback
+import androidx.compose.ui.platform.LocalResources
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import coil3.ImageLoader
+import coil3.compose.setSingletonImageLoaderFactory
+import coil3.svg.SvgDecoder
+import com.nextcloud.android.common.ui.R
+import com.nextcloud.android.common.ui.component.ContentUnavailableView
+import com.nextcloud.android.common.ui.network.auth.ServerCredentials
+import com.nextcloud.android.common.ui.network.http.NextcloudHttpClient
+import com.nextcloud.android.common.ui.share.component.RecipientIcon
+import com.nextcloud.android.common.ui.share.component.bottomsheet.AddOrEditShareBottomSheet
+import com.nextcloud.android.common.ui.share.component.bottomsheet.QuickSharePermissionBottomSheet
+import com.nextcloud.android.common.ui.share.component.dialog.DeleteShareConfirmationDialog
+import com.nextcloud.android.common.ui.share.model.api.permission.PermissionPreset
+import com.nextcloud.android.common.ui.share.model.api.share.Share
+import com.nextcloud.android.common.ui.share.model.ui.PermissionPresetOption
+import com.nextcloud.android.common.ui.share.model.ui.ShareItemOverlayState
+import com.nextcloud.android.common.ui.share.model.ui.ShareItemType
+import com.nextcloud.android.common.ui.share.model.ui.ShareScreenState
+import com.nextcloud.android.common.ui.share.model.ui.filtered
+import com.nextcloud.android.common.ui.share.repository.ShareRemoteRepository
+import kotlinx.coroutines.launch
+
+@Composable
+private fun ShareScreen(sourceId: String, internalLink: String, viewModel: ShareViewModel) {
+ val errorMessageId by viewModel.errorMessageId.collectAsState()
+ val screenState by viewModel.state.collectAsState()
+ val activeShare by viewModel.activeShare.collectAsState()
+ val scope = rememberCoroutineScope()
+ val snackbarHostState = remember { SnackbarHostState() }
+ val resources = LocalResources.current
+ var editorInitialPreset by remember { mutableStateOf(null) }
+
+ LaunchedEffect(errorMessageId) {
+ errorMessageId?.let {
+ snackbarHostState.showSnackbar(resources.getString(it))
+ viewModel.updateErrorMessage(null)
+ }
+ }
+
+ Scaffold(
+ floatingActionButton = {
+ FloatingActionButton(
+ onClick = {
+ scope.launch {
+ viewModel.createDraftShare()?.let {
+ viewModel.addSource(it.id, sourceId)
+ }
+ }
+ },
+ ) {
+ Icon(painterResource(R.drawable.ic_person_add), contentDescription = "Add")
+ }
+ },
+ snackbarHost = { SnackbarHost(snackbarHostState) },
+ containerColor = Color.Transparent
+ ) { paddingValues ->
+ when (val state = screenState) {
+ is ShareScreenState.Loading -> {
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(paddingValues),
+ contentAlignment = Alignment.Center
+ ) {
+ CircularProgressIndicator()
+ }
+ }
+
+ is ShareScreenState.Empty -> {
+ ContentUnavailableView(
+ iconId = R.drawable.ic_person_add,
+ title = stringResource(R.string.share_view_empty_title),
+ )
+ }
+
+ is ShareScreenState.Loaded -> {
+ LazyColumn(
+ modifier = Modifier
+ .padding(paddingValues)
+ .fillMaxWidth(),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ itemsIndexed(state.shares.filtered(), key = { _, share -> share.id }) { index, share ->
+ val type = ShareItemType.type(index, state.shares.lastIndex)
+
+ if (index == 0) {
+ Spacer(modifier = Modifier.height(16.dp))
+ } else {
+ Spacer(modifier = Modifier.height(2.dp))
+ }
+
+ ShareItem(
+ share = share,
+ type = type,
+ onSelectShare = { selected ->
+ editorInitialPreset = null
+ viewModel.setActiveShare(selected)
+ },
+ onCustomizeShare = { selected ->
+ editorInitialPreset = PermissionPresetOption.CUSTOM
+ viewModel.setActiveShare(selected)
+ },
+ onChangePreset = { selected, preset ->
+ viewModel.updatePermissionPreset(selected.id, preset, updateActiveShare = false)
+ },
+ onDeleteShare = { viewModel.deleteShare(it.id) },
+ onSendEmail = { selected ->
+ editorInitialPreset = null
+ viewModel.setActiveShare(selected)
+ }
+ )
+ }
+ }
+ }
+ }
+ }
+
+ activeShare.shareOrNull?.let { activeShareObject ->
+ AddOrEditShareBottomSheet(
+ share = activeShareObject,
+ internalLink = internalLink,
+ viewModel = viewModel,
+ initialPresetOption = editorInitialPreset,
+ onDismissDraft = { draftShare ->
+ viewModel.deleteShare(draftShare.id)
+ viewModel.setActiveShare(null)
+ }
+ )
+ }
+}
+
+@Composable
+private fun ShareItem(
+ share: Share,
+ type: ShareItemType,
+ onSelectShare: (Share) -> Unit,
+ onCustomizeShare: (Share) -> Unit,
+ onChangePreset: (Share, PermissionPreset) -> Unit,
+ onDeleteShare: (Share) -> Unit,
+ onSendEmail: (Share) -> Unit
+) {
+ var overlayState by remember { mutableStateOf(ShareItemOverlayState.None) }
+ val haptics = LocalHapticFeedback.current
+
+ when (overlayState) {
+ is ShareItemOverlayState.QuickShare -> {
+ QuickSharePermissionBottomSheet(
+ selectedOption = PermissionPresetOption.from(share.permissionPreset),
+ onOptionSelected = { option ->
+ overlayState = ShareItemOverlayState.None
+ val preset = option.preset
+ if (preset != null) {
+ onChangePreset(share, preset)
+ } else {
+ onCustomizeShare(share)
+ }
+ },
+ onDismiss = { overlayState = ShareItemOverlayState.None }
+ )
+ }
+
+ is ShareItemOverlayState.DeleteConfirmation -> {
+ DeleteShareConfirmationDialog(
+ onConfirm = {
+ overlayState = ShareItemOverlayState.None
+ onDeleteShare(share)
+ },
+ onDismiss = { overlayState = ShareItemOverlayState.None }
+ )
+ }
+
+ else -> Unit
+ }
+
+ ListItem(
+ modifier = Modifier
+ .fillMaxWidth(0.9f)
+ .clip(type.getShape())
+ .combinedClickable(
+ onClick = { onSelectShare(share) },
+ onLongClick = {
+ haptics.performHapticFeedback(HapticFeedbackType.LongPress)
+ overlayState = ShareItemOverlayState.ContextMenu
+ }
+ )
+ .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)),
+ headlineContent = {
+ val headline = if (share.recipients.isNotEmpty()) {
+ share.recipients.first().displayName
+ } else {
+ ""
+ }
+
+ Text(
+ text = headline,
+ style = MaterialTheme.typography.titleMedium
+ )
+ },
+ leadingContent = {
+ share.recipients.first().icon?.let { RecipientIcon(icon = it) }
+ },
+ supportingContent = {
+ val chipHorizontalPadding = 10.dp
+ Row(
+ modifier = Modifier
+ .offset(x = -chipHorizontalPadding)
+ .clip(RoundedCornerShape(percent = 50))
+ .clickable { overlayState = ShareItemOverlayState.QuickShare }
+ .background(MaterialTheme.colorScheme.primary.copy(alpha = 0.08f))
+ .padding(horizontal = chipHorizontalPadding, vertical = 2.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ text = stringResource(PermissionPresetOption.from(share.permissionPreset).labelRes),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.primary,
+ )
+
+ Spacer(modifier = Modifier.width(2.dp))
+
+ Icon(
+ imageVector = Icons.Default.KeyboardArrowDown,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.primary,
+ modifier = Modifier.size(18.dp)
+ )
+ }
+ },
+ trailingContent = {
+ Box {
+ IconButton(onClick = { overlayState = ShareItemOverlayState.ContextMenu }) {
+ Icon(Icons.Default.MoreVert, contentDescription = "More options")
+ }
+
+ DropdownMenu(
+ expanded = overlayState == ShareItemOverlayState.ContextMenu,
+ onDismissRequest = { overlayState = ShareItemOverlayState.None }
+ ) {
+ DropdownMenuItem(
+ leadingIcon = {
+ Icon(Icons.Default.Edit,
+ contentDescription = "Edit icon",
+ modifier = Modifier.size(18.dp)
+ )
+ },
+ text = { Text(stringResource(R.string.share_view_list_item_edit)) },
+ onClick = {
+ overlayState = ShareItemOverlayState.None
+ onSelectShare(share)
+ }
+ )
+ DropdownMenuItem(
+ leadingIcon = {
+ Icon(
+ Icons.AutoMirrored.Filled.Send,
+ contentDescription = "Send icon",
+ modifier = Modifier.size(18.dp)
+ )
+ },
+ text = { Text(stringResource(R.string.share_view_list_item_send_email)) },
+ onClick = {
+ onSendEmail(share)
+ overlayState = ShareItemOverlayState.None
+ }
+ )
+ HorizontalDivider()
+ DropdownMenuItem(
+ leadingIcon = {
+ Icon(
+ Icons.Default.Delete,
+ contentDescription = "Delete icon",
+ modifier = Modifier.size(18.dp)
+ )
+ },
+ text = {
+ Text(
+ stringResource(R.string.share_view_list_item_delete),
+ color = MaterialTheme.colorScheme.error
+ )
+ },
+ onClick = {
+ overlayState = ShareItemOverlayState.DeleteConfirmation
+ }
+ )
+ }
+ }
+ },
+ colors = ListItemDefaults.colors(containerColor = Color.Transparent)
+ )
+}
+
+fun ComposeView.initShareScreen(
+ sourceId: String,
+ internalLink: String,
+ credentials: ServerCredentials,
+ colorScheme: ColorScheme
+) {
+ val nextcloudHttpClient = NextcloudHttpClient.create(credentials)
+ val viewModel = ShareViewModel(repository = ShareRemoteRepository(nextcloudHttpClient))
+
+ setContent {
+ setSingletonImageLoaderFactory { context ->
+ ImageLoader.Builder(context)
+ .components { add(SvgDecoder.Factory()) }
+ .build()
+ }
+
+ MaterialTheme(
+ colorScheme = colorScheme,
+ content = {
+ ShareScreen(sourceId, internalLink, viewModel)
+ }
+ )
+ }
+}
diff --git a/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareViewModel.kt b/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareViewModel.kt
new file mode 100644
index 00000000..a32d7bc9
--- /dev/null
+++ b/ui/src/main/java/com/nextcloud/android/common/ui/share/ShareViewModel.kt
@@ -0,0 +1,363 @@
+/*
+ * Nextcloud Android Common Library
+ *
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: MIT
+ */
+
+package com.nextcloud.android.common.ui.share
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.nextcloud.android.common.ui.R
+import com.nextcloud.android.common.ui.network.model.NetworkResult
+import com.nextcloud.android.common.ui.network.model.dataOrElse
+import com.nextcloud.android.common.ui.share.model.api.permission.PermissionPreset
+import com.nextcloud.android.common.ui.share.model.api.recipients.Recipient
+import com.nextcloud.android.common.ui.share.model.api.request.AddRecipientRequest
+import com.nextcloud.android.common.ui.share.model.api.request.AddSourceRequest
+import com.nextcloud.android.common.ui.share.model.api.request.GetShareRequest
+import com.nextcloud.android.common.ui.share.model.api.request.UpdateSharePermissionPresetRequest
+import com.nextcloud.android.common.ui.share.model.api.request.UpdateSharePermissionRequest
+import com.nextcloud.android.common.ui.share.model.api.request.UpdateSharePropertyRequest
+import com.nextcloud.android.common.ui.share.model.api.request.UpdateShareRecipientSecretRequest
+import com.nextcloud.android.common.ui.share.model.api.request.UpdateShareStateRequest
+import com.nextcloud.android.common.ui.share.model.api.share.Share
+import com.nextcloud.android.common.ui.share.model.api.state.ShareState
+import com.nextcloud.android.common.ui.share.model.ui.ActiveShareState
+import com.nextcloud.android.common.ui.share.model.ui.ShareCategory
+import com.nextcloud.android.common.ui.share.model.ui.ShareScreenState
+import com.nextcloud.android.common.ui.share.model.ui.filtered
+import com.nextcloud.android.common.ui.share.repository.ShareRepository
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.FlowPreview
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.debounce
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.filter
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import java.util.UUID
+import kotlin.time.Duration.Companion.milliseconds
+
+class ShareViewModel(
+ private val repository: ShareRepository
+) : ViewModel() {
+
+ companion object {
+ private const val SEARCH_DEBOUNCE_DELAY = 300L
+ private const val PROPERTY_DEBOUNCE_DELAY = 1000L
+ }
+
+ private val _state = MutableStateFlow(ShareScreenState.Loading)
+ val state: StateFlow = _state
+
+ private val _activeShare = MutableStateFlow(ActiveShareState.None)
+ val activeShare: StateFlow = _activeShare
+
+ private val _searchQuery = MutableStateFlow("")
+
+ private val _recipientSearchResults = MutableStateFlow>(emptyList())
+ val recipientSearchResults: StateFlow> = _recipientSearchResults
+
+ private val _errorMessageId = MutableStateFlow(null)
+ val errorMessageId: StateFlow = _errorMessageId
+
+ private val _propertyErrors = MutableStateFlow