David Liu

Merge branch 'flowlint'

1 <?xml version="1.0" encoding="UTF-8"?> 1 <?xml version="1.0" encoding="UTF-8"?>
2 <project version="4"> 2 <project version="4">
3 <component name="CompilerConfiguration"> 3 <component name="CompilerConfiguration">
4 - <bytecodeTargetLevel target="11" /> 4 + <bytecodeTargetLevel target="1.8">
  5 + <module name="livekit-android.livekit-android-sdk" target="11" />
  6 + <module name="livekit-android.sample-app" target="11" />
  7 + <module name="livekit-android.sample-app-common" target="11" />
  8 + <module name="livekit-android.sample-app-compose" target="11" />
  9 + </bytecodeTargetLevel>
5 </component> 10 </component>
6 </project> 11 </project>
@@ -53,16 +53,34 @@ ext { @@ -53,16 +53,34 @@ ext {
53 versions = [ 53 versions = [
54 androidx_core : "1.6.0", 54 androidx_core : "1.6.0",
55 androidx_lifecycle: "2.4.0", 55 androidx_lifecycle: "2.4.0",
  56 + autoService : '1.0.1',
56 dagger : "2.27", 57 dagger : "2.27",
57 groupie : "2.9.0", 58 groupie : "2.9.0",
  59 + junit : "4.13.2",
  60 + junitJupiter : "5.5.0",
  61 + lint : "30.0.1",
58 protobuf : "3.15.1", 62 protobuf : "3.15.1",
59 ] 63 ]
60 generated = [ 64 generated = [
61 protoSrc: "$projectDir/protocol", 65 protoSrc: "$projectDir/protocol",
62 ] 66 ]
63 deps = [ 67 deps = [
  68 + auto : [
  69 + 'service' : "com.google.auto.service:auto-service:${versions.autoService}",
  70 + 'serviceAnnotations': "com.google.auto.service:auto-service-annotations:${versions.autoService}",
  71 + ],
64 kotlinx_coroutines: "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.5.2", 72 kotlinx_coroutines: "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.5.2",
65 - timber : "com.github.ajalt:timberkt:1.5.1", 73 + timber : "com.github.ajalt:timberkt:1.5.1",
  74 + // lint
  75 + lint : "com.android.tools.lint:lint:${versions.lint}",
  76 + lintApi : "com.android.tools.lint:lint-api:${versions.lint}",
  77 + lintChecks : "com.android.tools.lint:lint-checks:${versions.lint}",
  78 + lintTests : "com.android.tools.lint:lint-tests:${versions.lint}",
  79 +
  80 + // tests
  81 + junit : "junit:junit:${versions.junit}",
  82 + junitJupiterApi : "org.junit.jupiter:junit-jupiter-api:${versions.junitJupiter}",
  83 + junitJupiterEngine: "org.junit.jupiter:junit-jupiter-engine:${versions.junitJupiter}",
66 ] 84 ]
67 annotations = [ 85 annotations = [
68 ] 86 ]
@@ -118,6 +118,9 @@ dependencies { @@ -118,6 +118,9 @@ dependencies {
118 implementation deps.timber 118 implementation deps.timber
119 implementation 'com.vdurmont:semver4j:3.1.0' 119 implementation 'com.vdurmont:semver4j:3.1.0'
120 120
  121 + lintChecks project(':livekit-lint')
  122 + lintPublish project(':livekit-lint')
  123 +
121 testImplementation 'junit:junit:4.13.2' 124 testImplementation 'junit:junit:4.13.2'
122 testImplementation 'org.robolectric:robolectric:4.6' 125 testImplementation 'org.robolectric:robolectric:4.6'
123 testImplementation 'org.mockito:mockito-core:4.0.0' 126 testImplementation 'org.mockito:mockito-core:4.0.0'
@@ -16,6 +16,7 @@ import io.livekit.android.events.RoomEvent @@ -16,6 +16,7 @@ import io.livekit.android.events.RoomEvent
16 import io.livekit.android.renderer.TextureViewRenderer 16 import io.livekit.android.renderer.TextureViewRenderer
17 import io.livekit.android.room.participant.* 17 import io.livekit.android.room.participant.*
18 import io.livekit.android.room.track.* 18 import io.livekit.android.room.track.*
  19 +import io.livekit.android.util.FlowObservable
19 import io.livekit.android.util.LKLog 20 import io.livekit.android.util.LKLog
20 import io.livekit.android.util.flow 21 import io.livekit.android.util.flow
21 import io.livekit.android.util.flowDelegate 22 import io.livekit.android.util.flowDelegate
@@ -60,23 +61,18 @@ constructor( @@ -60,23 +61,18 @@ constructor(
60 @Deprecated("Use events instead.") 61 @Deprecated("Use events instead.")
61 var listener: RoomListener? = null 62 var listener: RoomListener? = null
62 63
63 - /**  
64 - * Changes can be observed by using [io.livekit.android.util.flow]  
65 - */ 64 + @get:FlowObservable
66 var sid: Sid? by flowDelegate(null) 65 var sid: Sid? by flowDelegate(null)
67 - /**  
68 - * Changes can be observed by using [io.livekit.android.util.flow]  
69 - */ 66 +
  67 + @get:FlowObservable
70 var name: String? by flowDelegate(null) 68 var name: String? by flowDelegate(null)
71 private set 69 private set
72 - /**  
73 - * Changes can be observed by using [io.livekit.android.util.flow]  
74 - */ 70 +
  71 + @get:FlowObservable
75 var state: State by flowDelegate(State.DISCONNECTED) 72 var state: State by flowDelegate(State.DISCONNECTED)
76 private set 73 private set
77 - /**  
78 - * Changes can be observed by using [io.livekit.android.util.flow]  
79 - */ 74 +
  75 + @get:FlowObservable
80 var metadata: String? by flowDelegate(null) 76 var metadata: String? by flowDelegate(null)
81 private set 77 private set
82 78
@@ -112,17 +108,16 @@ constructor( @@ -112,17 +108,16 @@ constructor(
112 108
113 lateinit var localParticipant: LocalParticipant 109 lateinit var localParticipant: LocalParticipant
114 private set 110 private set
  111 +
115 private var mutableRemoteParticipants by flowDelegate(emptyMap<String, RemoteParticipant>()) 112 private var mutableRemoteParticipants by flowDelegate(emptyMap<String, RemoteParticipant>())
116 - /**  
117 - * Changes can be observed by using [io.livekit.android.util.flow]  
118 - */ 113 +
  114 + @get:FlowObservable
119 val remoteParticipants: Map<String, RemoteParticipant> 115 val remoteParticipants: Map<String, RemoteParticipant>
120 get() = mutableRemoteParticipants 116 get() = mutableRemoteParticipants
121 117
122 private var mutableActiveSpeakers by flowDelegate(emptyList<Participant>()) 118 private var mutableActiveSpeakers by flowDelegate(emptyList<Participant>())
123 - /**  
124 - * Changes can be observed by using [io.livekit.android.util.flow]  
125 - */ 119 +
  120 + @get:FlowObservable
126 val activeSpeakers: List<Participant> 121 val activeSpeakers: List<Participant>
127 get() = mutableActiveSpeakers 122 get() = mutableActiveSpeakers
128 123
@@ -7,6 +7,7 @@ import io.livekit.android.room.track.LocalTrackPublication @@ -7,6 +7,7 @@ import io.livekit.android.room.track.LocalTrackPublication
7 import io.livekit.android.room.track.RemoteTrackPublication 7 import io.livekit.android.room.track.RemoteTrackPublication
8 import io.livekit.android.room.track.Track 8 import io.livekit.android.room.track.Track
9 import io.livekit.android.room.track.TrackPublication 9 import io.livekit.android.room.track.TrackPublication
  10 +import io.livekit.android.util.FlowObservable
10 import io.livekit.android.util.flow 11 import io.livekit.android.util.flow
11 import io.livekit.android.util.flowDelegate 12 import io.livekit.android.util.flowDelegate
12 import kotlinx.coroutines.CoroutineDispatcher 13 import kotlinx.coroutines.CoroutineDispatcher
@@ -32,23 +33,27 @@ open class Participant( @@ -32,23 +33,27 @@ open class Participant(
32 /** 33 /**
33 * Changes can be observed by using [io.livekit.android.util.flow] 34 * Changes can be observed by using [io.livekit.android.util.flow]
34 */ 35 */
  36 + @get:FlowObservable
35 var participantInfo: LivekitModels.ParticipantInfo? by flowDelegate(null) 37 var participantInfo: LivekitModels.ParticipantInfo? by flowDelegate(null)
36 private set 38 private set
37 39
38 /** 40 /**
39 * Changes can be observed by using [io.livekit.android.util.flow] 41 * Changes can be observed by using [io.livekit.android.util.flow]
40 */ 42 */
  43 + @get:FlowObservable
41 var identity: String? by flowDelegate(identity) 44 var identity: String? by flowDelegate(identity)
42 internal set 45 internal set
43 46
44 /** 47 /**
45 * Changes can be observed by using [io.livekit.android.util.flow] 48 * Changes can be observed by using [io.livekit.android.util.flow]
46 */ 49 */
  50 + @get:FlowObservable
47 var audioLevel: Float by flowDelegate(0f) 51 var audioLevel: Float by flowDelegate(0f)
48 internal set 52 internal set
49 /** 53 /**
50 * Changes can be observed by using [io.livekit.android.util.flow] 54 * Changes can be observed by using [io.livekit.android.util.flow]
51 */ 55 */
  56 + @get:FlowObservable
52 var isSpeaking: Boolean by flowDelegate(false) { newValue, oldValue -> 57 var isSpeaking: Boolean by flowDelegate(false) { newValue, oldValue ->
53 if (newValue != oldValue) { 58 if (newValue != oldValue) {
54 listener?.onSpeakingChanged(this) 59 listener?.onSpeakingChanged(this)
@@ -60,6 +65,7 @@ open class Participant( @@ -60,6 +65,7 @@ open class Participant(
60 /** 65 /**
61 * Changes can be observed by using [io.livekit.android.util.flow] 66 * Changes can be observed by using [io.livekit.android.util.flow]
62 */ 67 */
  68 + @get:FlowObservable
63 var metadata: String? by flowDelegate(null) { newMetadata, oldMetadata -> 69 var metadata: String? by flowDelegate(null) { newMetadata, oldMetadata ->
64 if (newMetadata != oldMetadata) { 70 if (newMetadata != oldMetadata) {
65 listener?.onMetadataChanged(this, oldMetadata) 71 listener?.onMetadataChanged(this, oldMetadata)
@@ -72,6 +78,7 @@ open class Participant( @@ -72,6 +78,7 @@ open class Participant(
72 /** 78 /**
73 * Changes can be observed by using [io.livekit.android.util.flow] 79 * Changes can be observed by using [io.livekit.android.util.flow]
74 */ 80 */
  81 + @get:FlowObservable
75 var connectionQuality by flowDelegate(ConnectionQuality.UNKNOWN) 82 var connectionQuality by flowDelegate(ConnectionQuality.UNKNOWN)
76 internal set 83 internal set
77 84
@@ -93,11 +100,13 @@ open class Participant( @@ -93,11 +100,13 @@ open class Participant(
93 /** 100 /**
94 * Changes can be observed by using [io.livekit.android.util.flow] 101 * Changes can be observed by using [io.livekit.android.util.flow]
95 */ 102 */
  103 + @get:FlowObservable
96 var tracks by flowDelegate(emptyMap<String, TrackPublication>()) 104 var tracks by flowDelegate(emptyMap<String, TrackPublication>())
97 protected set 105 protected set
98 /** 106 /**
99 * Changes can be observed by using [io.livekit.android.util.flow] 107 * Changes can be observed by using [io.livekit.android.util.flow]
100 */ 108 */
  109 + @get:FlowObservable
101 val audioTracks by flowDelegate( 110 val audioTracks by flowDelegate(
102 stateFlow = ::tracks.flow 111 stateFlow = ::tracks.flow
103 .map { it.filterValues { publication -> publication.kind == Track.Kind.AUDIO } } 112 .map { it.filterValues { publication -> publication.kind == Track.Kind.AUDIO } }
@@ -106,6 +115,7 @@ open class Participant( @@ -106,6 +115,7 @@ open class Participant(
106 /** 115 /**
107 * Changes can be observed by using [io.livekit.android.util.flow] 116 * Changes can be observed by using [io.livekit.android.util.flow]
108 */ 117 */
  118 + @get:FlowObservable
109 val videoTracks by flowDelegate( 119 val videoTracks by flowDelegate(
110 stateFlow = ::tracks.flow 120 stateFlow = ::tracks.flow
111 .map { 121 .map {
1 package io.livekit.android.room.track 1 package io.livekit.android.room.track
2 2
3 import io.livekit.android.room.participant.Participant 3 import io.livekit.android.room.participant.Participant
  4 +import io.livekit.android.util.FlowObservable
4 import io.livekit.android.util.flowDelegate 5 import io.livekit.android.util.flowDelegate
5 import livekit.LivekitModels 6 import livekit.LivekitModels
6 import java.lang.ref.WeakReference 7 import java.lang.ref.WeakReference
@@ -10,6 +11,7 @@ open class TrackPublication( @@ -10,6 +11,7 @@ open class TrackPublication(
10 track: Track?, 11 track: Track?,
11 participant: Participant 12 participant: Participant
12 ) { 13 ) {
  14 + @get:FlowObservable
13 open var track: Track? by flowDelegate(track) 15 open var track: Track? by flowDelegate(track)
14 internal set 16 internal set
15 var name: String 17 var name: String
@@ -62,7 +62,16 @@ internal val <T> KProperty0<T>.delegate: Any? @@ -62,7 +62,16 @@ internal val <T> KProperty0<T>.delegate: Any?
62 val <T> KProperty0<T>.flow: StateFlow<T> 62 val <T> KProperty0<T>.flow: StateFlow<T>
63 get() = delegate as StateFlow<T> 63 get() = delegate as StateFlow<T>
64 64
65 -class MutableStateFlowDelegate<T> 65 +/**
  66 + * Indicates that the target property changes can be observed with [flow].
  67 + */
  68 +@Target(AnnotationTarget.CLASS, AnnotationTarget.PROPERTY_GETTER)
  69 +@Retention(AnnotationRetention.BINARY)
  70 +@MustBeDocumented
  71 +annotation class FlowObservable
  72 +
  73 +@FlowObservable
  74 +internal class MutableStateFlowDelegate<T>
66 internal constructor( 75 internal constructor(
67 private val flow: MutableStateFlow<T>, 76 private val flow: MutableStateFlow<T>,
68 private val onSetValue: ((newValue: T, oldValue: T) -> Unit)? = null 77 private val onSetValue: ((newValue: T, oldValue: T) -> Unit)? = null
@@ -82,7 +91,8 @@ internal constructor( @@ -82,7 +91,8 @@ internal constructor(
82 } 91 }
83 } 92 }
84 93
85 -class StateFlowDelegate<T> 94 +@FlowObservable
  95 +internal class StateFlowDelegate<T>
86 internal constructor( 96 internal constructor(
87 private val flow: StateFlow<T> 97 private val flow: StateFlow<T>
88 ) : StateFlow<T> by flow { 98 ) : StateFlow<T> by flow {
  1 +plugins {
  2 + id 'java-library'
  3 + id 'kotlin'
  4 + id 'kotlin-kapt'
  5 +}
  6 +
  7 +java {
  8 + sourceCompatibility = JavaVersion.VERSION_1_8
  9 + targetCompatibility = JavaVersion.VERSION_1_8
  10 +}
  11 +
  12 +dependencies {
  13 +
  14 + compileOnly "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
  15 + // used for lint rules
  16 + compileOnly deps.lintApi
  17 + compileOnly deps.lintChecks
  18 + compileOnly deps.lintTests
  19 +
  20 + // Handle creating manifests for lint checker
  21 + compileOnly deps.auto.serviceAnnotations
  22 + kapt deps.auto.service
  23 +
  24 + // test lint
  25 + testImplementation deps.lint
  26 + testImplementation deps.lintTests
  27 +
  28 + compileOnly "org.jetbrains.kotlin:kotlin-reflect:$kotlin_version"
  29 + // test runners
  30 + testImplementation deps.junit
  31 + testImplementation deps.junitJupiterApi
  32 + testRuntimeOnly deps.junitJupiterEngine
  33 +}
  34 +test {
  35 + environment "LINT_TEST_KOTLINC", ""
  36 +}
  1 +@file:Suppress("UnstableApiUsage") // We know that Lint API's aren't final.
  2 +
  3 +/*
  4 + * Copyright (C) 2018 The Android Open Source Project
  5 + *
  6 + * Licensed under the Apache License, Version 2.0 (the "License");
  7 + * you may not use this file except in compliance with the License.
  8 + * You may obtain a copy of the License at
  9 + *
  10 + * http://www.apache.org/licenses/LICENSE-2.0
  11 + *
  12 + * Unless required by applicable law or agreed to in writing, software
  13 + * distributed under the License is distributed on an "AS IS" BASIS,
  14 + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  15 + * See the License for the specific language governing permissions and
  16 + * limitations under the License.
  17 + */
  18 +
  19 +package io.livekit.lint
  20 +
  21 +import com.android.tools.lint.detector.api.*
  22 +import com.intellij.psi.PsiClassType
  23 +import com.intellij.psi.PsiElement
  24 +import com.intellij.psi.PsiField
  25 +import com.intellij.psi.PsiMethod
  26 +import org.jetbrains.uast.UCallableReferenceExpression
  27 +import org.jetbrains.uast.UReferenceExpression
  28 +import org.jetbrains.uast.kotlin.KotlinUQualifiedReferenceExpression
  29 +import org.jetbrains.uast.tryResolve
  30 +
  31 +/** Checks related to DiffUtil computation. */
  32 +class FlowDelegateUsageDetector : Detector(), SourceCodeScanner {
  33 +
  34 + override fun visitReference(context: JavaContext, reference: UReferenceExpression, referenced: PsiElement) {
  35 +
  36 + // Check if we're actually trying to access the flow delegate
  37 + val referencedMethod = referenced as? PsiMethod ?: return
  38 + if (referenced.name != GET_FLOW || referencedMethod.containingClass?.qualifiedName != FLOW_DELEGATE) {
  39 + return
  40 + }
  41 +
  42 + // This should get the getter we're trying to receive the flow from.
  43 + val parent = reference.uastParent as? KotlinUQualifiedReferenceExpression
  44 + val rec = parent?.receiver
  45 + val resolve = rec?.tryResolve()
  46 + println("parent: $parent, rec: $rec, resolve: $resolve")
  47 + val receiver = ((reference.uastParent as? KotlinUQualifiedReferenceExpression)
  48 + ?.receiver as? UCallableReferenceExpression)
  49 + ?.resolve()
  50 +
  51 + val isAnnotated = when (receiver) {
  52 + is PsiMethod -> {
  53 + println("${receiver.name}, ${receiver.annotations.fold("") { total, next -> "$total, ${next.text}" }}")
  54 + receiver.hasAnnotation(FLOW_OBSERVABLE_ANNOTATION)
  55 + }
  56 + is PsiField -> {
  57 + val receiverClass = (receiver.type as? PsiClassType)?.resolve()
  58 + println("${receiverClass}, ${receiverClass?.annotations?.fold("") { total, next -> "$total, ${next.text}" }}")
  59 + receiverClass?.hasAnnotation(FLOW_OBSERVABLE_ANNOTATION) ?: false
  60 + }
  61 + else -> {
  62 + false
  63 + }
  64 + }
  65 +
  66 + if (!isAnnotated) {
  67 + val message = DEFAULT_MSG
  68 + val location = context.getLocation(reference)
  69 + context.report(ISSUE, reference, location, message)
  70 + }
  71 + }
  72 +
  73 + override fun getApplicableReferenceNames(): List<String>? =
  74 + listOf("flow")
  75 +
  76 +
  77 + companion object {
  78 +
  79 + // The name of the method for the flow accessor
  80 + private const val GET_FLOW = "getFlow"
  81 +
  82 + // The containing file and implicitly generated class
  83 + private const val FLOW_DELEGATE = "io.livekit.android.util.FlowDelegateKt"
  84 +
  85 + private const val FLOW_OBSERVABLE_ANNOTATION = "io.livekit.android.util.FlowObservable"
  86 +
  87 + private const val DEFAULT_MSG =
  88 + "Incorrect flow property usage: Only properties marked with the @FlowObservable annotation can be observed using `io.livekit.android.util.flow`. Improper usage will result in a NullPointerException."
  89 +
  90 + private val IMPLEMENTATION =
  91 + Implementation(FlowDelegateUsageDetector::class.java, Scope.JAVA_FILE_SCOPE)
  92 +
  93 + @JvmField
  94 + val ISSUE = Issue.create(
  95 + id = "FlowDelegateUsageDetector",
  96 + briefDescription = "flow on a non-@FlowObservable property",
  97 + explanation = """
  98 + Only properties marked with the @FlowObservable annotation can be observed using
  99 + `io.livekit.android.util.flow`.
  100 + """,
  101 + category = Category.CORRECTNESS,
  102 + priority = 4,
  103 + androidSpecific = true,
  104 + moreInfo = "https://issuetracker.google.com/116789824",
  105 + severity = Severity.ERROR,
  106 + implementation = IMPLEMENTATION
  107 + )
  108 + }
  109 +}
  1 +package io.livekit.lint
  2 +
  3 +import com.android.tools.lint.client.api.IssueRegistry
  4 +import com.android.tools.lint.client.api.Vendor
  5 +import com.android.tools.lint.detector.api.CURRENT_API
  6 +import com.android.tools.lint.detector.api.Issue
  7 +import com.google.auto.service.AutoService
  8 +
  9 +@Suppress("UnstableApiUsage", "unused")
  10 +@AutoService(value = [IssueRegistry::class])
  11 +class IssueRegistry : IssueRegistry() {
  12 +
  13 + override val api: Int = CURRENT_API
  14 +
  15 + override val vendor: Vendor = Vendor(
  16 + vendorName = "LiveKit",
  17 + identifier = "io.livekit.android",
  18 + feedbackUrl = "https://github.com/livekit/client-sdk-android",
  19 + )
  20 +
  21 + override val issues: List<Issue>
  22 + get() = listOf(MediaTrackEqualsDetector.ISSUE, FlowDelegateUsageDetector.ISSUE)
  23 +}
  1 +@file:Suppress("UnstableApiUsage") // We know that Lint API's aren't final.
  2 +package io.livekit.lint
  3 +
  4 +import com.android.tools.lint.client.api.UElementHandler
  5 +import com.android.tools.lint.detector.api.*
  6 +import com.intellij.psi.PsiClassType
  7 +import org.jetbrains.uast.UBinaryExpression
  8 +import org.jetbrains.uast.UCallExpression
  9 +import org.jetbrains.uast.UastBinaryOperator
  10 +
  11 +/**
  12 + * Detects MediaStreamTrack.equals() usage. This is generally a mistake and should not be used.
  13 + */
  14 +class MediaTrackEqualsDetector : Detector(), SourceCodeScanner {
  15 +
  16 + override fun getApplicableUastTypes() =
  17 + listOf(UBinaryExpression::class.java, UCallExpression::class.java)
  18 +
  19 + override fun createUastHandler(context: JavaContext): UElementHandler? {
  20 + return object : UElementHandler() {
  21 +
  22 + override fun visitBinaryExpression(node: UBinaryExpression) {
  23 + checkExpression(context, node)
  24 + }
  25 +
  26 + override fun visitCallExpression(node: UCallExpression) {
  27 + checkCall(context, node)
  28 + }
  29 + }
  30 + }
  31 +
  32 + private fun checkCall(context: JavaContext, node: UCallExpression) {
  33 + if (node.methodName == "equals") {
  34 + val left = node.receiverType ?: return
  35 + val right = node.valueArguments.takeIf { it.isNotEmpty() }
  36 + ?.get(0)
  37 + ?.getExpressionType()
  38 + ?: return
  39 + if (left is PsiClassType && right is PsiClassType
  40 + && (left.canonicalText == MEDIA_STREAM_TRACK || right.canonicalText == MEDIA_STREAM_TRACK)
  41 + ) {
  42 + val message = DEFAULT_MSG
  43 + val location = context.getLocation(node)
  44 + context.report(ISSUE, node, location, message)
  45 + }
  46 + }
  47 + }
  48 +
  49 + private fun checkExpression(context: JavaContext, node: UBinaryExpression) {
  50 + if (node.operator == UastBinaryOperator.IDENTITY_EQUALS ||
  51 + node.operator == UastBinaryOperator.EQUALS
  52 + ) {
  53 + val left = node.leftOperand.getExpressionType() ?: return
  54 + val right = node.rightOperand.getExpressionType() ?: return
  55 + if (left is PsiClassType && right is PsiClassType
  56 + && (left.canonicalText == MEDIA_STREAM_TRACK || right.canonicalText == MEDIA_STREAM_TRACK)
  57 + ) {
  58 + val message = DEFAULT_MSG
  59 + val location = node.operatorIdentifier?.let {
  60 + context.getLocation(it)
  61 + } ?: context.getLocation(node)
  62 + context.report(ISSUE, node, location, message)
  63 + }
  64 + }
  65 + }
  66 +
  67 + companion object {
  68 + private const val MEDIA_STREAM_TRACK = "org.webrtc.MediaStreamTrack"
  69 +
  70 + private const val DEFAULT_MSG =
  71 + "Suspicious equality check: MediaStreamTracks should not be checked for equality. Check id() instead."
  72 +
  73 + private val IMPLEMENTATION =
  74 + Implementation(MediaTrackEqualsDetector::class.java, Scope.JAVA_FILE_SCOPE)
  75 +
  76 + @JvmField
  77 + val ISSUE = Issue.create(
  78 + id = "MediaTrackEqualsDetector",
  79 + briefDescription = "Suspicious MediaStreamTrack Equality",
  80 + explanation = """
  81 + MediaStreamTrack does not implement `equals`, and therefore cannot be relied upon.
  82 + Additionally, many MediaStreamTrack objects may exist for the same underlying stream,
  83 + and therefore the identity operator `===` is unreliable.
  84 + """,
  85 + category = Category.CORRECTNESS,
  86 + priority = 4,
  87 + androidSpecific = true,
  88 + moreInfo = "https://github.com/livekit/client-sdk-android/commit/01152f2ac01dae59759383d587cdc21035718b8e",
  89 + severity = Severity.ERROR,
  90 + implementation = IMPLEMENTATION
  91 + )
  92 + }
  93 +}
  1 +@file:Suppress("UnstableApiUsage", "NewObjectEquality")
  2 +
  3 +package io.livekit.lint
  4 +
  5 +import com.android.tools.lint.checks.infrastructure.TestFile
  6 +import com.android.tools.lint.checks.infrastructure.TestFiles.kotlin
  7 +import com.android.tools.lint.checks.infrastructure.TestLintTask.lint
  8 +import org.junit.Test
  9 +
  10 +class FlowDelegateUsageDetectorTest {
  11 + @Test
  12 + fun normalFlowAccess() {
  13 + lint()
  14 + .allowMissingSdk()
  15 + .files(
  16 + flowAccess(),
  17 + stateFlow(),
  18 + kotlin(
  19 + """
  20 + package foo
  21 + import io.livekit.android.util.FlowObservable
  22 + import io.livekit.android.util.flow
  23 + import io.livekit.android.util.flowDelegate
  24 + class Example {
  25 + @get:FlowObservable
  26 + val value: Int by flowDelegate(0)
  27 + fun foo() {
  28 + ::value.flow
  29 + return
  30 + }
  31 + }"""
  32 + ).indented()
  33 + )
  34 + .issues(FlowDelegateUsageDetector.ISSUE)
  35 + .run()
  36 + .expectClean()
  37 + }
  38 +
  39 + @Test
  40 + fun thisColonAccess() {
  41 + lint()
  42 + .allowMissingSdk()
  43 + .files(
  44 + flowAccess(),
  45 + stateFlow(),
  46 + kotlin(
  47 + """
  48 + package foo
  49 + import io.livekit.android.util.FlowObservable
  50 + import io.livekit.android.util.flow
  51 + import io.livekit.android.util.flowDelegate
  52 + class Example {
  53 + @get:FlowObservable
  54 + val value: Int by flowDelegate(0)
  55 + fun foo() {
  56 + this::value.flow
  57 + return
  58 + }
  59 + }"""
  60 + ).indented()
  61 + )
  62 + .issues(FlowDelegateUsageDetector.ISSUE)
  63 + .run()
  64 + .expectClean()
  65 + }
  66 +
  67 + @Test
  68 + fun otherClassAccess() {
  69 + lint()
  70 + .allowMissingSdk()
  71 + .files(
  72 + flowAccess(),
  73 + stateFlow(),
  74 + kotlin(
  75 + """
  76 + package foo
  77 + import io.livekit.android.util.FlowObservable
  78 + import io.livekit.android.util.flow
  79 + import io.livekit.android.util.flowDelegate
  80 + class FlowContainer {
  81 + @get:FlowObservable
  82 + val value: Int by flowDelegate(0)
  83 + }
  84 + class Example {
  85 + fun foo() {
  86 + val container = FlowContainer()
  87 + container::value.flow
  88 + return
  89 + }
  90 + }"""
  91 + ).indented()
  92 + )
  93 + .issues(FlowDelegateUsageDetector.ISSUE)
  94 + .run()
  95 + .expectClean()
  96 + }
  97 +
  98 + @Test
  99 + fun parenthesesClassAccess() {
  100 + lint()
  101 + .allowMissingSdk()
  102 + .files(
  103 + flowAccess(),
  104 + stateFlow(),
  105 + kotlin(
  106 + """
  107 + package foo
  108 + import io.livekit.android.util.FlowObservable
  109 + import io.livekit.android.util.flow
  110 + import io.livekit.android.util.flowDelegate
  111 + class FlowContainer {
  112 + @get:FlowObservable
  113 + val value: Int by flowDelegate(0)
  114 + }
  115 + class Example {
  116 + fun foo() {
  117 + val container = FlowContainer()
  118 + (container)::value.flow
  119 + return
  120 + }
  121 + }"""
  122 + ).indented()
  123 + )
  124 + .issues(FlowDelegateUsageDetector.ISSUE)
  125 + .run()
  126 + .expectClean()
  127 + }
  128 +
  129 + @Test
  130 + fun roundaboutAccess() {
  131 + lint()
  132 + .allowMissingSdk()
  133 + .files(
  134 + flowAccess(),
  135 + stateFlow(),
  136 + kotlin(
  137 + """
  138 + package foo
  139 + import io.livekit.android.util.FlowObservable
  140 + import io.livekit.android.util.flow
  141 + import io.livekit.android.util.flowDelegate
  142 + class FlowContainer {
  143 + var value: Int by flowDelegate(0)
  144 + @get:FlowObservable
  145 + val otherValue: Int
  146 + get() = value
  147 + }
  148 + class Example {
  149 + fun foo() {
  150 + val container = FlowContainer()
  151 + container::otherValue.flow
  152 + return
  153 + }
  154 + }"""
  155 + ).indented()
  156 + )
  157 + .issues(FlowDelegateUsageDetector.ISSUE)
  158 + .run()
  159 + .expectClean()
  160 + }
  161 +
  162 + @Test
  163 + fun nonAnnotatedFlowAccess() {
  164 + lint()
  165 + .allowMissingSdk()
  166 + .files(
  167 + flowAccess(),
  168 + stateFlow(),
  169 + kotlin(
  170 + """
  171 + package foo
  172 + import io.livekit.android.util.FlowObservable
  173 + import io.livekit.android.util.flow
  174 + import io.livekit.android.util.flowDelegate
  175 + class Example {
  176 + val value: Int = 0
  177 + fun foo() {
  178 + this::value.flow
  179 + return
  180 + }
  181 + }"""
  182 + ).indented()
  183 + )
  184 + .issues(FlowDelegateUsageDetector.ISSUE)
  185 + .run()
  186 + .expectErrorCount(1)
  187 + }
  188 +}
  189 +
  190 +fun flowAccess(): TestFile {
  191 + return kotlin(
  192 + "io/livekit/android/util/FlowDelegate.kt",
  193 + """
  194 + package io.livekit.android.util
  195 +
  196 + import kotlin.reflect.KProperty
  197 + import kotlin.reflect.KProperty0
  198 + import kotlinx.coroutines.flow.StateFlow
  199 + import kotlinx.coroutines.flow.MutableStateFlow
  200 +
  201 + internal val <T> KProperty0<T>.delegate: Any?
  202 + get() { getDelegate() }
  203 +
  204 + @Suppress("UNCHECKED_CAST")
  205 + val <T> KProperty0<T>.flow: StateFlow<T>
  206 + get() = delegate as StateFlow<T>
  207 +
  208 + @Target(AnnotationTarget.PROPERTY_GETTER)
  209 + @Retention(AnnotationRetention.SOURCE)
  210 + @MustBeDocumented
  211 + annotation class FlowObservable
  212 +
  213 + @FlowObservable
  214 + class MutableStateFlowDelegate<T>
  215 + internal constructor(
  216 + private val flow: MutableStateFlow<T>,
  217 + private val onSetValue: ((newValue: T, oldValue: T) -> Unit)? = null
  218 + ) : MutableStateFlow<T> by flow {
  219 +
  220 + operator fun getValue(thisRef: Any?, property: KProperty<*>): T {
  221 + return flow.value
  222 + }
  223 +
  224 + operator fun setValue(thisRef: Any?, property: KProperty<*>, value: T) {
  225 + val oldValue = flow.value
  226 + flow.value = value
  227 + onSetValue?.invoke(value, oldValue)
  228 + }
  229 + }
  230 +
  231 + public fun <T> flowDelegate(
  232 + initialValue: T,
  233 + onSetValue: ((newValue: T, oldValue: T) -> Unit)? = null
  234 + ): MutableStateFlowDelegate<T> {
  235 + return MutableStateFlowDelegate(MutableStateFlow(initialValue), onSetValue)
  236 + }
  237 + """
  238 + ).indented()
  239 + .within("src")
  240 +}
  241 +
  242 +fun stateFlow(): TestFile {
  243 +
  244 + return kotlin(
  245 + """
  246 + package kotlinx.coroutines.flow
  247 + interface StateFlow<out T> {
  248 + val value: T
  249 + }
  250 + class MutableStateFlow<T>(override var value: T) : StateFlow<T>
  251 + """
  252 + ).indented()
  253 +}
  1 +@file:Suppress("UnstableApiUsage", "NewObjectEquality")
  2 +
  3 +package io.livekit.lint
  4 +
  5 +import com.android.tools.lint.checks.infrastructure.TestFile
  6 +import com.android.tools.lint.checks.infrastructure.TestFiles.java
  7 +import com.android.tools.lint.checks.infrastructure.TestFiles.kotlin
  8 +import com.android.tools.lint.checks.infrastructure.TestLintTask.lint
  9 +import org.junit.Test
  10 +
  11 +class MediaTrackEqualsDetectorTest {
  12 + @Test
  13 + fun objectEquals() {
  14 + lint()
  15 + .allowMissingSdk()
  16 + .files(
  17 + java(
  18 + """
  19 + package foo;
  20 +
  21 + class Example {
  22 + public boolean foo() {
  23 + Object a = new Object();
  24 + Object b = new Object();
  25 + return a.equals(b);
  26 + }
  27 + }"""
  28 + ).indented()
  29 + )
  30 + .issues(MediaTrackEqualsDetector.ISSUE)
  31 + .run()
  32 + .expectClean()
  33 + }
  34 +
  35 + @Test
  36 + fun objectEqualityOperator() {
  37 + lint()
  38 + .allowMissingSdk()
  39 + .files(
  40 + java(
  41 + """
  42 + package foo;
  43 +
  44 + class Example {
  45 + public boolean foo() {
  46 + Object a = new Object();
  47 + Object b = new Object();
  48 + return a == b;
  49 + }
  50 + }"""
  51 + ).indented()
  52 + )
  53 + .issues(MediaTrackEqualsDetector.ISSUE)
  54 + .run()
  55 + .expectClean()
  56 + }
  57 +
  58 + @Test
  59 + fun badMediaTrackEquals() {
  60 + lint()
  61 + .allowMissingSdk()
  62 + .files(
  63 + mediaStreamTrack(),
  64 + java(
  65 + """
  66 + package foo;
  67 + import org.webrtc.MediaStreamTrack;
  68 +
  69 + class Example {
  70 + public boolean foo() {
  71 + MediaStreamTrack a = new MediaStreamTrack();
  72 + MediaStreamTrack b = new MediaStreamTrack();
  73 + return a.equals(b);
  74 + }
  75 + }"""
  76 + ).indented()
  77 + )
  78 + .issues(MediaTrackEqualsDetector.ISSUE)
  79 + .run()
  80 + .expectErrorCount(1)
  81 + }
  82 +
  83 + @Test
  84 + fun mediaTrackEqualityOperator() {
  85 + lint()
  86 + .allowMissingSdk()
  87 + .files(
  88 + mediaStreamTrack(),
  89 + java(
  90 + """
  91 + package foo;
  92 + import org.webrtc.MediaStreamTrack;
  93 +
  94 + class Example {
  95 + public boolean foo() {
  96 + ABC a = new ABC();
  97 + MediaStreamTrack b = new MediaStreamTrack();
  98 + return a == b;
  99 + }
  100 + public boolean equals(Object o){
  101 + return false;
  102 + }
  103 + }"""
  104 + ).indented()
  105 + )
  106 + .issues(MediaTrackEqualsDetector.ISSUE)
  107 + .run()
  108 + .expectErrorCount(1)
  109 + }
  110 +
  111 + @Test
  112 + fun properMediaTrackEquality() {
  113 + lint()
  114 + .allowMissingSdk()
  115 + .files(
  116 + mediaStreamTrack(),
  117 + java(
  118 + """
  119 + package foo;
  120 +
  121 + class Example {
  122 + public boolean foo() {
  123 + MediaStreamTrack a = new MediaStreamTrack();
  124 + MediaStreamTrack b = new MediaStreamTrack();
  125 + return a.getId() == b.getId();
  126 + }
  127 + }"""
  128 + ).indented()
  129 + )
  130 + .issues(MediaTrackEqualsDetector.ISSUE)
  131 + .run()
  132 + .expectClean()
  133 + }
  134 +
  135 + @Test
  136 + fun kotlinMediaTrackEqualityOperator() {
  137 + lint()
  138 + .allowMissingSdk()
  139 + .files(
  140 + mediaStreamTrack(),
  141 + kotlin(
  142 + """
  143 + package foo
  144 + import org.webrtc.MediaStreamTrack
  145 +
  146 + class Example {
  147 + fun foo() : Boolean {
  148 + val a = MediaStreamTrack()
  149 + val b = MediaStreamTrack()
  150 + return a == b;
  151 + }
  152 + }"""
  153 + ).indented()
  154 + )
  155 + .issues(MediaTrackEqualsDetector.ISSUE)
  156 + .run()
  157 + .expectErrorCount(1)
  158 + }
  159 +
  160 + @Test
  161 + fun kotlinMediaTrackIdentityEqualityOperator() {
  162 + lint()
  163 + .allowMissingSdk()
  164 + .files(
  165 + mediaStreamTrack(),
  166 + kotlin(
  167 + """
  168 + package foo
  169 + import org.webrtc.MediaStreamTrack
  170 +
  171 + class Example {
  172 + fun foo() : Boolean {
  173 + val a = MediaStreamTrack()
  174 + val b = MediaStreamTrack()
  175 + return a === b
  176 + }
  177 + }"""
  178 + ).indented()
  179 + )
  180 + .issues(MediaTrackEqualsDetector.ISSUE)
  181 + .run()
  182 + .expectErrorCount(1)
  183 + }
  184 +
  185 + @Test
  186 + fun kotlinMediaTrackEquals() {
  187 + lint()
  188 + .allowMissingSdk()
  189 + .files(
  190 + mediaStreamTrack(),
  191 + kotlin(
  192 + """
  193 + package foo
  194 + import org.webrtc.MediaStreamTrack
  195 +
  196 + class Example {
  197 + fun foo() : Boolean {
  198 + val a = MediaStreamTrack()
  199 + val b = MediaStreamTrack()
  200 + return a.equals(b)
  201 + }
  202 + }"""
  203 + ).indented()
  204 + )
  205 + .issues(MediaTrackEqualsDetector.ISSUE)
  206 + .run()
  207 + .expectErrorCount(1)
  208 + }
  209 +
  210 + @Test
  211 + fun kotlinProperMediaTrackEquality() {
  212 + lint()
  213 + .allowMissingSdk()
  214 + .files(
  215 + mediaStreamTrack(),
  216 + kotlin(
  217 + """
  218 + package foo
  219 + import org.webrtc.MediaStreamTrack
  220 +
  221 + class Example {
  222 + fun foo() : Boolean {
  223 + val a = MediaStreamTrack()
  224 + val b = MediaStreamTrack()
  225 + return a.id() == b.id()
  226 + }
  227 + }"""
  228 + ).indented()
  229 + )
  230 + .issues(MediaTrackEqualsDetector.ISSUE)
  231 + .run()
  232 + .expectClean()
  233 + }
  234 +}
  235 +
  236 +fun mediaStreamTrack(): TestFile {
  237 + return java(
  238 + """
  239 + package org.webrtc;
  240 +
  241 + class MediaStreamTrack {
  242 + int getId(){
  243 + return 0;
  244 + }
  245 + }
  246 + """
  247 + ).indented()
  248 +}
@@ -7,3 +7,4 @@ pluginManagement { @@ -7,3 +7,4 @@ pluginManagement {
7 include ':sample-app', ':sample-app-compose', ':livekit-android-sdk' 7 include ':sample-app', ':sample-app-compose', ':livekit-android-sdk'
8 rootProject.name='livekit-android' 8 rootProject.name='livekit-android'
9 include ':sample-app-common' 9 include ':sample-app-common'
  10 +include ':livekit-lint'