Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions android/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ dependencies {
testImplementation(libs.androidx.compose.ui)
testImplementation(libs.junit)
testImplementation(libs.kotlin.test)
testImplementation(libs.robolectric)

detektPlugins(libs.detekt.formatting)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
package dev.appoutlet.some.android.resolver

import android.net.Uri
import dev.appoutlet.some.android.strategy.UriStrategy
import dev.appoutlet.some.core.Resolver
import dev.appoutlet.some.core.ResolverChain
import dev.appoutlet.some.core.StrategyProvider
import dev.appoutlet.some.core.get
import kotlin.random.Random
import kotlin.reflect.KType
import kotlin.reflect.typeOf

private const val CONTENT_SCHEME = "content"
private const val FILE_SCHEME = "file"
private const val HTTPS_SCHEME = "https"
private const val SCHEME_SEPARATOR = "://"
private const val PATH_PREFIX = "/"
private const val QUERY_PREFIX = "?"
private const val QUERY_PAIR_SEPARATOR = "&"
private const val FRAGMENT_PREFIX = "#"

private val ALLOWED_SCHEMES = listOf(CONTENT_SCHEME, FILE_SCHEME, HTTPS_SCHEME)

private val URL_COMPATIBLE_CHARS = ('a'..'z') + ('0'..'9')

private const val MIN_HOST_LENGTH = 3
private const val MAX_HOST_LENGTH = 8
private const val MIN_TLD_LENGTH = 2
private const val MAX_TLD_LENGTH = 3
private const val MIN_PATH_SEGMENTS = 1
private const val MAX_PATH_SEGMENTS = 4
private const val MIN_SEGMENT_LENGTH = 2
private const val MAX_SEGMENT_LENGTH = 6
private const val MIN_QUERY_PAIRS = 1
private const val MAX_QUERY_PAIRS = 3
private const val MIN_QUERY_KEY_LENGTH = 2
private const val MAX_QUERY_KEY_LENGTH = 5
private const val MIN_QUERY_VALUE_LENGTH = 2
private const val MAX_QUERY_VALUE_LENGTH = 5
private const val MIN_FRAGMENT_LENGTH = 2
private const val MAX_FRAGMENT_LENGTH = 6

/**
* Resolves [Uri] types using the active [UriStrategy].
*
* @param strategyProvider Provider of all configured generation strategies.
* @param random Random source used when generating random URI components.
*/
class UriResolver(
strategyProvider: StrategyProvider,
private val random: Random
) : Resolver {
private val uriStrategy = strategyProvider.get<UriStrategy>() ?: UriStrategy.default

override fun canResolve(type: KType): Boolean = type == typeOf<Uri>()

override fun resolve(type: KType, chain: ResolverChain): Any {
val scheme = when (uriStrategy) {
is UriStrategy.Random -> ALLOWED_SCHEMES.random(random)
is UriStrategy.Content -> CONTENT_SCHEME
is UriStrategy.File -> FILE_SCHEME
is UriStrategy.Url -> HTTPS_SCHEME
}

val host = generateHost()
val path = generatePath()
val query = if (random.nextBoolean()) generateQuery() else null
val fragment = if (random.nextBoolean()) generateFragment() else null

val builder = StringBuilder()
.append(scheme)
.append(SCHEME_SEPARATOR)
.append(host)
.append(path)

if (query != null) {
builder.append(QUERY_PREFIX).append(query)
}

if (fragment != null) {
builder.append(FRAGMENT_PREFIX).append(fragment)
}

return Uri.parse(builder.toString())
}

private fun generateHost(): String {
return if (uriStrategy is UriStrategy.Url) {
val host = urlCompatibleString(MIN_HOST_LENGTH, MAX_HOST_LENGTH)
val tld = urlCompatibleString(MIN_TLD_LENGTH, MAX_TLD_LENGTH)
"$host.$tld"
} else {
urlCompatibleString(MIN_HOST_LENGTH, MAX_HOST_LENGTH)
}
}

private fun generatePath(): String {
val segments = random.nextInt(MIN_PATH_SEGMENTS, MAX_PATH_SEGMENTS)
return List(segments) { urlCompatibleString(MIN_SEGMENT_LENGTH, MAX_SEGMENT_LENGTH) }
.joinToString(prefix = PATH_PREFIX, separator = PATH_PREFIX)
}

private fun generateQuery(): String {
val pairs = random.nextInt(MIN_QUERY_PAIRS, MAX_QUERY_PAIRS)

return List(pairs) {
val key = urlCompatibleString(MIN_QUERY_KEY_LENGTH, MAX_QUERY_KEY_LENGTH)
val value = urlCompatibleString(MIN_QUERY_VALUE_LENGTH, MAX_QUERY_VALUE_LENGTH)
"$key=$value"
}.joinToString(QUERY_PAIR_SEPARATOR)
}

private fun generateFragment(): String {
return urlCompatibleString(MIN_FRAGMENT_LENGTH, MAX_FRAGMENT_LENGTH)
}

private fun urlCompatibleString(minLength: Int, maxLength: Int): String {
val length = random.nextInt(minLength, maxLength)
return List(length) { URL_COMPATIBLE_CHARS.random(random) }.joinToString("")
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package dev.appoutlet.some.android.strategy

import dev.appoutlet.some.config.Strategy

/**
* Strategy for generating [android.net.Uri] values.
*
* Available strategies:
* - [Random] – generates URIs with any scheme from `[content, file, https]` (default)
* - [Content] – generates only `content://...` URIs
* - [File] – generates only `file://...` URIs
* - [Url] – generates only `https://...` URIs
*
* Example usage:
* ```kotlin
* someSetup {
* strategy(UriStrategy.Url)
* }
* ```
*/
sealed interface UriStrategy : Strategy {
override val key get() = UriStrategy::class

/**
* Generates URIs with a random scheme from `[content, file, https]`.
*/
data object Random : UriStrategy

/**
* Generates only `content://...` URIs.
*/
data object Content : UriStrategy

/**
* Generates only `file://...` URIs.
*/
data object File : UriStrategy

/**
* Generates only `https://...` URIs.
*/
data object Url : UriStrategy

companion object {
/**
* The default URI strategy.
*/
val default: UriStrategy get() = Random
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
package dev.appoutlet.some.android.resolver

import android.net.Uri
import dev.appoutlet.some.android.strategy.UriStrategy
import dev.appoutlet.some.config.DefaultStrategyProvider
import dev.appoutlet.some.config.NullableStrategy
import dev.appoutlet.some.core.ResolverChain
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import kotlin.random.Random
import kotlin.reflect.typeOf

@RunWith(RobolectricTestRunner::class)
class UriResolverTest {
@Test
fun `UriResolver canResolve detects Uri type`() {
val resolver = UriResolver(DefaultStrategyProvider(), Random.Default)
assertTrue(resolver.canResolve(typeOf<Uri>()))
}

@Test
fun `UriResolver rejects non-Uri types`() {
val resolver = UriResolver(DefaultStrategyProvider(), Random.Default)
assertFalse(resolver.canResolve(typeOf<String>()))
assertFalse(resolver.canResolve(typeOf<Int>()))
}

@Test
fun `UriStrategy Random generates URIs with allowed schemes`() {
val resolver = UriResolver(
DefaultStrategyProvider(mapOf(UriStrategy::class to UriStrategy.Random)),
Random.Default
)

repeat(20) {
val result = resolver.resolve(typeOf<Uri>(), testChain) as Uri
assertTrue(
"Expected scheme in [content, file, https] but got ${result.scheme}",
result.scheme in setOf("content", "file", "https")
)
assertUriShape(result)
}
}

@Test
fun `UriStrategy Url generates only https URIs`() {
val resolver = UriResolver(
DefaultStrategyProvider(mapOf(UriStrategy::class to UriStrategy.Url)),
Random.Default
)

repeat(10) {
val result = resolver.resolve(typeOf<Uri>(), testChain) as Uri
assertEquals("https", result.scheme)
assertTrue(
"Generated URL $result does not match expected URL shape",
HTTPS_URL_REGEX.matches(result.toString())
)
}
}

@Test
fun `UriStrategy Content generates only content URIs`() {
val resolver = UriResolver(
DefaultStrategyProvider(mapOf(UriStrategy::class to UriStrategy.Content)),
Random.Default
)

repeat(10) {
val result = resolver.resolve(typeOf<Uri>(), testChain) as Uri
assertEquals("content", result.scheme)
assertUriShape(result)
}
}

@Test
fun `UriStrategy File generates only file URIs`() {
val resolver = UriResolver(
DefaultStrategyProvider(mapOf(UriStrategy::class to UriStrategy.File)),
Random.Default
)

repeat(10) {
val result = resolver.resolve(typeOf<Uri>(), testChain) as Uri
assertEquals("file", result.scheme)
assertUriShape(result)
}
}

@Test
fun `Generated URIs are parseable by Uri parse`() {
val resolver = UriResolver(DefaultStrategyProvider(), Random.Default)

repeat(20) {
val result = resolver.resolve(typeOf<Uri>(), testChain) as Uri
val reparsed = Uri.parse(result.toString())
assertNotNull(reparsed)
assertEquals(result.scheme, reparsed.scheme)
}
}

@Test
fun `Default strategy is Random`() {
val resolver = UriResolver(DefaultStrategyProvider(), Random.Default)
val result = resolver.resolve(typeOf<Uri>(), testChain) as Uri
assertTrue(
"Expected scheme in [content, file, https] but got ${result.scheme}",
result.scheme in setOf("content", "file", "https")
)
assertUriShape(result)
}

private fun assertUriShape(uri: Uri) {
assertNotNull(uri.scheme)
assertTrue("Host should not be empty", uri.host?.isNotEmpty() == true)
assertTrue("Path should not be empty", uri.path?.isNotEmpty() == true)
}

companion object {
private val testChain = ResolverChain(emptyList(), NullableStrategy.NullOnCircularReference)

private val HTTPS_URL_REGEX = (
"^https://" +
"[a-z0-9]{3,8}\\.[a-z0-9]{2,3}" +
"(/[a-z0-9]{2,6})+" +
"(\\?[a-z0-9]{2,5}=[a-z0-9]{2,5}(&[a-z0-9]{2,5}=[a-z0-9]{2,5})*)?" +
"(#[a-z0-9]{2,6})?$"
).toRegex()
}
}
1 change: 1 addition & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ plugins {
alias(libs.plugins.android.library) apply false
alias(libs.plugins.kotlin.jvm) apply false
alias(libs.plugins.mavenPublish) apply false
alias(libs.plugins.detekt) apply false

alias(libs.plugins.dokka)
alias(libs.plugins.gitHooks)
Expand Down
48 changes: 0 additions & 48 deletions docs/android.md

This file was deleted.

Loading