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
5 changes: 5 additions & 0 deletions .github/workflows/android.yml
Original file line number Diff line number Diff line change
Expand Up @@ -83,3 +83,8 @@ jobs:
path: stream-android-core/build/reports/tests/testDebugUnitTest/index.html

- uses: GetStream/android-ci-actions/actions/setup-ruby@main

- name: Sonar
run: ./gradlew sonar
env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
13 changes: 13 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,9 +1,21 @@
import org.jetbrains.kotlin.gradle.dsl.JvmTarget

plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
}

kotlin {
compilerOptions {
jvmTarget.set(JvmTarget.JVM_11)
freeCompilerArgs.addAll(
"-opt-in=io.getstream.android.core.annotations.StreamInternalApi",
"-XXLanguage:+PropertyParamAnnotationDefaultTargetMode"
)
}
}

android {
namespace = "io.getstream.android.core"
compileSdk = 36
Expand Down Expand Up @@ -42,6 +54,7 @@ android {
dependencies {

implementation(project(":stream-android-core"))
implementation(project(":stream-android-core-annotations"))

implementation(libs.androidx.core.ktx)
implementation(libs.androidx.appcompat)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://github.com/GetStream/stream-feeds-android/blob/main/LICENSE
* https://github.com/GetStream/stream-core-android/blob/main/LICENSE
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
Expand Down
3 changes: 1 addition & 2 deletions lint.xml
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,7 @@
</issue>
<issue id="SuspendRunCatching" severity="error"/>
<issue id="ExposeAsStateFlow" severity="error"/>
<issue id="StreamCoreApiMissing" severity="error">
<issue id="StreamApiExplicitMarker" severity="error">
<option name="packages" value="io.getstream.android.core.api.*,io.getstream.android.core.api" />
<option name="exclude_packages" value="io.getstream.android.core.api.model.value.*" />
</issue>
</lint>
Original file line number Diff line number Diff line change
Expand Up @@ -88,15 +88,9 @@ annotation class StreamInternalApi
annotation class StreamDelicateApi(val message: String)

/**
* Marks APIs that are part of the **Stream Core SDK layer**.
*
* These APIs are primarily intended for **internal use within Stream SDKs** (e.g. video, chat, or
* other verticals) and are not considered public surface APIs.
*
* While they may be accessible, external usage is **discouraged** because:
* - Support will typically focus on higher-level, public APIs instead.
* - A vertical can upgrade to a major version of Stream core that may no longer have or support
* this api.
* Marks APIs that are part of the **Stream core SDK layer**. This API can be safely published and
* used by other Stream SDKs. They can also be propagated and exposed via public APIs of the product
* SDKs.
*/
@Target(
AnnotationTarget.CLASS,
Expand All @@ -106,10 +100,4 @@ annotation class StreamDelicateApi(val message: String)
AnnotationTarget.TYPEALIAS,
)
@Retention(AnnotationRetention.BINARY)
@RequiresOptIn(
message =
"Stream Core SDK API – intended for use only within the Stream SDK core. " +
"External usage is discouraged and may not be supported.",
level = RequiresOptIn.Level.ERROR,
)
annotation class StreamCoreApi
annotation class StreamPublishedApi
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import com.android.tools.lint.detector.api.Issue
import io.getstream.android.core.lint.detectors.ExposeAsStateFlowDetector
import io.getstream.android.core.lint.detectors.KeepInstanceDetector
import io.getstream.android.core.lint.detectors.MustBeInternalDetector
import io.getstream.android.core.lint.detectors.StreamCoreApiDetector
import io.getstream.android.core.lint.detectors.StreamApiExplicitMarkerDetector
import io.getstream.android.core.lint.detectors.SuspendRunCatchingDetector

/** The stream lint rules registry. */
Expand All @@ -34,7 +34,7 @@ class StreamIssueRegistry : IssueRegistry() {
KeepInstanceDetector.ISSUE,
SuspendRunCatchingDetector.ISSUE,
ExposeAsStateFlowDetector.ISSUE,
StreamCoreApiDetector.ISSUE,
StreamApiExplicitMarkerDetector.ISSUE,
)

override val vendor =
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,270 @@
/*
* Copyright (c) 2014-2025 Stream.io Inc. All rights reserved.
*
* Licensed under the Stream License;
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://github.com/GetStream/stream-core-android/blob/main/LICENSE
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.getstream.android.core.lint.detectors

import com.android.tools.lint.client.api.UElementHandler
import com.android.tools.lint.detector.api.*
import org.jetbrains.kotlin.lexer.KtTokens
import org.jetbrains.kotlin.psi.*
import org.jetbrains.uast.*

class StreamApiExplicitMarkerDetector : Detector(), Detector.UastScanner {

override fun getApplicableUastTypes() =
listOf(UClass::class.java, UMethod::class.java, UField::class.java, UFile::class.java)

override fun createUastHandler(context: JavaContext) =
object : UElementHandler() {
override fun visitClass(node: UClass) {
val uFile = node.getContainingUFileOrNull() ?: return
if (!context.packageMatchesConfig(uFile.packageName)) return
checkUAnnotated(context, node, node.sourcePsi as? KtDeclaration)
}

override fun visitMethod(node: UMethod) {
val uFile = node.getContainingUFileOrNull() ?: return
if (!context.packageMatchesConfig(uFile.packageName)) return
checkUAnnotated(context, node, node.sourcePsi as? KtNamedFunction)
}

override fun visitField(node: UField) {
val uFile = node.getContainingUFileOrNull() ?: return
if (!context.packageMatchesConfig(uFile.packageName)) return
checkUAnnotated(context, node, node.sourcePsi as? KtProperty)
}

override fun visitFile(node: UFile) {
val ktFile = node.sourcePsi as? KtFile ?: return
val pkg = ktFile.packageFqName.asString()
if (!context.packageMatchesConfig(pkg)) return
ktFile.declarations.filterIsInstance<KtTypeAlias>().forEach { alias ->
checkTypeAlias(context, alias)
}
}
}

private fun checkUAnnotated(context: JavaContext, u: UElement, kt: KtDeclaration?) {
kt ?: return
if (!kt.isTopLevelPublic()) return
val annotated = (u as? UAnnotated)?.hasAnyAnnotation(MARKERS) ?: false
if (annotated) return
reportWithDualFix(context, u, kt)
}

private fun checkTypeAlias(context: JavaContext, alias: KtTypeAlias) {
if (!alias.isTopLevelPublic()) return
val annotated = alias.hasAnyAnnotationPsi(MARKERS_SIMPLE)
if (annotated) return
reportWithDualFix(context, alias, alias)
}

private fun reportWithDualFix(context: JavaContext, node: KtTypeAlias, decl: KtDeclaration) {
val loc = context.getLocation(decl)
val fixPublished =
LintFix.create()
.name("Annotate with @$PUBLISHED_SIMPLE")
.replace()
.range(loc)
.pattern("^")
.with("@$PUBLISHED_FQ\n")
.reformat(true)
.shortenNames()
.autoFix()
.build()
val fixInternal =
LintFix.create()
.name("Annotate with @$INTERNAL_SIMPLE")
.replace()
.range(loc)
.pattern("^")
.with("@$INTERNAL_FQ\n")
.reformat(true)
.shortenNames()
.autoFix()
.build()

context.report(
ISSUE,
node,
loc,
"Public API must be explicitly marked with @$PUBLISHED_SIMPLE or @$INTERNAL_SIMPLE.",
LintFix.create().group(fixPublished, fixInternal),
)
}

private fun reportWithDualFix(context: JavaContext, node: UElement, decl: KtDeclaration) {
val loc = context.getLocation(decl)
val fixPublished =
LintFix.create()
.name("Annotate with @$PUBLISHED_SIMPLE")
.replace()
.range(loc)
.pattern("^")
.with("@$PUBLISHED_FQ\n")
.reformat(true)
.shortenNames()
.autoFix()
.build()
val fixInternal =
LintFix.create()
.name("Annotate with @$INTERNAL_SIMPLE")
.replace()
.range(loc)
.pattern("^")
.with("@$INTERNAL_FQ\n")
.reformat(true)
.shortenNames()
.autoFix()
.build()

context.report(
ISSUE,
node,
loc,
"Public API must be explicitly marked with @$PUBLISHED_SIMPLE or @$INTERNAL_SIMPLE.",
LintFix.create().group(fixPublished, fixInternal),
)
}

// ----- package filtering helpers -----

private fun JavaContext.packageMatchesConfig(pkg: String): Boolean {
val patterns = configuredPackageGlobs()

// Default if not configured → only io.getstream.android.core.*
val effectivePatterns = patterns.ifEmpty { listOf("io.getstream.android.core.api") }

val included = effectivePatterns.any { pkgMatchesGlob(pkg, it) }
val excluded = packageMatchesExcludeConfig(pkg)
return included && !excluded
}

private fun JavaContext.packageMatchesExcludeConfig(pkg: String): Boolean {
val raw = configuration.getOption(ISSUE, OPTION_PACKAGES_EXCLUDE.name, "")?.trim().orEmpty()
if (raw.isEmpty()) return false
val patterns = raw.split(',').map { it.trim() }.filter { it.isNotEmpty() }
return patterns.any { pkgMatchesGlob(pkg, it) }
}

private fun JavaContext.configuredPackageGlobs(): List<String> {
val raw = configuration.getOption(ISSUE, OPTION_PACKAGES.name, "")?.trim().orEmpty()
if (raw.isEmpty()) return emptyList()
return raw.split(',').map { it.trim() }.filter { it.isNotEmpty() }
}

/** Simple glob matcher: `*` → `.*`, `?` → `.`, dot escaped; anchored. */
private fun pkgMatchesGlob(pkg: String, glob: String): Boolean = globToRegex(glob).matches(pkg)

private fun globToRegex(glob: String): Regex {
val sb = StringBuilder("^")
for (ch in glob) {
when (ch) {
'*' -> sb.append(".*")
'?' -> sb.append('.')
'.' -> sb.append("\\.")
else -> sb.append(Regex.escape(ch.toString()))
}
}
sb.append('$')
return sb.toString().toRegex()
}

// ----- misc helpers -----

private fun UAnnotated.hasAnyAnnotation(qns: Set<String>) =
qns.any { findAnnotation(it) != null || findAnnotation(it.substringAfterLast('.')) != null }

private fun KtAnnotated.hasAnyAnnotationPsi(simpleNames: Set<String>): Boolean =
annotationEntries.any { entry ->
entry.shortName?.asString() in simpleNames ||
entry.typeReference?.text in simpleNames // handles rare fully-qualified usage
}

private fun UElement.getContainingUFileOrNull(): UFile? {
var cur: UElement? = this
while (cur != null) {
if (cur is UFile) return cur
cur = cur.uastParent
}
return null
}

private fun KtDeclaration.isTopLevelPublic(): Boolean {
if (parent !is KtFile) return false
val mods = modifierList
val isPublic =
mods?.hasModifier(KtTokens.PUBLIC_KEYWORD) == true ||
!(mods?.hasModifier(KtTokens.PRIVATE_KEYWORD) == true ||
mods?.hasModifier(KtTokens.PROTECTED_KEYWORD) == true ||
mods?.hasModifier(KtTokens.INTERNAL_KEYWORD) == true)
return isPublic
}

companion object {
private const val PUBLISHED_FQ = "io.getstream.android.core.annotations.StreamPublishedApi"
private const val PUBLISHED_SIMPLE = "StreamPublishedApi"
private const val INTERNAL_FQ = "io.getstream.android.core.annotations.StreamInternalApi"
private const val INTERNAL_SIMPLE = "StreamInternalApi"
private val MARKERS = setOf(PUBLISHED_FQ, INTERNAL_FQ)
private val MARKERS_SIMPLE = setOf(PUBLISHED_SIMPLE, INTERNAL_SIMPLE)

private val OPTION_PACKAGES =
StringOption(
name = "packages",
description = "Comma-separated package **glob** patterns where the rule applies.",
explanation =
"""
Supports wildcards: '*' (any sequence) and '?' (single char).
Examples:
- 'io.getstream.android.core.api'
- 'io.getstream.android.core.*.api'
- 'io.getstream.android.*'
"""
.trimIndent(),
)

private val OPTION_PACKAGES_EXCLUDE =
StringOption(
name = "exclude_packages",
description = "Comma-separated package **glob** patterns to exclude from the rule.",
explanation =
"""
Same glob syntax as 'packages'. Evaluated after includes.
"""
.trimIndent(),
)

private val IMPLEMENTATION =
Implementation(StreamApiExplicitMarkerDetector::class.java, Scope.JAVA_FILE_SCOPE)

@JvmField
val ISSUE: Issue =
Issue.create(
"StreamApiExplicitMarkerMissing",
"Public API must be explicitly marked",
"""
To prevent accidental exposure, all top-level public declarations must be explicitly \
marked as @StreamPublishedApi (allowed to leak) or @StreamInternalApi (not allowed to leak).
"""
.trimIndent(),
Category.CORRECTNESS,
7,
Severity.ERROR,
IMPLEMENTATION,
)
.setOptions(listOf(OPTION_PACKAGES, OPTION_PACKAGES_EXCLUDE))
}
}
Loading