diff options
| author | Claude <noreply@anthropic.com> | 2026-09-21 08:13:49 +0000 |
|---|---|---|
| committer | Claude <noreply@anthropic.com> | 2026-09-21 08:13:49 +0000 |
| commit | 5876025559ba0ad287d3a7a4d9fa0246509e668e (patch) | |
| tree | c918d01e2b74d205ee00fe96926232475840ff97 | |
Scaffold Listen, with a podcast-native feed parser
Sibling to FeeDreader, not a shared module. That was a considered call: its
FeedParser only inspects <enclosure> while hunting for a thumbnail, and its
FilterRuleEngine is 46 lines of title substring matching that doesn't
generalise to queue rules, so only Opml.kt would genuinely have transferred.
The parser is the piece everything else sits on, so it goes first, test-first:
18 tests covering audio/video enclosure selection, iTunes durations in both
second and clock form, season/episode numbers, podcast:chapters for the skip
feature, RFC 5005 archive paging for drip subscriptions, and episode identity
falling back to the enclosure URL when a guid is missing.
Two things worth recording:
- Built on XmlPullParserFactory rather than android.util.Xml. The latter
returns null off-device, which is why FeeDreader's 297-line parser has no
tests at all. Both resolve to the same KXmlParser on Android.
- SimpleDateFormat with isLenient=false rejects "Tue, 02 Sep 2026 06:00:00
GMT" -- an entirely ordinary RFC 822 date. Left strict, every pubDate in
the app would have been null and episode ordering would have been silently
wrong everywhere.
minSdk is 29, deliberately not FeeDreader's 36, which would restrict the app
to Android 16 and newer.
Permissions: INTERNET and ACCESS_NETWORK_STATE for feeds; FOREGROUND_SERVICE,
FOREGROUND_SERVICE_MEDIA_PLAYBACK, POST_NOTIFICATIONS and WAKE_LOCK because
Android requires a foreground service with a notification to play audio in the
background. That notification is the transport control, not an alert -- Listen
posts no new-episode notifications by design.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
27 files changed, 1142 insertions, 0 deletions
diff --git a/.coverage-baseline b/.coverage-baseline new file mode 100644 index 0000000..f362818 --- /dev/null +++ b/.coverage-baseline @@ -0,0 +1 @@ +75.88 diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100755 index 0000000..32b492a --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,94 @@ +#!/usr/bin/env bash +# Quality gate -- see CLAUDE.md "Quality gate -- every commit" for what each step is and why. +# Activate once per clone with: git config core.hooksPath .githooks +set -euo pipefail +cd "$(git rev-parse --show-toplevel)" + +fail() { + echo "pre-commit: $1" >&2 + exit 1 +} + +echo "== [1/8] Build ==" +./gradlew :app:compileDebugKotlin -q + +echo "== [2/8] Tests ==" +./gradlew :app:testDebugUnitTest -q + +echo "== [3/8] Static analysis: lint ==" +if ! ./gradlew :app:lintDebug -q; then + fail "lint failed -- fix all lint ERRORS before committing (warnings are fine to accumulate, see CLAUDE.md)." +fi + +echo "== [4/8] Static analysis: detekt (code-quality auditor) ==" +if ! ./gradlew :app:detekt -q; then + fail "detekt found new issues beyond the tracked baseline. Fix them, or if they're genuinely pre-existing debt being touched incidentally, regenerate the baseline with './gradlew :app:detektBaseline' and explain why in the commit message." +fi + +echo "== [5/8] Coverage must not decrease ==" +./gradlew :app:jacocoTestReport -q +REPORT="app/build/reports/jacoco/jacocoTestReport/jacocoTestReport.xml" +BASELINE_FILE=".coverage-baseline" +if [ -f "$REPORT" ]; then + CURRENT=$(python3 - "$REPORT" <<'PY' +import sys, xml.etree.ElementTree as ET +root = ET.parse(sys.argv[1]).getroot() +line = next(c for c in root.findall('counter') if c.get('type') == 'LINE') +missed, covered = int(line.get('missed')), int(line.get('covered')) +total = missed + covered +print(f"{(covered / total * 100) if total else 0.0:.2f}") +PY +) +else + CURRENT="0.00" +fi +BASELINE=$(cat "$BASELINE_FILE" 2>/dev/null || echo "0.00") + +DROPPED=$(python3 -c "print(1 if float('$CURRENT') < float('$BASELINE') else 0)") +if [ "$DROPPED" = "1" ]; then + if [ -n "${COVERAGE_EXCEPTION:-}" ]; then + echo "pre-commit: coverage dropped ${BASELINE}% -> ${CURRENT}% -- allowed via COVERAGE_EXCEPTION: ${COVERAGE_EXCEPTION}" + echo "$CURRENT" > "$BASELINE_FILE" + git add "$BASELINE_FILE" + else + fail "coverage dropped ${BASELINE}% -> ${CURRENT}%. If this drop is the correct outcome (e.g. deleting dead covered code), commit with COVERAGE_EXCEPTION=\"reason\" git commit ... Otherwise, add tests." + fi +else + echo "$CURRENT" > "$BASELINE_FILE" + git add "$BASELINE_FILE" + echo "pre-commit: coverage ${BASELINE}% -> ${CURRENT}%" +fi + +echo "== [6/8] WebView JavaScript justification ==" +STAGED_KT=$(git diff --cached --name-only --diff-filter=ACM -- '*.kt' || true) +if [ -n "$STAGED_KT" ]; then + for f in $STAGED_KT; do + [ -f "$f" ] || continue + ADDED_JS_LINES=$(git diff --cached -U0 -- "$f" | grep -E '^\+.*(javaScriptEnabled\s*=\s*true|setJavaScriptEnabled\(true\))' || true) + if [ -n "$ADDED_JS_LINES" ]; then + if ! grep -q '@SuppressLint("SetJavaScriptEnabled")' "$f"; then + fail "$f enables JavaScript on a WebView with no @SuppressLint(\"SetJavaScriptEnabled\") + justification comment nearby (see ReaderExtractor.kt, LinkReaderScreen.kt, BrowserScreen.kt for the pattern). This app's security posture depends on every JS-enabled site being deliberate and explained." + fi + fi + done +fi + +echo "== [7/8] Manifest permission diff ==" +if git diff --cached --name-only | grep -q '^app/src/main/AndroidManifest.xml$'; then + if git diff --cached -- app/src/main/AndroidManifest.xml | grep -qE '^\+.*<uses-permission'; then + echo "pre-commit: WARNING -- AndroidManifest.xml adds a new <uses-permission>. Make sure the commit message says why." + fi +fi + +echo "== [8/8] Secret scan ==" +if [ -z "${SECRET_SCAN_OVERRIDE:-}" ]; then + SECRET_HIT=$(git diff --cached -U0 -- . ':!*.lock' | grep -E '^\+' | grep -viE '^\+\+\+' | grep -E '(api[_-]?key|secret|password|token)["'\'' ]*[:=]["'\'' ]*[A-Za-z0-9_\-]{16,}' || true) + if [ -n "$SECRET_HIT" ]; then + echo "$SECRET_HIT" >&2 + fail "possible hardcoded secret in the staged diff (see above). If this is a false positive (e.g. an obviously-fake test fixture key), commit with SECRET_SCAN_OVERRIDE=1 git commit ..." + fi +else + echo "pre-commit: secret scan skipped (SECRET_SCAN_OVERRIDE set)" +fi + +echo "pre-commit: all checks passed." diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2c97816 --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +*.iml +.gradle/ +/local.properties +/.idea/ +.DS_Store +/build +/captures +.externalNativeBuild +.cxx +local.properties +app/build/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..50e1fcb --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,91 @@ +# Listen — Agent Instructions + +Personal Android podcast app, `org.terst.listen`. Sibling to FeeDreader (`/workspace/feedreader`), +built for Peter's own use. Greenfield, hands-on-testing-rounds workflow: sideload a debug build, +use it, batch feedback. + +Design plan: https://claude.ai/code/artifact/85137e78-3718-4d84-8ab8-f72885170653 + +## What this app is, and isn't + +Replacing Pocket Casts completely. The distinguishing features are audio-based intro/outro +skipping, slow-drip archive subscriptions, and rule-driven auto-queueing. + +**Non-goals, deliberately:** anything social, new-episode notifications (or any alert — the media +notification is a transport control and is mandatory for background audio, which is a different +thing), favourites, bookmarks. Discovery and video were both cut from scope. + +## Relationship to FeeDreader + +Separate repos, no shared module. That was a considered call, not an accident: FeeDreader's +`FeedParser` treats `<enclosure>` as an image source and its `FilterRuleEngine` is 46 lines of +substring matching that doesn't generalise to queue rules, so only `Opml.kt` (~96 lines) genuinely +transferred. + +**Standing instruction:** watch for things that do generalise, and avoid pointless drift between +the two apps. If a change here would obviously improve FeeDreader too, say so. Don't force sharing +where the problems are actually different — but don't let two solutions to the same problem quietly +diverge either. + +## Test-driven development is mandatory + +Same rule as FeeDreader, same reasons. Every change to `app/src/main/` follows red-green-refactor, +in that literal order: + +1. **Write the test first**, for the specific behaviour you're about to add or change. +2. **Run it. Confirm it fails, and fails for the right reason** — an assertion mismatch showing the + missing behaviour, not a compile error. +3. Write the minimal production code to pass. +4. Run again. Confirm green. +5. Refactor if needed, keeping it green. +6. Run the full quality gate before committing. + +**If production code was written before or without a preceding failing test**, discard the diff and +redo it test-first. Do not backfill tests onto existing code — a test written against working code +confirms what the code does, not what it should do. + +**Scoping exception:** pure resource/config/manifest changes with no unit-testable behaviour don't +get a red step; the static-analysis gate covers those. + +## Verification — mandatory + +**Never conclude a build or test result from grepped console output**, and never verify a launch +with `adb shell monkey`. Use the shared wrappers, which report from exit codes, parsed JUnit XML, +and a logcat capture started before launch: + +``` +/workspace/devtools/bin/gradle-test /workspace/listen -- --rerun-tasks +/workspace/devtools/bin/android-launch-check org.terst.listen \ + --apk app/build/outputs/apk/debug/app-debug.apk --settle 15 +``` + +See `/workspace/devtools/README.md`. These exist because grepped console text has produced wrong +claims three separate times across these repos. + +## Quality gate — every commit + +1. **Build** — `./gradlew compileDebugKotlin` succeeds. +2. **Tests** — `./gradlew testDebugUnitTest` passes in full. +3. **Static analysis** — `./gradlew detekt lintDebug` clean: zero lint errors, zero new detekt + findings beyond the baseline. +4. **Coverage must not decrease** against `.coverage-baseline` (line coverage over + `app/src/main/java` excluding `ui/**`). Set `COVERAGE_EXCEPTION="reason"` for justified drops, + e.g. deleting covered dead code. +5. **Manifest permission diff** — if `<uses-permission>` changed, the commit message says why. +6. **Schema migrations** — if `AppDatabase.kt`'s `version` changed, a migration test must be + touched in the same diff. Room tracks by version; a wrong migration corrupts a real, + non-reproducible library. + +## Domain notes worth keeping + +- **Playback position is the crown jewel of the data model.** It must survive process death. Save + on a timer, on pause, and on lifecycle transitions — not just on stop. +- **Never assume audio.** An enclosure carries a MIME type. Video was cut from scope, but the model + stays media-agnostic so it isn't a retrofit. +- **GUIDs move.** Publishers rewrite feeds; match episodes on GUID first, then enclosure URL, then + title+date. An episode that loses its identity loses its playback position. +- **Retention is auto-delete-once-played** with a grace period, so a mis-tap doesn't destroy an + episode you were still listening to. +- **YouTube never touches this app.** Audio arrives via podkit running in Termux on the phone, + writing to a local folder Listen imports. No extractor code here, nothing to break when Google + ships a change. diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..a1d1854 --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,140 @@ +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.android") + id("org.jetbrains.kotlin.plugin.serialization") + id("org.jetbrains.kotlin.plugin.compose") + id("com.google.devtools.ksp") + id("io.gitlab.arturbosch.detekt") + id("jacoco") +} + +android { + namespace = "org.terst.listen" + compileSdk = 36 + + defaultConfig { + applicationId = "org.terst.listen" + // Deliberately not FeeDreader's minSdk. That value is 36 in its working tree, which + // would restrict the app to Android 16 and newer; 29 covers any phone in real use and + // is comfortably above Media3's floor of 21. + minSdk = 29 + targetSdk = 36 + versionCode = 1 + versionName = "1.0" + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + debug { + enableUnitTestCoverage = true + } + release { + isMinifyEnabled = false + } + } + + buildFeatures { + compose = true + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + + kotlinOptions { + jvmTarget = "11" + } + + testOptions { + unitTests { + // Room's and Media3's Android stubs otherwise throw on every call from a JVM test. + isReturnDefaultValues = true + } + } +} + +dependencies { + val composeBom = platform("androidx.compose:compose-bom:2026.06.01") + implementation(composeBom) + androidTestImplementation(composeBom) + + implementation("androidx.core:core-ktx:1.17.0") + implementation("androidx.activity:activity-compose:1.11.0") + implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.9.4") + implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.9.4") + implementation("androidx.navigation:navigation-compose:2.9.5") + + implementation("androidx.compose.ui:ui") + implementation("androidx.compose.ui:ui-graphics") + implementation("androidx.compose.ui:ui-tooling-preview") + implementation("androidx.compose.material3:material3") + implementation("androidx.compose.material:material-icons-extended") + + // Media3 is the whole playback stack: ExoPlayer for decode and speed control, + // MediaSessionService for lock-screen and Bluetooth transport, and its DownloadManager, + // which shares a cache with playback so a partly-downloaded episode streams the remainder + // instead of failing. + val media3 = "1.8.0" + implementation("androidx.media3:media3-exoplayer:$media3") + implementation("androidx.media3:media3-session:$media3") + implementation("androidx.media3:media3-ui:$media3") + implementation("androidx.media3:media3-datasource-okhttp:$media3") + + implementation("androidx.room:room-runtime:2.8.4") + implementation("androidx.room:room-ktx:2.8.4") + ksp("androidx.room:room-compiler:2.8.4") + + implementation("androidx.work:work-runtime-ktx:2.10.5") + implementation("androidx.datastore:datastore-preferences:1.1.7") + + implementation("com.squareup.okhttp3:okhttp:4.12.0") + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.0") + + testImplementation("junit:junit:4.13.2") + testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.0") + testImplementation("androidx.room:room-testing:2.8.4") + // The JVM has no XmlPullParser implementation; the feed parser needs a real one under test. + testImplementation("net.sf.kxml:kxml2:2.3.0") + testImplementation("xmlpull:xmlpull:1.1.3.1") + testImplementation("com.squareup.okhttp3:mockwebserver:4.12.0") + + androidTestImplementation("androidx.test.ext:junit:1.3.0") + androidTestImplementation("androidx.test.espresso:espresso-core:3.7.0") + androidTestImplementation("androidx.compose.ui:ui-test-junit4") + androidTestImplementation("androidx.room:room-testing:2.8.4") + androidTestImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.0") + + debugImplementation("androidx.compose.ui:ui-tooling") + debugImplementation("androidx.compose.ui:ui-test-manifest") +} + +detekt { + buildUponDefaultConfig = true + config.setFrom(files("$rootDir/config/detekt/detekt.yml")) + baseline = file("$rootDir/config/detekt/baseline.xml") +} + +jacoco { + toolVersion = "0.8.12" +} + +tasks.register<JacocoReport>("jacocoTestReport") { + dependsOn("testDebugUnitTest") + reports { + xml.required.set(true) + html.required.set(true) + } + // ui/** is excluded for the same reason as FeeDreader: Compose screens are impractical to + // unit-test for coverage and including them makes the metric meaningless. + val kotlinDebugTree = fileTree("${layout.buildDirectory.get()}/tmp/kotlin-classes/debug") { + exclude("**/ui/**", "**/R.class", "**/R$*.class", "**/BuildConfig.*", "**/*_Impl*") + } + val javaDebugTree = fileTree("${layout.buildDirectory.get()}/intermediates/javac/debug") { + exclude("**/ui/**", "**/R.class", "**/R$*.class", "**/BuildConfig.*", "**/*_Impl*") + } + classDirectories.setFrom(files(kotlinDebugTree, javaDebugTree)) + sourceDirectories.setFrom(files("src/main/java")) + executionData.setFrom(fileTree(layout.buildDirectory) { include("**/*.exec") }) +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..ac8bb04 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,58 @@ +<?xml version="1.0" encoding="utf-8"?> +<manifest xmlns:android="http://schemas.android.com/apk/res/android"> + + <uses-permission android:name="android.permission.INTERNET" /> + <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> + + <!-- Background audio requires a foreground service, and since Android 14 that service must + declare its type. The notification this produces is the transport control (play/pause on + the lock screen), not an alert: Listen posts no new-episode notifications of any kind. --> + <uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> + <uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" /> + <uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> + <uses-permission android:name="android.permission.WAKE_LOCK" /> + + <application + android:name=".ListenApp" + android:allowBackup="true" + android:icon="@mipmap/ic_launcher" + android:label="@string/app_name" + android:supportsRtl="true" + android:theme="@style/Theme.Listen"> + + <activity + android:name=".ui.MainActivity" + android:exported="true" + android:theme="@style/Theme.Listen"> + <intent-filter> + <action android:name="android.intent.action.MAIN" /> + <category android:name="android.intent.category.LAUNCHER" /> + </intent-filter> + </activity> + + <!-- Share-sheet target. Listen has no discovery, so this and OPML import are how + subscriptions get added: a URL shared from a browser or FeeDreader is resolved to + a feed here. --> + <activity + android:name=".ui.ShareReceiverActivity" + android:exported="true" + android:excludeFromRecents="true" + android:theme="@style/Theme.Listen"> + <intent-filter> + <action android:name="android.intent.action.SEND" /> + <category android:name="android.intent.category.DEFAULT" /> + <data android:mimeType="text/plain" /> + </intent-filter> + </activity> + + <service + android:name=".playback.PlaybackService" + android:exported="false" + android:foregroundServiceType="mediaPlayback"> + <intent-filter> + <action android:name="androidx.media3.session.MediaSessionService" /> + </intent-filter> + </service> + + </application> +</manifest> diff --git a/app/src/main/java/org/terst/listen/ListenApp.kt b/app/src/main/java/org/terst/listen/ListenApp.kt new file mode 100644 index 0000000..063e824 --- /dev/null +++ b/app/src/main/java/org/terst/listen/ListenApp.kt @@ -0,0 +1,5 @@ +package org.terst.listen + +import android.app.Application + +class ListenApp : Application() diff --git a/app/src/main/java/org/terst/listen/feed/PodcastFeedParser.kt b/app/src/main/java/org/terst/listen/feed/PodcastFeedParser.kt new file mode 100644 index 0000000..8b22bce --- /dev/null +++ b/app/src/main/java/org/terst/listen/feed/PodcastFeedParser.kt @@ -0,0 +1,283 @@ +package org.terst.listen.feed + +import org.xmlpull.v1.XmlPullParser +import org.xmlpull.v1.XmlPullParserFactory +import java.io.InputStream +import java.text.SimpleDateFormat +import java.util.Locale +import java.util.TimeZone + +/** One playable item in a feed. */ +data class ParsedEpisode( + val guid: String, + val title: String?, + val description: String?, + val mediaUrl: String, + val mediaType: String, + val mediaLengthBytes: Long?, + val durationMs: Long?, + val publishedAtMs: Long?, + val seasonNumber: Int?, + val episodeNumber: Int?, + val imageUrl: String?, + val chaptersUrl: String?, +) + +/** A parsed podcast feed. */ +data class ParsedFeed( + val title: String?, + val description: String?, + val author: String?, + val link: String?, + val imageUrl: String?, + val previousArchiveUrl: String?, + val episodes: List<ParsedEpisode>, +) + +/** + * Parses a podcast RSS feed. + * + * Written rather than reused from FeeDreader because that parser inspects `<enclosure>` only while + * hunting for a thumbnail -- it has no concept of playable media, duration, or episode identity. + * + * Namespace processing stays off, so element names arrive prefixed ("itunes:duration") the way + * feeds actually write them. Prefixes are stripped per-element instead, because publishers bind + * these namespaces to whatever prefix they like. + */ +object PodcastFeedParser { + + private const val MS_PER_SECOND = 1000L + private const val SECONDS_PER_MINUTE = 60L + private const val CLOCK_PARTS_HMS = 3 + private const val CLOCK_PARTS_MS = 2 + + fun parse(input: InputStream): ParsedFeed { + // XmlPullParserFactory rather than android.util.Xml, which returns null off-device and + // so cannot be unit-tested at all -- the reason FeeDreader's parser has no tests. Both + // resolve to the same KXmlParser on Android. + val parser = XmlPullParserFactory.newInstance().newPullParser() + parser.setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, false) + parser.setInput(input, null) + + val channel = ChannelBuilder() + val episodes = mutableListOf<ParsedEpisode>() + var item: ItemBuilder? = null + + while (parser.next() != XmlPullParser.END_DOCUMENT) { + val name = parser.localName() + when (parser.eventType) { + XmlPullParser.START_TAG -> + if (name.isItem()) item = ItemBuilder() else (item ?: channel).readTag(parser, name) + + XmlPullParser.END_TAG -> if (name.isItem()) { + item?.build()?.let(episodes::add) + item = null + } + } + } + + return channel.build(episodes) + } + + private fun String.isItem() = this == "item" || this == "entry" + + /** Accumulates fields as they are encountered; both scopes share the dispatch shape. */ + private interface TagSink { + fun readTag(parser: XmlPullParser, name: String) + } + + private class ChannelBuilder : TagSink { + private var title: String? = null + private var description: String? = null + private var author: String? = null + private var link: String? = null + private var imageUrl: String? = null + private var itunesImageUrl: String? = null + private var previousArchiveUrl: String? = null + + override fun readTag(parser: XmlPullParser, name: String) { + when (name) { + "title" -> title = title ?: parser.text() + "description", "subtitle", "summary" -> description = description ?: parser.text() + "author" -> author = author ?: parser.text() + "image" -> itunesImageUrl = itunesImageUrl ?: parser.attr("href") + "url" -> imageUrl = imageUrl ?: parser.text() + "link" -> readLink(parser) + } + } + + private fun readLink(parser: XmlPullParser) { + // RFC 5005 archive paging is how a drip subscription reaches episodes the current + // page no longer lists. + when (parser.attr("rel")) { + "prev-archive" -> previousArchiveUrl = previousArchiveUrl ?: parser.attr("href") + null -> link = link ?: (parser.attr("href") ?: parser.text()) + } + } + + fun build(episodes: List<ParsedEpisode>) = ParsedFeed( + title = title, + description = description, + author = author, + link = link, + imageUrl = itunesImageUrl ?: imageUrl, + previousArchiveUrl = previousArchiveUrl, + episodes = episodes, + ) + } + + /** Accumulates one item's fields; resolves identity only once the whole item has been seen. */ + private class ItemBuilder : TagSink { + var title: String? = null + var description: String? = null + var guid: String? = null + var mediaUrl: String? = null + var mediaType: String? = null + var mediaLength: Long? = null + var durationMs: Long? = null + var publishedAtMs: Long? = null + var season: Int? = null + var episode: Int? = null + var imageUrl: String? = null + var chaptersUrl: String? = null + + // Split by where the value comes from -- attributes or element text -- rather than + // one dispatch over every tag, which was complex enough for detekt to object to. + override fun readTag(parser: XmlPullParser, name: String) { + when (name) { + "enclosure" -> readEnclosure(parser) + "image" -> imageUrl = imageUrl ?: parser.attr("href") + "chapters" -> chaptersUrl = chaptersUrl ?: parser.attr("url") + else -> readTextTag(parser, name) + } + } + + /** Tags stored as the text they carry. First value seen wins, so a duplicate can't clobber it. */ + private fun readTextTag(parser: XmlPullParser, name: String) { + when (name) { + "title" -> title = title ?: parser.text() + "description", "summary" -> description = description ?: parser.text() + "guid", "id" -> guid = guid ?: parser.text() + else -> readConvertedTag(parser, name) + } + } + + /** Tags whose text has to be converted before it means anything. */ + private fun readConvertedTag(parser: XmlPullParser, name: String) { + when (name) { + "pubDate", "published" -> publishedAtMs = publishedAtMs ?: parseDate(parser.text()) + "duration" -> durationMs = durationMs ?: parseDuration(parser.text()) + "season" -> season = season ?: parser.text()?.toIntOrNull() + "episode" -> episode = episode ?: parser.text()?.toIntOrNull() + } + } + + private fun readEnclosure(parser: XmlPullParser) { + if (mediaUrl != null) return + val url = parser.attr("url") ?: return + val type = parser.attr("type") ?: inferType(url) ?: return + // An item whose only attachment is cover art is not an episode. This is the exact + // distinction FeeDreader's parser does not make. + if (!isPlayable(type)) return + mediaUrl = url + mediaType = type + mediaLength = parser.attr("length")?.toLongOrNull() + } + + fun build(): ParsedEpisode? { + val url = mediaUrl ?: return null + val type = mediaType ?: return null + return ParsedEpisode( + // Identity resolved last: a missing guid falls back to the enclosure URL, which + // is stable enough to keep a playback position attached to the right episode. + guid = guid?.takeIf { it.isNotBlank() } ?: url, + title = title, + description = description, + mediaUrl = url, + mediaType = type, + mediaLengthBytes = mediaLength, + durationMs = durationMs, + publishedAtMs = publishedAtMs, + seasonNumber = season, + episodeNumber = episode, + imageUrl = imageUrl, + chaptersUrl = chaptersUrl, + ) + } + } + + private fun isPlayable(type: String) = + type.startsWith("audio/", ignoreCase = true) || type.startsWith("video/", ignoreCase = true) + + private fun inferType(url: String): String? = when (url.substringAfterLast('.', "").lowercase()) { + "mp3" -> "audio/mpeg" + "m4a", "m4b", "aac" -> "audio/mp4" + "opus" -> "audio/opus" + "ogg" -> "audio/ogg" + "flac" -> "audio/flac" + "wav" -> "audio/wav" + "mp4", "m4v" -> "video/mp4" + else -> null + } + + /** + * iTunes durations come as either a plain second count or a clock value. Returns null rather + * than zero when absent, so "unknown length" stays distinguishable from "zero length". + */ + internal fun parseDuration(text: String?): Long? { + val raw = text?.trim().orEmpty() + if (raw.isEmpty()) return null + if (!raw.contains(':')) return raw.toLongOrNull()?.times(MS_PER_SECOND) + + val parts = raw.split(':').map { it.trim().toLongOrNull() ?: return null } + val seconds = when (parts.size) { + CLOCK_PARTS_HMS -> + parts[0] * SECONDS_PER_MINUTE * SECONDS_PER_MINUTE + parts[1] * SECONDS_PER_MINUTE + parts[2] + CLOCK_PARTS_MS -> parts[0] * SECONDS_PER_MINUTE + parts[1] + else -> return null + } + return seconds * MS_PER_SECOND + } + + private val DATE_PATTERNS = listOf( + "EEE, dd MMM yyyy HH:mm:ss zzz", + "EEE, dd MMM yyyy HH:mm:ss Z", + "EEE, dd MMM yyyy HH:mm zzz", + "dd MMM yyyy HH:mm:ss zzz", + "yyyy-MM-dd'T'HH:mm:ssXXX", + "yyyy-MM-dd'T'HH:mm:ss'Z'", + ) + + /** Returns null on anything unparseable; a wrong date is worse than a missing one. */ + internal fun parseDate(text: String?): Long? { + val raw = text?.trim().orEmpty() + if (raw.isEmpty()) return null + for (pattern in DATE_PATTERNS) { + val format = SimpleDateFormat(pattern, Locale.US) + format.timeZone = TimeZone.getTimeZone("UTC") + // Lenient on purpose. Strict mode rejects "Tue, 02 Sep 2026 06:00:00 GMT" -- an + // entirely ordinary RFC 822 date -- which would null out every pubDate in the app + // and destroy episode ordering. Leniency relaxes field values, not structure, so + // unstructured text still fails to match and still returns null. + format.isLenient = true + val parsed = runCatching { format.parse(raw) }.getOrNull() + if (parsed != null) return parsed.time + } + return null + } + + /** Element name with any namespace prefix stripped. */ + private fun XmlPullParser.localName() = name?.substringAfterLast(':').orEmpty() + + private fun XmlPullParser.attr(attribute: String): String? { + for (i in 0 until attributeCount) { + if (getAttributeName(i).substringAfterLast(':') == attribute) { + return getAttributeValue(i)?.trim()?.takeIf { it.isNotEmpty() } + } + } + return null + } + + private fun XmlPullParser.text(): String? = + runCatching { nextText() }.getOrNull()?.trim()?.takeIf { it.isNotEmpty() } +} diff --git a/app/src/main/java/org/terst/listen/playback/PlaybackService.kt b/app/src/main/java/org/terst/listen/playback/PlaybackService.kt new file mode 100644 index 0000000..5fb39c2 --- /dev/null +++ b/app/src/main/java/org/terst/listen/playback/PlaybackService.kt @@ -0,0 +1,39 @@ +package org.terst.listen.playback + +import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.session.MediaSession +import androidx.media3.session.MediaSessionService + +/** + * Hosts the player so audio survives the app leaving the foreground. + * + * The notification this produces is the transport control -- lock screen, Bluetooth, car head + * unit. It is mandatory for background playback and is not an alert; Listen posts no + * new-episode notifications. + */ +class PlaybackService : MediaSessionService() { + + private var player: ExoPlayer? = null + private var session: MediaSession? = null + + override fun onCreate() { + super.onCreate() + val exo = ExoPlayer.Builder(this) + // Podcast listening is continuous: keep playing through a transient focus loss + // rather than stopping dead, and duck for navigation prompts. + .setHandleAudioBecomingNoisy(true) + .build() + player = exo + session = MediaSession.Builder(this, exo).build() + } + + override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession? = session + + override fun onDestroy() { + session?.release() + player?.release() + session = null + player = null + super.onDestroy() + } +} diff --git a/app/src/main/java/org/terst/listen/ui/MainActivity.kt b/app/src/main/java/org/terst/listen/ui/MainActivity.kt new file mode 100644 index 0000000..88cd45f --- /dev/null +++ b/app/src/main/java/org/terst/listen/ui/MainActivity.kt @@ -0,0 +1,34 @@ +package org.terst.listen.ui + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContent { ListenTheme { LibraryPlaceholder() } } + } +} + +@Composable +fun ListenTheme(content: @Composable () -> Unit) { + MaterialTheme { Surface(modifier = Modifier.fillMaxSize()) { content() } } +} + +@Composable +private fun LibraryPlaceholder() { + Column(modifier = Modifier.padding(24.dp)) { + Text("Listen", style = MaterialTheme.typography.headlineMedium) + Text("No subscriptions yet.", style = MaterialTheme.typography.bodyMedium) + } +} diff --git a/app/src/main/java/org/terst/listen/ui/ShareReceiverActivity.kt b/app/src/main/java/org/terst/listen/ui/ShareReceiverActivity.kt new file mode 100644 index 0000000..4130c2b --- /dev/null +++ b/app/src/main/java/org/terst/listen/ui/ShareReceiverActivity.kt @@ -0,0 +1,32 @@ +package org.terst.listen.ui + +import android.content.Intent +import android.os.Bundle +import android.widget.Toast +import androidx.activity.ComponentActivity + +/** + * Share-sheet target. With discovery out of scope, this and OPML import are how subscriptions + * get added: a URL shared from a browser or FeeDreader is resolved to a feed. + * + * Resolution itself is not wired up yet -- see SubscriptionResolver. + */ +class ShareReceiverActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val shared = intent?.takeIf { it.action == Intent.ACTION_SEND } + ?.getStringExtra(Intent.EXTRA_TEXT) + .orEmpty() + val url = URL_REGEX.find(shared)?.value + Toast.makeText( + this, + if (url == null) "No link found in that share" else "Resolving $url", + Toast.LENGTH_SHORT, + ).show() + finish() + } + + private companion object { + val URL_REGEX = Regex("""https?://\S+""") + } +} diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..bd43aaf --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,21 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- Headphones, drawn inside the adaptive icon's 72dp safe zone (centred in a 108dp canvas). + Amber on deep teal, matching the design plan's palette. --> +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="108dp" + android:height="108dp" + android:viewportWidth="108" + android:viewportHeight="108"> + <path + android:pathData="M36,64 A18,18 0 0 1 72,64" + android:strokeColor="#E0A83C" + android:strokeWidth="7" + android:strokeLineCap="round" + android:fillColor="@android:color/transparent" /> + <path + android:pathData="M31,62 h8 a3,3 0 0 1 3,3 v12 a3,3 0 0 1 -3,3 h-8 a3,3 0 0 1 -3,-3 v-12 a3,3 0 0 1 3,-3 z" + android:fillColor="#E0A83C" /> + <path + android:pathData="M69,62 h8 a3,3 0 0 1 3,3 v12 a3,3 0 0 1 -3,3 h-8 a3,3 0 0 1 -3,-3 v-12 a3,3 0 0 1 3,-3 z" + android:fillColor="#E0A83C" /> +</vector> diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..b686c90 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="utf-8"?> +<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> + <background android:drawable="@color/icon_background" /> + <foreground android:drawable="@drawable/ic_launcher_foreground" /> +</adaptive-icon> diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..b686c90 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="utf-8"?> +<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> + <background android:drawable="@color/icon_background" /> + <foreground android:drawable="@drawable/ic_launcher_foreground" /> +</adaptive-icon> diff --git a/app/src/main/res/values-night/colors.xml b/app/src/main/res/values-night/colors.xml new file mode 100644 index 0000000..4b2fc81 --- /dev/null +++ b/app/src/main/res/values-night/colors.xml @@ -0,0 +1,4 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <color name="window_background">#121618</color> +</resources> diff --git a/app/src/main/res/values-night/themes.xml b/app/src/main/res/values-night/themes.xml new file mode 100644 index 0000000..9a93256 --- /dev/null +++ b/app/src/main/res/values-night/themes.xml @@ -0,0 +1,9 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <style name="Theme.Listen" parent="android:Theme.Material.NoActionBar"> + <item name="android:windowBackground">@color/window_background</item> + <item name="android:statusBarColor">@color/window_background</item> + <item name="android:navigationBarColor">@color/window_background</item> + <item name="android:windowLightStatusBar">false</item> + </style> +</resources> diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..5118730 --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <color name="window_background">#EDEFEC</color> + <color name="icon_background">#1F5352</color> +</resources> diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..745e7f4 --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,4 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <string name="app_name">Listen</string> +</resources> diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..816a5ab --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,14 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <!-- Deliberately a plain API 21+ platform theme rather than a DayNight or DeviceDefault + variant. FeeDreader shipped a theme parent that silently required API 29 against a + minSdk of 26 and only an audit caught it; picking a parent well below minSdk removes + that class of mistake. Compose draws the whole UI, so this theme only needs to supply + a window background and get out of the way. Dark mode comes from values-night. --> + <style name="Theme.Listen" parent="android:Theme.Material.Light.NoActionBar"> + <item name="android:windowBackground">@color/window_background</item> + <item name="android:statusBarColor">@color/window_background</item> + <item name="android:navigationBarColor">@color/window_background</item> + <item name="android:windowLightStatusBar">true</item> + </style> +</resources> diff --git a/app/src/test/java/org/terst/listen/feed/PodcastFeedParserTest.kt b/app/src/test/java/org/terst/listen/feed/PodcastFeedParserTest.kt new file mode 100644 index 0000000..f5bd6b0 --- /dev/null +++ b/app/src/test/java/org/terst/listen/feed/PodcastFeedParserTest.kt @@ -0,0 +1,221 @@ +package org.terst.listen.feed + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * The parser FeeDreader has does not do this job -- it only ever inspects an <enclosure> while + * looking for a thumbnail. These tests pin the podcast semantics it lacks: audio enclosures, + * durations, episode identity, and the Podcasting 2.0 tags the skip feature depends on. + */ +class PodcastFeedParserTest { + + private fun feed(items: String, channelExtra: String = "") = """ + <?xml version="1.0" encoding="UTF-8"?> + <rss version="2.0" + xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd" + xmlns:podcast="https://podcastindex.org/namespace/1.0"> + <channel> + <title>The Rest Is History</title> + <link>https://example.com</link> + <description>Two historians.</description> + <itunes:author>Goalhanger</itunes:author> + $channelExtra + $items + </channel> + </rss> + """.trim() // trim, not trimIndent: interpolating an already-dedented string drops the common + // indent to zero, leaving whitespace ahead of the XML declaration -- which is invalid XML. + + private fun item( + title: String = "Episode One", + guid: String = "urn:uuid:1", + enclosure: String = """<enclosure url="https://cdn.example.com/1.mp3" length="1000" type="audio/mpeg"/>""", + extra: String = "", + ) = """ + <item> + <title>$title</title> + <guid isPermaLink="false">$guid</guid> + <pubDate>Tue, 02 Sep 2026 06:00:00 GMT</pubDate> + $enclosure + $extra + </item> + """.trimIndent() + + @Test + fun `reads channel metadata`() { + val parsed = PodcastFeedParser.parse(feed(item()).byteInputStream()) + assertEquals("The Rest Is History", parsed.title) + assertEquals("Two historians.", parsed.description) + assertEquals("Goalhanger", parsed.author) + } + + @Test + fun `reads an audio enclosure as the episode media`() { + val parsed = PodcastFeedParser.parse(feed(item()).byteInputStream()) + val episode = parsed.episodes.single() + assertEquals("https://cdn.example.com/1.mp3", episode.mediaUrl) + assertEquals("audio/mpeg", episode.mediaType) + assertEquals(1000L, episode.mediaLengthBytes) + } + + /** + * The whole reason this parser exists rather than reusing FeeDreader's: an item without + * playable media is not an episode, and an image enclosure is not media. + */ + @Test + fun `drops items whose enclosure is not playable media`() { + val imageOnly = item(enclosure = """<enclosure url="https://c.example.com/art.jpg" length="9" type="image/jpeg"/>""") + val noEnclosure = item(guid = "urn:uuid:2", enclosure = "") + val parsed = PodcastFeedParser.parse(feed(imageOnly + noEnclosure).byteInputStream()) + assertTrue("expected no episodes, got ${parsed.episodes}", parsed.episodes.isEmpty()) + } + + @Test + fun `keeps a video enclosure`() { + val video = item(enclosure = """<enclosure url="https://c.example.com/1.mp4" length="5" type="video/mp4"/>""") + val parsed = PodcastFeedParser.parse(feed(video).byteInputStream()) + assertEquals("video/mp4", parsed.episodes.single().mediaType) + } + + @Test + fun `parses itunes duration in seconds`() { + val parsed = PodcastFeedParser.parse( + feed(item(extra = "<itunes:duration>3600</itunes:duration>")).byteInputStream() + ) + assertEquals(3600_000L, parsed.episodes.single().durationMs) + } + + @Test + fun `parses itunes duration in clock form`() { + val cases = mapOf( + "01:02:03" to 3723_000L, + "02:03" to 123_000L, + "1:00:00" to 3600_000L, + ) + for ((text, expected) in cases) { + val parsed = PodcastFeedParser.parse( + feed(item(extra = "<itunes:duration>$text</itunes:duration>")).byteInputStream() + ) + assertEquals("duration $text", expected, parsed.episodes.single().durationMs) + } + } + + @Test + fun `duration is null when absent rather than zero`() { + val parsed = PodcastFeedParser.parse(feed(item()).byteInputStream()) + assertNull(parsed.episodes.single().durationMs) + } + + @Test + fun `reads season and episode numbers`() { + val parsed = PodcastFeedParser.parse( + feed( + item(extra = "<itunes:season>4</itunes:season><itunes:episode>12</itunes:episode>") + ).byteInputStream() + ) + val episode = parsed.episodes.single() + assertEquals(4, episode.seasonNumber) + assertEquals(12, episode.episodeNumber) + } + + /** + * Episode identity is what a playback position hangs off. A missing guid must fall back to + * something stable rather than being dropped or randomised. + */ + @Test + fun `falls back to the enclosure url when guid is missing`() { + val parsed = PodcastFeedParser.parse(feed(item(guid = "")).byteInputStream()) + assertEquals("https://cdn.example.com/1.mp3", parsed.episodes.single().guid) + } + + @Test + fun `parses pubDate to an epoch millis value`() { + val parsed = PodcastFeedParser.parse(feed(item()).byteInputStream()) + // Tue, 02 Sep 2026 06:00:00 GMT + assertEquals(1788328800_000L, parsed.episodes.single().publishedAtMs) + } + + @Test + fun `survives a malformed pubDate`() { + val parsed = PodcastFeedParser.parse( + feed(item().replace("Tue, 02 Sep 2026 06:00:00 GMT", "last thursday")).byteInputStream() + ) + assertNull(parsed.episodes.single().publishedAtMs) + } + + /** + * Tier 1 of the skip feature. A show that publishes its ad markers makes detection exact, + * so the link has to survive parsing. + */ + @Test + fun `reads the podcast chapters url`() { + val parsed = PodcastFeedParser.parse( + feed( + item( + extra = """<podcast:chapters url="https://example.com/1.json" type="application/json+chapters"/>""" + ) + ).byteInputStream() + ) + assertEquals("https://example.com/1.json", parsed.episodes.single().chaptersUrl) + } + + @Test + fun `reads the rfc5005 previous archive link for drip subscriptions`() { + val parsed = PodcastFeedParser.parse( + feed( + item(), + channelExtra = """<atom:link xmlns:atom="http://www.w3.org/2005/Atom" + rel="prev-archive" href="https://example.com/page2.xml"/>""" + ).byteInputStream() + ) + assertEquals("https://example.com/page2.xml", parsed.previousArchiveUrl) + } + + @Test + fun `prefers itunes image over channel image`() { + val parsed = PodcastFeedParser.parse( + feed( + item(), + channelExtra = """<itunes:image href="https://example.com/art.jpg"/>""" + ).byteInputStream() + ) + assertEquals("https://example.com/art.jpg", parsed.imageUrl) + } + + @Test + fun `preserves feed order`() { + val three = item(title = "Third", guid = "c") + item(title = "Second", guid = "b") + + item(title = "First", guid = "a") + val parsed = PodcastFeedParser.parse(feed(three).byteInputStream()) + assertEquals(listOf("Third", "Second", "First"), parsed.episodes.map { it.title }) + } + + @Test + fun `unescapes entities in titles`() { + val parsed = PodcastFeedParser.parse( + feed(item(title = "Mathematics & Physics")).byteInputStream() + ) + assertEquals("Mathematics & Physics", parsed.episodes.single().title) + } + + @Test + fun `reads an episode description from itunes summary when description is absent`() { + val parsed = PodcastFeedParser.parse( + feed(item(extra = "<itunes:summary>A summary.</itunes:summary>")).byteInputStream() + ) + assertEquals("A summary.", parsed.episodes.single().description) + } + + @Test + fun `rejects a document that is not a feed`() { + val html = "<html><body><p>not a feed</p></body></html>" + val parsed = PodcastFeedParser.parse(html.byteInputStream()) + assertNotNull(parsed) + assertTrue(parsed.episodes.isEmpty()) + assertNull(parsed.title) + } +} diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..a2551b0 --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,8 @@ +plugins { + id("com.android.application") version "8.13.2" apply false + id("org.jetbrains.kotlin.android") version "2.2.21" apply false + id("org.jetbrains.kotlin.plugin.serialization") version "2.2.21" apply false + id("org.jetbrains.kotlin.plugin.compose") version "2.2.21" apply false + id("com.google.devtools.ksp") version "2.2.21-2.0.5" apply false + id("io.gitlab.arturbosch.detekt") version "1.23.8" apply false +} diff --git a/config/detekt/detekt.yml b/config/detekt/detekt.yml new file mode 100644 index 0000000..a5b1129 --- /dev/null +++ b/config/detekt/detekt.yml @@ -0,0 +1,20 @@ +# Builds on detekt's default ruleset (see app/build.gradle.kts: buildUponDefaultConfig = true). +# Only overrides/additions relative to the defaults belong here -- keep this file small so +# upgrading detekt pulls in sane new defaults automatically instead of us silently diverging. + +style: + ReturnCount: + max: 5 # the feed parser is guard-clause heavy on purpose -- an enclosure with no URL, no + # type, or a non-playable type each bail out early. Forcing those into nested conditionals + # to satisfy a limit of 2 would make the code harder to read, not easier. + MaxLineLength: + maxLineLength: 140 # several files carry long, deliberate rationale comments (see + # ReaderExtractor.kt) -- 120 (detekt's default) wrapped mid-explanation too aggressively. + ForbiddenComment: + active: false # this codebase deliberately doesn't use TODO/FIXME markers (see CLAUDE.md + # "why these checks") -- nothing to forbid. + +naming: + FunctionNaming: + ignoreAnnotated: ["Composable"] # Jetpack Compose's own naming convention is PascalCase for + # composable functions -- that's the correct style here, not a violation. diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..fb2663e --- /dev/null +++ b/gradle.properties @@ -0,0 +1,5 @@ +android.useAndroidX=true + +android.nonTransitiveRClass=true + +org.gradle.jvmargs=-Xmx4096m -XX:MaxMetaspaceSize=1024m diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar Binary files differnew file mode 100644 index 0000000..d64cd49 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.jar diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..1a70468 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.0-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists @@ -0,0 +1,8 @@ +#!/bin/sh +# Standard Gradle wrapper invocation -- this project needs a newer Gradle than the shared +# system install at /opt/gradle/gradle-8.6 (AGP 9.x requires Gradle 9.5+), and other projects +# in this sandbox rely on that shared install staying at 8.6, so this project uses its own +# pinned version via gradle/wrapper/gradle-wrapper.properties instead of the shared shortcut. +APP_HOME="$(cd "$(dirname "$0")" && pwd)" +CLASSPATH="$APP_HOME/gradle/wrapper/gradle-wrapper.jar" +exec java -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain "$@" diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..c9939d4 --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,18 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "Listen" +include(":app") |
