Skip to content

Commit 523e558

Browse files
Allow for more explicit API declarations (#10)
1 parent e6411a7 commit 523e558

File tree

50 files changed

+586
-569
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

50 files changed

+586
-569
lines changed

.github/workflows/android.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,3 +83,8 @@ jobs:
8383
path: stream-android-core/build/reports/tests/testDebugUnitTest/index.html
8484

8585
- uses: GetStream/android-ci-actions/actions/setup-ruby@main
86+
87+
- name: Sonar
88+
run: ./gradlew sonar
89+
env:
90+
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}

app/build.gradle.kts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,21 @@
1+
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
2+
13
plugins {
24
alias(libs.plugins.android.application)
35
alias(libs.plugins.kotlin.android)
46
alias(libs.plugins.kotlin.compose)
57
}
68

9+
kotlin {
10+
compilerOptions {
11+
jvmTarget.set(JvmTarget.JVM_11)
12+
freeCompilerArgs.addAll(
13+
"-opt-in=io.getstream.android.core.annotations.StreamInternalApi",
14+
"-XXLanguage:+PropertyParamAnnotationDefaultTargetMode"
15+
)
16+
}
17+
}
18+
719
android {
820
namespace = "io.getstream.android.core"
921
compileSdk = 36
@@ -42,6 +54,7 @@ android {
4254
dependencies {
4355

4456
implementation(project(":stream-android-core"))
57+
implementation(project(":stream-android-core-annotations"))
4558

4659
implementation(libs.androidx.core.ktx)
4760
implementation(libs.androidx.appcompat)

buildSrc/src/main/kotlin/io/getstream/core/Configuration.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
* you may not use this file except in compliance with the License.
66
* You may obtain a copy of the License at
77
*
8-
* https://github.com/GetStream/stream-feeds-android/blob/main/LICENSE
8+
* https://github.com/GetStream/stream-core-android/blob/main/LICENSE
99
*
1010
* Unless required by applicable law or agreed to in writing, software
1111
* distributed under the License is distributed on an "AS IS" BASIS,

lint.xml

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,7 @@
88
</issue>
99
<issue id="SuspendRunCatching" severity="error"/>
1010
<issue id="ExposeAsStateFlow" severity="error"/>
11-
<issue id="StreamCoreApiMissing" severity="error">
11+
<issue id="StreamApiExplicitMarker" severity="error">
1212
<option name="packages" value="io.getstream.android.core.api.*,io.getstream.android.core.api" />
13-
<option name="exclude_packages" value="io.getstream.android.core.api.model.value.*" />
1413
</issue>
1514
</lint>

stream-android-core-annotations/src/main/java/io/getstream/android/core/annotations/StreamApiMarkers.kt

Lines changed: 4 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -88,15 +88,9 @@ annotation class StreamInternalApi
8888
annotation class StreamDelicateApi(val message: String)
8989

9090
/**
91-
* Marks APIs that are part of the **Stream Core SDK layer**.
92-
*
93-
* These APIs are primarily intended for **internal use within Stream SDKs** (e.g. video, chat, or
94-
* other verticals) and are not considered public surface APIs.
95-
*
96-
* While they may be accessible, external usage is **discouraged** because:
97-
* - Support will typically focus on higher-level, public APIs instead.
98-
* - A vertical can upgrade to a major version of Stream core that may no longer have or support
99-
* this api.
91+
* Marks APIs that are part of the **Stream core SDK layer**. This API can be safely published and
92+
* used by other Stream SDKs. They can also be propagated and exposed via public APIs of the product
93+
* SDKs.
10094
*/
10195
@Target(
10296
AnnotationTarget.CLASS,
@@ -106,10 +100,4 @@ annotation class StreamDelicateApi(val message: String)
106100
AnnotationTarget.TYPEALIAS,
107101
)
108102
@Retention(AnnotationRetention.BINARY)
109-
@RequiresOptIn(
110-
message =
111-
"Stream Core SDK API – intended for use only within the Stream SDK core. " +
112-
"External usage is discouraged and may not be supported.",
113-
level = RequiresOptIn.Level.ERROR,
114-
)
115-
annotation class StreamCoreApi
103+
annotation class StreamPublishedApi

stream-android-core-lint/src/main/java/io/getstream/android/core/lint/StreamIssueRegistry.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ import com.android.tools.lint.detector.api.Issue
2222
import io.getstream.android.core.lint.detectors.ExposeAsStateFlowDetector
2323
import io.getstream.android.core.lint.detectors.KeepInstanceDetector
2424
import io.getstream.android.core.lint.detectors.MustBeInternalDetector
25-
import io.getstream.android.core.lint.detectors.StreamCoreApiDetector
25+
import io.getstream.android.core.lint.detectors.StreamApiExplicitMarkerDetector
2626
import io.getstream.android.core.lint.detectors.SuspendRunCatchingDetector
2727

2828
/** The stream lint rules registry. */
@@ -34,7 +34,7 @@ class StreamIssueRegistry : IssueRegistry() {
3434
KeepInstanceDetector.ISSUE,
3535
SuspendRunCatchingDetector.ISSUE,
3636
ExposeAsStateFlowDetector.ISSUE,
37-
StreamCoreApiDetector.ISSUE,
37+
StreamApiExplicitMarkerDetector.ISSUE,
3838
)
3939

4040
override val vendor =
Lines changed: 270 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,270 @@
1+
/*
2+
* Copyright (c) 2014-2025 Stream.io Inc. All rights reserved.
3+
*
4+
* Licensed under the Stream License;
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://github.com/GetStream/stream-core-android/blob/main/LICENSE
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package io.getstream.android.core.lint.detectors
17+
18+
import com.android.tools.lint.client.api.UElementHandler
19+
import com.android.tools.lint.detector.api.*
20+
import org.jetbrains.kotlin.lexer.KtTokens
21+
import org.jetbrains.kotlin.psi.*
22+
import org.jetbrains.uast.*
23+
24+
class StreamApiExplicitMarkerDetector : Detector(), Detector.UastScanner {
25+
26+
override fun getApplicableUastTypes() =
27+
listOf(UClass::class.java, UMethod::class.java, UField::class.java, UFile::class.java)
28+
29+
override fun createUastHandler(context: JavaContext) =
30+
object : UElementHandler() {
31+
override fun visitClass(node: UClass) {
32+
val uFile = node.getContainingUFileOrNull() ?: return
33+
if (!context.packageMatchesConfig(uFile.packageName)) return
34+
checkUAnnotated(context, node, node.sourcePsi as? KtDeclaration)
35+
}
36+
37+
override fun visitMethod(node: UMethod) {
38+
val uFile = node.getContainingUFileOrNull() ?: return
39+
if (!context.packageMatchesConfig(uFile.packageName)) return
40+
checkUAnnotated(context, node, node.sourcePsi as? KtNamedFunction)
41+
}
42+
43+
override fun visitField(node: UField) {
44+
val uFile = node.getContainingUFileOrNull() ?: return
45+
if (!context.packageMatchesConfig(uFile.packageName)) return
46+
checkUAnnotated(context, node, node.sourcePsi as? KtProperty)
47+
}
48+
49+
override fun visitFile(node: UFile) {
50+
val ktFile = node.sourcePsi as? KtFile ?: return
51+
val pkg = ktFile.packageFqName.asString()
52+
if (!context.packageMatchesConfig(pkg)) return
53+
ktFile.declarations.filterIsInstance<KtTypeAlias>().forEach { alias ->
54+
checkTypeAlias(context, alias)
55+
}
56+
}
57+
}
58+
59+
private fun checkUAnnotated(context: JavaContext, u: UElement, kt: KtDeclaration?) {
60+
kt ?: return
61+
if (!kt.isTopLevelPublic()) return
62+
val annotated = (u as? UAnnotated)?.hasAnyAnnotation(MARKERS) ?: false
63+
if (annotated) return
64+
reportWithDualFix(context, u, kt)
65+
}
66+
67+
private fun checkTypeAlias(context: JavaContext, alias: KtTypeAlias) {
68+
if (!alias.isTopLevelPublic()) return
69+
val annotated = alias.hasAnyAnnotationPsi(MARKERS_SIMPLE)
70+
if (annotated) return
71+
reportWithDualFix(context, alias, alias)
72+
}
73+
74+
private fun reportWithDualFix(context: JavaContext, node: KtTypeAlias, decl: KtDeclaration) {
75+
val loc = context.getLocation(decl)
76+
val fixPublished =
77+
LintFix.create()
78+
.name("Annotate with @$PUBLISHED_SIMPLE")
79+
.replace()
80+
.range(loc)
81+
.pattern("^")
82+
.with("@$PUBLISHED_FQ\n")
83+
.reformat(true)
84+
.shortenNames()
85+
.autoFix()
86+
.build()
87+
val fixInternal =
88+
LintFix.create()
89+
.name("Annotate with @$INTERNAL_SIMPLE")
90+
.replace()
91+
.range(loc)
92+
.pattern("^")
93+
.with("@$INTERNAL_FQ\n")
94+
.reformat(true)
95+
.shortenNames()
96+
.autoFix()
97+
.build()
98+
99+
context.report(
100+
ISSUE,
101+
node,
102+
loc,
103+
"Public API must be explicitly marked with @$PUBLISHED_SIMPLE or @$INTERNAL_SIMPLE.",
104+
LintFix.create().group(fixPublished, fixInternal),
105+
)
106+
}
107+
108+
private fun reportWithDualFix(context: JavaContext, node: UElement, decl: KtDeclaration) {
109+
val loc = context.getLocation(decl)
110+
val fixPublished =
111+
LintFix.create()
112+
.name("Annotate with @$PUBLISHED_SIMPLE")
113+
.replace()
114+
.range(loc)
115+
.pattern("^")
116+
.with("@$PUBLISHED_FQ\n")
117+
.reformat(true)
118+
.shortenNames()
119+
.autoFix()
120+
.build()
121+
val fixInternal =
122+
LintFix.create()
123+
.name("Annotate with @$INTERNAL_SIMPLE")
124+
.replace()
125+
.range(loc)
126+
.pattern("^")
127+
.with("@$INTERNAL_FQ\n")
128+
.reformat(true)
129+
.shortenNames()
130+
.autoFix()
131+
.build()
132+
133+
context.report(
134+
ISSUE,
135+
node,
136+
loc,
137+
"Public API must be explicitly marked with @$PUBLISHED_SIMPLE or @$INTERNAL_SIMPLE.",
138+
LintFix.create().group(fixPublished, fixInternal),
139+
)
140+
}
141+
142+
// ----- package filtering helpers -----
143+
144+
private fun JavaContext.packageMatchesConfig(pkg: String): Boolean {
145+
val patterns = configuredPackageGlobs()
146+
147+
// Default if not configured → only io.getstream.android.core.*
148+
val effectivePatterns = patterns.ifEmpty { listOf("io.getstream.android.core.api") }
149+
150+
val included = effectivePatterns.any { pkgMatchesGlob(pkg, it) }
151+
val excluded = packageMatchesExcludeConfig(pkg)
152+
return included && !excluded
153+
}
154+
155+
private fun JavaContext.packageMatchesExcludeConfig(pkg: String): Boolean {
156+
val raw = configuration.getOption(ISSUE, OPTION_PACKAGES_EXCLUDE.name, "")?.trim().orEmpty()
157+
if (raw.isEmpty()) return false
158+
val patterns = raw.split(',').map { it.trim() }.filter { it.isNotEmpty() }
159+
return patterns.any { pkgMatchesGlob(pkg, it) }
160+
}
161+
162+
private fun JavaContext.configuredPackageGlobs(): List<String> {
163+
val raw = configuration.getOption(ISSUE, OPTION_PACKAGES.name, "")?.trim().orEmpty()
164+
if (raw.isEmpty()) return emptyList()
165+
return raw.split(',').map { it.trim() }.filter { it.isNotEmpty() }
166+
}
167+
168+
/** Simple glob matcher: `*` → `.*`, `?` → `.`, dot escaped; anchored. */
169+
private fun pkgMatchesGlob(pkg: String, glob: String): Boolean = globToRegex(glob).matches(pkg)
170+
171+
private fun globToRegex(glob: String): Regex {
172+
val sb = StringBuilder("^")
173+
for (ch in glob) {
174+
when (ch) {
175+
'*' -> sb.append(".*")
176+
'?' -> sb.append('.')
177+
'.' -> sb.append("\\.")
178+
else -> sb.append(Regex.escape(ch.toString()))
179+
}
180+
}
181+
sb.append('$')
182+
return sb.toString().toRegex()
183+
}
184+
185+
// ----- misc helpers -----
186+
187+
private fun UAnnotated.hasAnyAnnotation(qns: Set<String>) =
188+
qns.any { findAnnotation(it) != null || findAnnotation(it.substringAfterLast('.')) != null }
189+
190+
private fun KtAnnotated.hasAnyAnnotationPsi(simpleNames: Set<String>): Boolean =
191+
annotationEntries.any { entry ->
192+
entry.shortName?.asString() in simpleNames ||
193+
entry.typeReference?.text in simpleNames // handles rare fully-qualified usage
194+
}
195+
196+
private fun UElement.getContainingUFileOrNull(): UFile? {
197+
var cur: UElement? = this
198+
while (cur != null) {
199+
if (cur is UFile) return cur
200+
cur = cur.uastParent
201+
}
202+
return null
203+
}
204+
205+
private fun KtDeclaration.isTopLevelPublic(): Boolean {
206+
if (parent !is KtFile) return false
207+
val mods = modifierList
208+
val isPublic =
209+
mods?.hasModifier(KtTokens.PUBLIC_KEYWORD) == true ||
210+
!(mods?.hasModifier(KtTokens.PRIVATE_KEYWORD) == true ||
211+
mods?.hasModifier(KtTokens.PROTECTED_KEYWORD) == true ||
212+
mods?.hasModifier(KtTokens.INTERNAL_KEYWORD) == true)
213+
return isPublic
214+
}
215+
216+
companion object {
217+
private const val PUBLISHED_FQ = "io.getstream.android.core.annotations.StreamPublishedApi"
218+
private const val PUBLISHED_SIMPLE = "StreamPublishedApi"
219+
private const val INTERNAL_FQ = "io.getstream.android.core.annotations.StreamInternalApi"
220+
private const val INTERNAL_SIMPLE = "StreamInternalApi"
221+
private val MARKERS = setOf(PUBLISHED_FQ, INTERNAL_FQ)
222+
private val MARKERS_SIMPLE = setOf(PUBLISHED_SIMPLE, INTERNAL_SIMPLE)
223+
224+
private val OPTION_PACKAGES =
225+
StringOption(
226+
name = "packages",
227+
description = "Comma-separated package **glob** patterns where the rule applies.",
228+
explanation =
229+
"""
230+
Supports wildcards: '*' (any sequence) and '?' (single char).
231+
Examples:
232+
- 'io.getstream.android.core.api'
233+
- 'io.getstream.android.core.*.api'
234+
- 'io.getstream.android.*'
235+
"""
236+
.trimIndent(),
237+
)
238+
239+
private val OPTION_PACKAGES_EXCLUDE =
240+
StringOption(
241+
name = "exclude_packages",
242+
description = "Comma-separated package **glob** patterns to exclude from the rule.",
243+
explanation =
244+
"""
245+
Same glob syntax as 'packages'. Evaluated after includes.
246+
"""
247+
.trimIndent(),
248+
)
249+
250+
private val IMPLEMENTATION =
251+
Implementation(StreamApiExplicitMarkerDetector::class.java, Scope.JAVA_FILE_SCOPE)
252+
253+
@JvmField
254+
val ISSUE: Issue =
255+
Issue.create(
256+
"StreamApiExplicitMarkerMissing",
257+
"Public API must be explicitly marked",
258+
"""
259+
To prevent accidental exposure, all top-level public declarations must be explicitly \
260+
marked as @StreamPublishedApi (allowed to leak) or @StreamInternalApi (not allowed to leak).
261+
"""
262+
.trimIndent(),
263+
Category.CORRECTNESS,
264+
7,
265+
Severity.ERROR,
266+
IMPLEMENTATION,
267+
)
268+
.setOptions(listOf(OPTION_PACKAGES, OPTION_PACKAGES_EXCLUDE))
269+
}
270+
}

0 commit comments

Comments
 (0)