diff --git a/app/build.gradle.kts b/app/build.gradle.kts index ef734b56..24dca25a 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -212,6 +212,8 @@ ksp { } dependencies { + lintChecks(project(":lint-rules")) + implementation(libs.quartz) { exclude(group = "net.java.dev.jna") } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 08ae52fc..0b9d7ba2 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -33,6 +33,8 @@ coroutinesTest = "1.11.0" kmpTor = "2.6.0" kmpTorResource = "409.5.0" secp256k1Jni = "0.23.0" +# Android Lint API version follows AGP: lint = agp major + 23 +lintApi = "32.2.1" [libraries] datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastorePreferences" } @@ -80,6 +82,7 @@ paging-compose = { group = "androidx.paging", name = "paging-compose", version.r paging-runtime = { group = "androidx.paging", name = "paging-runtime", version.ref = "pagingCommon" } mockk = { module = "io.mockk:mockk", version.ref = "mockk" } kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutinesTest" } +lint-api = { module = "com.android.tools.lint:lint-api", version.ref = "lintApi" } kmptor-runtime = { module = "io.matthewnelson.kmp-tor:runtime", version.ref = "kmpTor" } kmptor-resource-exec = { module = "io.matthewnelson.kmp-tor:resource-exec-tor", version.ref = "kmpTorResource" } secp256k1-jni-jvm = { module = "fr.acinq.secp256k1:secp256k1-kmp-jni-jvm", version.ref = "secp256k1Jni" } diff --git a/lint-rules/build.gradle.kts b/lint-rules/build.gradle.kts new file mode 100644 index 00000000..a5da4a91 --- /dev/null +++ b/lint-rules/build.gradle.kts @@ -0,0 +1,24 @@ +plugins { + // No version: AGP's built-in Kotlin already puts the plugin on the build classpath + id("org.jetbrains.kotlin.jvm") + alias(libs.plugins.gradle.ktlint) version libs.versions.ktlint.get() +} + +java { + sourceCompatibility = JavaVersion.VERSION_21 + targetCompatibility = JavaVersion.VERSION_21 +} + +kotlin { + jvmToolchain(21) +} + +dependencies { + compileOnly(libs.lint.api) +} + +tasks.jar { + manifest { + attributes("Lint-Registry-v2" to "com.greenart7c3.nostrsigner.lint.AmberIssueRegistry") + } +} diff --git a/lint-rules/src/main/java/com/greenart7c3/nostrsigner/lint/AmberIssueRegistry.kt b/lint-rules/src/main/java/com/greenart7c3/nostrsigner/lint/AmberIssueRegistry.kt new file mode 100644 index 00000000..83bb708c --- /dev/null +++ b/lint-rules/src/main/java/com/greenart7c3/nostrsigner/lint/AmberIssueRegistry.kt @@ -0,0 +1,19 @@ +package com.greenart7c3.nostrsigner.lint + +import com.android.tools.lint.client.api.IssueRegistry +import com.android.tools.lint.client.api.Vendor +import com.android.tools.lint.detector.api.CURRENT_API +import com.android.tools.lint.detector.api.Issue + +class AmberIssueRegistry : IssueRegistry() { + override val issues: List = listOf( + LongToDurationDetector.ISSUE, + ) + + override val api: Int = CURRENT_API + + override val vendor: Vendor = Vendor( + vendorName = "Amber", + feedbackUrl = "https://github.com/greenart7c3/Amber/issues", + ) +} diff --git a/lint-rules/src/main/java/com/greenart7c3/nostrsigner/lint/LongToDurationDetector.kt b/lint-rules/src/main/java/com/greenart7c3/nostrsigner/lint/LongToDurationDetector.kt new file mode 100644 index 00000000..d4fc4ba6 --- /dev/null +++ b/lint-rules/src/main/java/com/greenart7c3/nostrsigner/lint/LongToDurationDetector.kt @@ -0,0 +1,90 @@ +package com.greenart7c3.nostrsigner.lint + +import com.android.tools.lint.detector.api.Category +import com.android.tools.lint.detector.api.Detector +import com.android.tools.lint.detector.api.Implementation +import com.android.tools.lint.detector.api.Issue +import com.android.tools.lint.detector.api.JavaContext +import com.android.tools.lint.detector.api.Scope +import com.android.tools.lint.detector.api.Severity +import com.android.tools.lint.detector.api.SourceCodeScanner +import com.intellij.psi.PsiMethod +import com.intellij.psi.PsiTypes +import org.jetbrains.kotlin.analysis.api.analyze +import org.jetbrains.kotlin.analysis.api.types.KaClassType +import org.jetbrains.kotlin.psi.KtExpression +import org.jetbrains.uast.UCallExpression + +/** + * Build-time port of Android Studio's "Long overload to Duration conversion" IDE + * inspection (ConvertLongToDurationInspection), which only runs inside the IDE. + * Flags calls that resolve to the legacy Long-millisecond overloads of the + * kotlinx.coroutines timing functions so they get migrated to the + * kotlin.time.Duration overloads. + */ +class LongToDurationDetector : + Detector(), + SourceCodeScanner { + override fun getApplicableMethodNames(): List = listOf( + "delay", + "withTimeout", + "withTimeoutOrNull", + "debounce", + "sample", + "throttle", + "timeout", + "onTimeout", + "advanceTimeBy", + ) + + override fun visitMethodCall(context: JavaContext, node: UCallExpression, method: PsiMethod) { + val packageName = context.evaluator.getPackage(method)?.qualifiedName ?: return + if (!packageName.startsWith("kotlinx.coroutines")) return + // The legacy overloads take the time value as a Long. Duration is a value + // class backed by a JVM long, so the Duration overloads erase to the same + // JVM signature and PSI resolution cannot tell them apart — classify by the + // Kotlin type of the argument at the call site instead. + if (method.parameterList.parameters.none { it.type == PsiTypes.longType() }) return + for (argument in node.valueArguments) { + val ktExpression = argument.sourcePsi as? KtExpression ?: continue + val argumentClassFqName = try { + analyze(ktExpression) { + (ktExpression.expressionType as? KaClassType)?.classId?.asFqNameString() + } + } catch (_: Throwable) { + // Analysis session unavailable — cannot classify the overload + return + } + when (argumentClassFqName) { + // Already migrated to the Duration overload + "kotlin.time.Duration" -> return + "kotlin.Long", "kotlin.Int" -> { + context.report( + ISSUE, + node, + context.getCallLocation(node, includeReceiver = false, includeArguments = true), + "Legacy `Long` overload can be converted to `Duration`", + ) + return + } + } + } + } + + companion object { + val ISSUE = Issue.create( + id = "LongToDuration", + briefDescription = "Legacy Long overload can be converted to Duration", + explanation = """ + kotlinx.coroutines timing functions (`delay`, `withTimeout`, `debounce`, …) \ + have `kotlin.time.Duration` overloads that are preferred over the legacy \ + Long-millisecond overloads. Replace e.g. `delay(3000)` with \ + `delay(3.seconds)` using `kotlin.time.Duration.Companion` extensions. + """, + category = Category.CORRECTNESS, + priority = 5, + severity = Severity.WARNING, + implementation = Implementation(LongToDurationDetector::class.java, Scope.JAVA_FILE_SCOPE), + ) + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index b5119ecd..396a96d3 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -17,3 +17,4 @@ dependencyResolutionManagement { } rootProject.name = "Nostr Signer" include(":app") +include(":lint-rules")