Refactor code to support AGP 9.0

This commit is contained in:
2025-12-20 20:53:13 +03:00
Unverified
parent 8151e0ca0d
commit b18af36b3e
133 changed files with 255 additions and 125 deletions
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:pathData="M480,520q-50,0 -85,-35t-35,-85q0,-50 35,-85t85,-35q50,0 85,35t35,85q0,50 -35,85t-85,35ZM240,920v-309q-38,-42 -59,-96t-21,-115q0,-134 93,-227t227,-93q134,0 227,93t93,227q0,61 -21,115t-59,96v309l-240,-80 -240,80ZM480,640q100,0 170,-70t70,-170q0,-100 -70,-170t-170,-70q-100,0 -170,70t-70,170q0,100 70,170t170,70ZM320,801l160,-41 160,41v-124q-35,20 -75.5,31.5T480,720q-44,0 -84.5,-11.5T320,677v124ZM480,739Z"
android:fillColor="#5f6368"/>
</vector>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:pathData="M320,520h80v-120q0,-33 23.5,-56.5T480,320v-80q-66,0 -113,47t-47,113v120ZM160,840q-33,0 -56.5,-23.5T80,760v-80q0,-33 23.5,-56.5T160,600h40v-200q0,-117 81.5,-198.5T480,120q117,0 198.5,81.5T760,400v200h40q33,0 56.5,23.5T880,680v80q0,33 -23.5,56.5T800,840L160,840Z"
android:fillColor="#5f6368"/>
</vector>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:pathData="M160,760h640v-80L160,680v80ZM320,520h80v-120q0,-33 23.5,-56.5T480,320v-80q-66,0 -113,47t-47,113v120ZM480,680ZM280,600h400v-200q0,-83 -58.5,-141.5T480,200q-83,0 -141.5,58.5T280,400v200ZM160,840q-33,0 -56.5,-23.5T80,760v-80q0,-33 23.5,-56.5T160,600h40v-200q0,-117 81.5,-198.5T480,120q117,0 198.5,81.5T760,400v200h40q33,0 56.5,23.5T880,680v80q0,33 -23.5,56.5T800,840L160,840ZM480,600Z"
android:fillColor="#5f6368"/>
</vector>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,29 @@
@file:Suppress("UnusedReceiverParameter", "unused")
package com.pr0gramm3r101.ui
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Language
import androidx.compose.material.icons.outlined.Language
import androidx.compose.runtime.Composable
import com.pr0gramm3r101.shared.Res
import com.pr0gramm3r101.shared.license
import com.pr0gramm3r101.shared.siren_filled
import com.pr0gramm3r101.shared.siren_outlined
import org.jetbrains.compose.resources.vectorResource
inline val Icons.Outlined.Internet get() = Icons.Outlined.Language
inline val Icons.Filled.Internet get() = Icons.Filled.Language
inline val Icons.Outlined.Website get() = Icons.Outlined.Language
inline val Icons.Filled.Website get() = Icons.Filled.Language
inline val Icons.Outlined.License
@Composable get() = vectorResource(Res.drawable.license)
inline val Icons.Filled.License
@Composable get() = Icons.Outlined.License
inline val Icons.Outlined.Siren
@Composable get() = vectorResource(Res.drawable.siren_outlined)
inline val Icons.Filled.Siren
@Composable get() = vectorResource(Res.drawable.siren_filled)
@@ -0,0 +1,290 @@
@file:JvmName("Adaptive")
package com.pr0gramm3r101.utils
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.toSize
import kotlin.jvm.JvmField
import kotlin.jvm.JvmName
import kotlin.jvm.JvmStatic
@Composable expect inline fun currentWindowSize(): IntSize
/**
* Calculates and returns [WindowAdaptiveInfo] of the provided context. It's a convenient function
* that uses the default [WindowSizeClass] constructor to retrieve [WindowSizeClass].
*
* @return [WindowAdaptiveInfo] of the provided context
*/
@Composable
fun currentWindowAdaptiveInfo(): WindowAdaptiveInfo {
val windowSize = with(LocalDensity.current) { currentWindowSize().toSize().toDpSize() }
return WindowAdaptiveInfo(
WindowSizeClass.compute(windowSize.width.value, windowSize.height.value)
)
}
/**
* This class collects window info that affects adaptation decisions. An adaptive layout is supposed
* to use the info from this class to decide how the layout is supposed to be adapted.
*
* @param windowSizeClass [WindowSizeClass] of the current window.
* @constructor create an instance of [WindowAdaptiveInfo]
*/
@Immutable
class WindowAdaptiveInfo(val windowSizeClass: WindowSizeClass) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is WindowAdaptiveInfo) return false
if (windowSizeClass != other.windowSizeClass) return false
return true
}
override fun hashCode() = windowSizeClass.hashCode()
override fun toString(): String {
return "WindowAdaptiveInfo(windowSizeClass=$windowSizeClass)"
}
}
/**
* [WindowSizeClass] represents breakpoints for a viewport. The recommended width and height break
* points are presented through [windowWidthSizeClass] and [windowHeightSizeClass]. Designers
* should design around the different combinations of width and height buckets. Developers should
* use the different buckets to specify the layouts. Ideally apps will work well in each bucket and
* by extension work well across multiple devices. If two devices are in similar buckets they
* should behave similarly.
*
* This class is meant to be a common definition that can be shared across different device types.
* Application developers can use WindowSizeClass to have standard window buckets and design the UI
* around those buckets. Library developers can use these buckets to create different UI with
* respect to each bucket. This will help with consistency across multiple device types.
*
* A library developer use-case can be creating some navigation UI library. For a size
* class with the [WindowWidthSizeClass.EXPANDED] width it might be more reasonable to have a side
* navigation. For a [WindowWidthSizeClass.COMPACT] width, a bottom navigation might be a better
* fit.
*
* An application use-case can be applied for apps that use a list-detail pattern. The app can use
* the [WindowWidthSizeClass.MEDIUM] to determine if there is enough space to show the list and the
* detail side by side. If all apps follow this guidance then it will present a very consistent user
* experience.
*
* In some cases developers or UI systems may decide to create their own break points. A developer
* might optimize for a window that is smaller than the supported break points or larger. A UI
* system might find that some break points are better suited than the recommended break points.
* In these cases developers may wish to specify their own custom break points and match using
* a `when` statement.
*
* @see WindowWidthSizeClass
* @see WindowHeightSizeClass
*/
class WindowSizeClass private constructor(
/**
* Returns the [WindowWidthSizeClass] that corresponds to the widthDp of the window.
*/
val windowWidthSizeClass: WindowWidthSizeClass,
/**
* Returns the [WindowHeightSizeClass] that corresponds to the heightDp of the window.
*/
val windowHeightSizeClass: WindowHeightSizeClass
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other == null || this::class != other::class) return false
other as WindowSizeClass
if (windowWidthSizeClass != other.windowWidthSizeClass) return false
if (windowHeightSizeClass != other.windowHeightSizeClass) return false
return true
}
override fun hashCode(): Int {
var result = windowWidthSizeClass.hashCode()
result = 31 * result + windowHeightSizeClass.hashCode()
return result
}
override fun toString(): String {
return "WindowSizeClass {" +
"windowWidthSizeClass=$windowWidthSizeClass, " +
"windowHeightSizeClass=$windowHeightSizeClass }"
}
companion object {
/**
* Computes the recommended [WindowSizeClass] for the given width and height in DP.
* @param dpWidth width of a window in DP.
* @param dpHeight height of a window in DP.
* @return [WindowSizeClass] that is recommended for the given dimensions.
* @throws IllegalArgumentException if [dpWidth] or [dpHeight] is
* negative.
*/
@JvmStatic
fun compute(dpWidth: Float, dpHeight: Float): WindowSizeClass {
return WindowSizeClass(
WindowWidthSizeClass.compute(dpWidth),
WindowHeightSizeClass.compute(dpHeight)
)
}
/**
* Computes the [WindowSizeClass] for the given width and height in pixels with density.
* @param widthPx width of a window in PX.
* @param heightPx height of a window in PX.
* @param density density of the display where the window is shown.
* @return [WindowSizeClass] that is recommended for the given dimensions.
* @throws IllegalArgumentException if [widthPx], [heightPx], or [density] is
* negative.
*/
@JvmStatic
fun compute(widthPx: Int, heightPx: Int, density: Float): WindowSizeClass {
val widthDp = widthPx / density
val heightDp = heightPx / density
return compute(widthDp, heightDp)
}
}
}
/**
* A class to represent the width size buckets for a viewport. The possible values are [COMPACT],
* [MEDIUM], and [EXPANDED]. [WindowWidthSizeClass] should not be used as a proxy for the device
* type. It is possible to have resizeable windows in different device types.
* The viewport might change from a [COMPACT] all the way to an [EXPANDED] size class.
*/
class WindowWidthSizeClass private constructor(
private val rawValue: Int
) {
override fun toString(): String {
val name = when (this) {
COMPACT -> "COMPACT"
MEDIUM -> "MEDIUM"
EXPANDED -> "EXPANDED"
else -> "UNKNOWN"
}
return "WindowWidthSizeClass: $name"
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other == null) return false
if (this::class != other::class) return false
val that = other as WindowWidthSizeClass
return rawValue == that.rawValue
}
override fun hashCode(): Int {
return rawValue
}
companion object {
/**
* A bucket to represent a compact width window, typical for a phone in portrait.
*/
@JvmField
val COMPACT: WindowWidthSizeClass = WindowWidthSizeClass(0)
/**
* A bucket to represent a medium width window, typical for a phone in landscape or
* a tablet.
*/
@JvmField
val MEDIUM: WindowWidthSizeClass = WindowWidthSizeClass(1)
/**
* A bucket to represent an expanded width window, typical for a large tablet or desktop
* form-factor.
*/
@JvmField
val EXPANDED: WindowWidthSizeClass = WindowWidthSizeClass(2)
/**
* Returns a recommended [WindowWidthSizeClass] for the width of a window given the width
* in DP.
* @param dpWidth the width of the window in DP
* @return A recommended size class for the width
* @throws IllegalArgumentException if the width is negative
*/
internal fun compute(dpWidth: Float): WindowWidthSizeClass {
require(dpWidth >= 0) { "Width must be positive, received $dpWidth" }
return when {
dpWidth < 600 -> COMPACT
dpWidth < 840 -> MEDIUM
else -> EXPANDED
}
}
}
}
class WindowHeightSizeClass private constructor(
private val rawValue: Int
) {
override fun toString(): String {
val name = when (this) {
COMPACT -> "COMPACT"
MEDIUM -> "MEDIUM"
EXPANDED -> "EXPANDED"
else -> "UNKNOWN"
}
return "WindowHeightSizeClass: $name"
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other == null) return false
if (this::class != other::class) return false
val that = other as WindowHeightSizeClass
return rawValue == that.rawValue
}
override fun hashCode(): Int {
return rawValue
}
companion object {
/**
* A bucket to represent a compact height, typical for a phone that is in landscape.
*/
@JvmField
val COMPACT: WindowHeightSizeClass = WindowHeightSizeClass(0)
/**
* A bucket to represent a medium height, typical for a phone in portrait or a tablet.
*/
@JvmField
val MEDIUM: WindowHeightSizeClass = WindowHeightSizeClass(1)
/**
* A bucket to represent an expanded height window, typical for a large tablet or a
* desktop form-factor.
*/
@JvmField
val EXPANDED: WindowHeightSizeClass = WindowHeightSizeClass(2)
/**
* Returns a recommended [WindowHeightSizeClass] for the height of a window given the
* height in DP.
* @param dpHeight the height of the window in DP
* @return A recommended size class for the height
* @throws IllegalArgumentException if the height is negative
*/
internal fun compute(dpHeight: Float): WindowHeightSizeClass {
require(dpHeight >= 0) { "Height must be positive, received $dpHeight" }
return when {
dpHeight < 480 -> COMPACT
dpHeight < 900 -> MEDIUM
else -> EXPANDED
}
}
}
}
@@ -0,0 +1,76 @@
package com.pr0gramm3r101.utils
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.NavigationRail
import androidx.compose.material3.NavigationRailItem
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
data class NavigationItem(
val name: String,
val selectedIcon: ImageVector,
val unselectedIcon: ImageVector = selectedIcon,
val onClick: () -> Unit
)
@Composable
fun SimpleNavigationBar(
selectedItem: Int,
vararg items: NavigationItem,
modifier: Modifier = Modifier,
) {
NavigationBar(modifier) {
items.forEachIndexed { index, item ->
NavigationBarItem(
icon = {
Icon(
imageVector =
if (selectedItem == index)
items[index].selectedIcon
else
items[index].unselectedIcon,
contentDescription = item.name
)
},
label = { Text(item.name) },
selected = selectedItem == index,
onClick = items[index].onClick
)
}
}
}
@Composable
fun SimpleNavigationRail(
selectedItem: Int,
vararg items: NavigationItem,
modifier: Modifier = Modifier,
) {
NavigationRail(
modifier,
containerColor = MaterialTheme.colorScheme.surfaceContainer
) {
items.forEachIndexed { index, item ->
NavigationRailItem(
icon = {
Icon(
imageVector =
if (selectedItem == index)
items[index].selectedIcon
else
items[index].unselectedIcon,
contentDescription = item.name
)
},
label = { Text(item.name) },
selected = selectedItem == index,
onClick = items[index].onClick
)
}
}
}
@@ -0,0 +1,38 @@
package com.pr0gramm3r101.utils
import androidx.navigation.NavController
/**
* Navigates to the specified route and clears the entire back stack.
* This ensures the destination becomes the new root of the navigation stack.
*
* @param route The destination route to navigate to
* @param launchSingleTop If true, prevents multiple instances of the same destination
*/
fun NavController.navigateAndWipeBackStack(route: String, launchSingleTop: Boolean = true) {
// First, pop all previous entries (keeping current screen for now)
while (previousBackStackEntry != null) {
if (!popBackStack()) {
break
}
}
// Get current route to pop it with the navigation
val currentRoute = currentBackStackEntry?.destination?.route
// Navigate to the new route, removing the current screen as well
if (currentRoute != null && currentRoute != route) {
navigate(route) {
popUpTo(currentRoute) {
inclusive = true
}
this.launchSingleTop = launchSingleTop
}
} else {
// Fallback: just navigate if current route is same or null
navigate(route) {
this.launchSingleTop = launchSingleTop
}
}
}
@@ -0,0 +1,184 @@
@file:Suppress("unused", "NOTHING_TO_INLINE", "KotlinRedundantDiagnosticSuppress")
package com.pr0gramm3r101.utils
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Typography
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocal
import androidx.compose.runtime.ProvidableCompositionLocal
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusManager
import androidx.compose.ui.platform.ClipboardManager
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.platform.SoftwareKeyboardController
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.LayoutDirection
import org.jetbrains.compose.resources.Font
import org.jetbrains.compose.resources.FontResource
import tech.annexflow.constraintlayout.compose.ConstrainScope
import tech.annexflow.constraintlayout.compose.ConstrainedLayoutReference
import tech.annexflow.constraintlayout.compose.ConstraintLayoutBaseScope
import tech.annexflow.constraintlayout.compose.HorizontalAnchorable
import tech.annexflow.constraintlayout.compose.VerticalAnchorable
@Suppress("ComposableNaming")
@Composable
fun Typography(fontRes: FontResource) = Typography().let {
val font = FontFamily(Font(fontRes))
it.copy(
displayLarge = it.displayLarge.copy(fontFamily = font),
displayMedium = it.displayMedium.copy(fontFamily = font),
displaySmall = it.displaySmall.copy(fontFamily = font),
headlineLarge = it.headlineLarge.copy(fontFamily = font),
headlineMedium = it.headlineMedium.copy(fontFamily = font),
headlineSmall = it.headlineSmall.copy(fontFamily = font),
titleLarge = it.titleLarge.copy(fontFamily = font),
titleMedium = it.titleMedium.copy(fontFamily = font),
titleSmall = it.titleSmall.copy(fontFamily = font),
bodyLarge = it.bodyLarge.copy(fontFamily = font),
bodyMedium = it.bodyMedium.copy(fontFamily = font),
bodySmall = it.bodySmall.copy(fontFamily = font),
labelLarge = it.labelLarge.copy(fontFamily = font),
labelMedium = it.labelMedium.copy(fontFamily = font),
labelSmall = it.labelSmall.copy(fontFamily = font)
)
}
val ConstrainScope.left get() = absoluteLeft
val ConstrainScope.right get() = absoluteRight
val ConstrainedLayoutReference.left get() = absoluteLeft
val ConstrainedLayoutReference.right get() = absoluteRight
inline infix fun HorizontalAnchorable.link(
anchor: ConstraintLayoutBaseScope.HorizontalAnchor
) = linkTo(anchor)
inline infix fun VerticalAnchorable.link(
anchor: ConstraintLayoutBaseScope.VerticalAnchor
) = linkTo(anchor)
expect fun Modifier.clearFocusOnKeyboardDismiss(): Modifier
operator fun Modifier.plus(other: Modifier) = then(other)
var ClipboardManager.text
get() = getText()
set(value) = setText(value!!)
@Composable
expect fun ToggleNavScrimEffect(enabled: Boolean = false)
interface SupportClipboardManager {
suspend fun setText(string: String)
suspend fun getText(): String?
suspend fun hasText(): Boolean = getText()?.isNotEmpty() == true
fun setTextListener(listener: (String) -> Unit)
}
val LocalSupportClipboardManager: ProvidableCompositionLocal<SupportClipboardManager> = compositionLocalOf {
throw IllegalStateException("This CompositionLocal hasn't been provided.")
}
val supportClipboardManagerImpl
@Composable get() = LocalClipboardManager().toSupport()
inline fun ClipboardManager.toSupport() = object : SupportClipboardManager {
private var listener: ((String) -> Unit)? = null
override suspend fun setText(string: String) {
setText(string.toAnnotatedString())
listener?.invoke(string)
}
override suspend fun getText() = this@toSupport.getText()?.toString() ?: ""
override fun setTextListener(listener: (String) -> Unit) {
this.listener = listener
}
}
fun String.toAnnotatedString() = AnnotatedString(this)
@Composable
inline operator fun <T> CompositionLocal<T>.invoke() = current
val LocalSnackbar: ProvidableCompositionLocal<SnackbarHostState> = compositionLocalOf { throw NullPointerException() }
inline fun resetFocus(
keyboardController: SoftwareKeyboardController?,
focusManager: FocusManager
) {
keyboardController?.hide()
focusManager.clearFocus(true)
}
val unsupported: Nothing
get() = throw UnsupportedOperationException()
val WindowWidthSizeClass.rawValue: Int get() =
when (this) {
WindowWidthSizeClass.COMPACT -> 0
WindowWidthSizeClass.MEDIUM -> 1
WindowWidthSizeClass.EXPANDED -> 2
else -> throw IllegalArgumentException("Unsupported WindowWidthSizeClass: $this")
}
val WindowHeightSizeClass.rawValue: Int get() =
when (this) {
WindowHeightSizeClass.COMPACT -> 0
WindowHeightSizeClass.MEDIUM -> 1
WindowHeightSizeClass.EXPANDED -> 2
else -> throw IllegalArgumentException("Unsupported WindowHeightSizeClass: $this")
}
operator fun WindowWidthSizeClass.compareTo(other: WindowWidthSizeClass) = this.rawValue.compareTo(other.rawValue)
operator fun WindowHeightSizeClass.compareTo(other: WindowHeightSizeClass) = this.rawValue.compareTo(other.rawValue)
val WindowSizeClass.width get() = windowWidthSizeClass
val WindowSizeClass.height get() = windowHeightSizeClass
val WindowAdaptiveInfo.widthSizeClass get() = windowSizeClass.width
@Suppress("unused")
val WindowAdaptiveInfo.heightSizeClass get() = windowSizeClass.height
fun String.encodeJSON(): String {
val out = StringBuilder()
for (i in indices) {
when (val c: Char = get(i)) {
'"', '\\', '/' -> out.append('\\').append(c)
'\t' -> out.append("\\t")
'\b' -> out.append("\\b")
'\n' -> out.append("\\n")
'\r' -> out.append("\\r")
else -> if (c.code <= 0x1F) {
out.append("\\u${c.code.toString(16).padStart(4, '0')}")
} else {
out.append(c)
}
}
}
return "$out"
}
@Composable
operator fun PaddingValues.plus(other: PaddingValues) = PaddingValues(
top = calculateTopPadding() + other.calculateTopPadding(),
bottom = calculateBottomPadding() + other.calculateTopPadding(),
start = when (LocalLayoutDirection()) {
LayoutDirection.Ltr -> calculateLeftPadding(LocalLayoutDirection())
LayoutDirection.Rtl -> calculateRightPadding(LocalLayoutDirection())
},
end = when (LocalLayoutDirection()) {
LayoutDirection.Ltr -> calculateRightPadding(LocalLayoutDirection())
LayoutDirection.Rtl -> calculateLeftPadding(LocalLayoutDirection())
}
)
expect val materialYouAvailable: Boolean
@@ -0,0 +1,5 @@
package com.pr0gramm3r101.utils
expect object UtilsLibrary {
fun init(vararg args: Any)
}
@@ -0,0 +1,19 @@
package com.pr0gramm3r101.utils.crypto
/**
* Platform-specific base64 encoding/decoding using native implementations
*/
expect object Base64 {
fun encode(bytes: ByteArray): String
fun decode(base64: String): ByteArray
}
/**
* Extension function to encode ByteArray to base64 string
*/
fun ByteArray.toBase64(): String = Base64.encode(this)
/**
* Extension function to decode base64 string to ByteArray
*/
fun String.fromBase64(): ByteArray = Base64.decode(this)
@@ -0,0 +1,8 @@
package com.pr0gramm3r101.utils.crypto
/**
* Platform-specific HMAC-SHA256 implementation
*/
expect object Hmac {
suspend fun hmacSha256(key: ByteArray, data: ByteArray): ByteArray
}
@@ -0,0 +1,59 @@
package com.pr0gramm3r101.utils.crypto
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
/**
* Derives authentication secret from password using HKDF
* Matches web client implementation:
* - Salt: fromchat.user:${username} encoded as bytes
* - Info: "auth-secret" encoded as bytes
* - Output: 32 bytes, then base64 encoded
*/
object PasswordHash {
/**
* Derives a 32-byte key using HKDF-SHA256
* Implementation of RFC 5869 HKDF
*/
suspend fun hkdfExtractAndExpand(
inputKeyMaterial: ByteArray,
salt: ByteArray,
info: ByteArray,
length: Int = 32
): ByteArray = withContext(Dispatchers.Default) {
// HKDF-Extract: PRK = HMAC-Hash(salt, IKM)
val prk = Hmac.hmacSha256(salt, inputKeyMaterial)
// HKDF-Expand: OKM = HKDF-Expand(PRK, info, L)
val hashLen = 32 // SHA-256 output length
val n = (length + hashLen - 1) / hashLen // number of blocks
val okm = ByteArray(length)
var offset = 0
var t = ByteArray(0)
for (i in 0 until n) {
val tInput = t + info + byteArrayOf((i + 1).toByte())
t = Hmac.hmacSha256(prk, tInput)
val copyLen = minOf(t.size, length - offset)
t.copyInto(okm, offset, 0, copyLen)
offset += copyLen
}
okm
}
}
/**
* Derives authentication secret so the raw password never leaves the client
*/
suspend fun deriveAuthSecret(username: String, password: String): String {
val salt = "fromchat.user:$username".encodeToByteArray()
val info = "auth-secret".encodeToByteArray()
val passwordBytes = password.encodeToByteArray()
val derived = PasswordHash.hkdfExtractAndExpand(passwordBytes, salt, info, 32)
return derived.toBase64()
}
@@ -0,0 +1,37 @@
package com.pr0gramm3r101.utils.settings
/**
* Async version of Settings API using suspend functions
*/
interface AsyncSettings {
suspend fun putString(key: String, value: String)
suspend fun getString(key: String, default: String = ""): String
suspend fun putInt(key: String, value: Int)
suspend fun getInt(key: String, default: Int = 0): Int
suspend fun putLong(key: String, value: Long)
suspend fun getLong(key: String, default: Long = 0L): Long
suspend fun putFloat(key: String, value: Float)
suspend fun getFloat(key: String, default: Float = 0f): Float
suspend fun putBoolean(key: String, value: Boolean)
suspend fun getBoolean(key: String, default: Boolean = false): Boolean
suspend fun putStringSet(key: String, value: Set<String>)
suspend fun getStringSet(key: String, default: Set<String> = emptySet()): Set<String>
suspend fun putStringList(key: String, value: List<String>)
suspend fun getStringList(key: String, default: List<String> = emptyList()): List<String>
suspend fun remove(key: String)
suspend fun contains(key: String): Boolean
suspend fun clear()
}
/**
* Global async settings instance
*/
expect val asyncSettings: AsyncSettings
@@ -0,0 +1,33 @@
@file:Suppress("NOTHING_TO_INLINE")
package com.pr0gramm3r101.utils.settings
interface Settings {
companion object {
inline fun get() = settings
inline operator fun invoke() = settings
}
fun putString(key: String, value: String)
fun getString(key: String, default: String = ""): String
fun putInt(key: String, value: Int)
fun getInt(key: String, default: Int = 0): Int
fun putLong(key: String, value: Long)
fun getLong(key: String, default: Long = 0L): Long
fun putFloat(key: String, value: Float)
fun getFloat(key: String, default: Float = 0f): Float
fun putBoolean(key: String, value: Boolean)
fun getBoolean(key: String, default: Boolean = false): Boolean
fun putStringSet(key: String, value: Set<String>)
fun getStringSet(key: String, default: Set<String> = emptySet()): Set<String>
fun putStringList(key: String, value: List<String>)
fun getStringList(key: String, default: List<String> = emptyList()): List<String>
fun remove(key: String)
}
@@ -0,0 +1,3 @@
package com.pr0gramm3r101.utils.settings
expect val settings: Settings
@@ -0,0 +1,29 @@
package com.pr0gramm3r101.utils.storage
/**
* Authentication token storage
*/
object AuthStorage {
private val storage = NamespacedStorage("auth")
suspend fun getToken(): String? {
val token = storage.getString("token")
return token.ifEmpty { null }
}
suspend fun setToken(token: String?) {
if (token != null) {
storage.putString("token", token)
} else {
storage.remove("token")
}
}
suspend fun clearToken() {
storage.remove("token")
}
suspend fun putString(key: String, value: String) = storage.putString(key, value)
suspend fun getString(key: String, default: String = ""): String = storage.getString(key, default)
suspend fun remove(key: String) = storage.remove(key)
}
@@ -0,0 +1,55 @@
package com.pr0gramm3r101.utils.storage
/**
* Server configuration data
*/
data class ServerConfigData(
val serverUrl: String,
val httpsEnabled: Boolean
)
/**
* Server configuration storage
*/
object ServerConfigStorage {
private val storage = NamespacedStorage("server_config")
private const val SERVER_URL_KEY = "server_url"
private const val HTTPS_ENABLED_KEY = "https_enabled"
suspend fun getServerUrl(): String? {
val url = storage.getString(SERVER_URL_KEY)
return url.ifEmpty { null }
}
suspend fun setServerUrl(url: String) {
storage.putString(SERVER_URL_KEY, url)
}
suspend fun getHttpsEnabled(): Boolean? {
return if (storage.contains(HTTPS_ENABLED_KEY)) {
storage.getBoolean(HTTPS_ENABLED_KEY, true)
} else {
null
}
}
suspend fun setHttpsEnabled(enabled: Boolean) {
storage.putBoolean(HTTPS_ENABLED_KEY, enabled)
}
suspend fun hasConfiguration(): Boolean {
return getServerUrl() != null
}
suspend fun getConfig(): ServerConfigData {
val url = getServerUrl() ?: throw IllegalStateException("Server URL not found in storage")
val https = getHttpsEnabled() ?: true
return ServerConfigData(url, https)
}
suspend fun saveConfig(config: ServerConfigData) {
setServerUrl(config.serverUrl)
setHttpsEnabled(config.httpsEnabled)
}
}
@@ -0,0 +1,59 @@
package com.pr0gramm3r101.utils.storage
import com.pr0gramm3r101.utils.settings.AsyncSettings
import com.pr0gramm3r101.utils.settings.asyncSettings
/**
* Generic key-value storage using AsyncSettings
*/
class Storage(private val settings: AsyncSettings = asyncSettings) {
suspend fun putString(key: String, value: String) = settings.putString(key, value)
suspend fun getString(key: String, default: String = ""): String = settings.getString(key, default)
suspend fun putInt(key: String, value: Int) = settings.putInt(key, value)
suspend fun getInt(key: String, default: Int = 0): Int = settings.getInt(key, default)
suspend fun putLong(key: String, value: Long) = settings.putLong(key, value)
suspend fun getLong(key: String, default: Long = 0L): Long = settings.getLong(key, default)
suspend fun putFloat(key: String, value: Float) = settings.putFloat(key, value)
suspend fun getFloat(key: String, default: Float = 0f): Float = settings.getFloat(key, default)
suspend fun putBoolean(key: String, value: Boolean) = settings.putBoolean(key, value)
suspend fun getBoolean(key: String, default: Boolean = false): Boolean = settings.getBoolean(key, default)
suspend fun putStringSet(key: String, value: Set<String>) = settings.putStringSet(key, value)
suspend fun getStringSet(key: String, default: Set<String> = emptySet()): Set<String> = settings.getStringSet(key, default)
suspend fun putStringList(key: String, value: List<String>) = settings.putStringList(key, value)
suspend fun getStringList(key: String, default: List<String> = emptyList()): List<String> = settings.getStringList(key, default)
suspend fun remove(key: String) = settings.remove(key)
suspend fun contains(key: String): Boolean = settings.contains(key)
suspend fun clear() = settings.clear()
}
/**
* Namespaced storage for specific features
*/
class NamespacedStorage(private val prefix: String, private val storage: Storage = Storage()) {
private fun key(name: String) = "$prefix:$name"
suspend fun putString(name: String, value: String) = storage.putString(key(name), value)
suspend fun getString(name: String, default: String = ""): String = storage.getString(key(name), default)
suspend fun putInt(name: String, value: Int) = storage.putInt(key(name), value)
suspend fun getInt(name: String, default: Int = 0): Int = storage.getInt(key(name), default)
suspend fun putLong(name: String, value: Long) = storage.putLong(key(name), value)
suspend fun getLong(name: String, default: Long = 0L): Long = storage.getLong(key(name), default)
suspend fun putFloat(name: String, value: Float) = storage.putFloat(key(name), value)
suspend fun getFloat(name: String, default: Float = 0f): Float = storage.getFloat(key(name), default)
suspend fun putBoolean(name: String, value: Boolean) = storage.putBoolean(key(name), value)
suspend fun getBoolean(name: String, default: Boolean = false): Boolean = storage.getBoolean(key(name), default)
suspend fun remove(name: String) = storage.remove(key(name))
suspend fun contains(name: String): Boolean = storage.contains(key(name))
}