Import miniabs 1.0 source

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xockoandClaude Opus 5.5 committed 2026-10-14 19:26:04 +02:00
commit 4fe9eed870
78 files changed
+5800

No files matched your search

+3
View File
@@ -0,0 +1,3 @@
build/
.DS_Store
._*
+71
View File
@@ -0,0 +1,71 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.miniabs"
android:versionCode="1"
android:versionName="1.0"
android:installLocation="auto">
<uses-sdk android:minSdkVersion="8" android:targetSdkVersion="10" />
<supports-screens
android:smallScreens="true"
android:normalScreens="true"
android:largeScreens="true"
android:anyDensity="true" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<application
android:label="@string/app_name"
android:icon="@drawable/ic_launcher"
android:theme="@style/MiniTheme">
<activity
android:name=".HomeActivity"
android:screenOrientation="portrait"
android:configChanges="keyboardHidden|orientation"
android:launchMode="singleTask">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<activity
android:name=".LoginActivity"
android:screenOrientation="portrait"
android:configChanges="keyboardHidden|orientation"
android:windowSoftInputMode="adjustResize" />
<activity
android:name=".PodcastActivity"
android:screenOrientation="portrait"
android:configChanges="keyboardHidden|orientation" />
<activity
android:name=".PlayerActivity"
android:screenOrientation="portrait"
android:configChanges="keyboardHidden|orientation"
android:launchMode="singleTop" />
<activity
android:name=".SettingsActivity"
android:label="Settings"
android:theme="@android:style/Theme"
android:screenOrientation="portrait"
android:configChanges="keyboardHidden|orientation" />
<service android:name=".PlayerService" />
<service android:name=".DownloadService" />
<receiver android:name=".MediaButtonReceiver">
<intent-filter>
<action android:name="android.intent.action.MEDIA_BUTTON" />
</intent-filter>
</receiver>
</application>
</manifest>
+48
View File
@@ -0,0 +1,48 @@
# MiniABS
A tiny Audiobookshelf **podcast** client for the Samsung Galaxy Mini (GT-S5570) and other Android 2.2+ phones. Native Java, no libraries, ~100 KB.
## Install
1. Copy `MiniABS.apk` to the phone (USB to the SD card, or download it in the browser).
2. On the phone: **Settings → Applications → Unknown sources** → tick it.
3. Open the APK from a file manager and install. It can be moved to the SD card (Settings → Applications → Manage applications → MiniABS → Move to SD card).
## Connecting
Use your server's **local http:// address**, e.g. `http://192.168.1.10:13378`.
Android 2.x only speaks old TLS and has an outdated certificate list, so it usually can't connect to modern HTTPS servers (including Let's Encrypt behind a reverse proxy). The "Accept any HTTPS certificate" box only helps with self-signed certificates on servers that still allow older TLS.
Sign in with your username and password, or tick "Use an API key instead". It works with both current servers (2.26+, short-lived access tokens that MiniABS refreshes automatically) and older ones (single long-lived token).
## Using it
- **Latest**: "Continue listening" plus the newest episodes across your podcast library. Tap the library name at the top to switch libraries.
- **Shows**: every podcast; tap one for its episodes. Menu key → sort oldest/newest, hide played.
- **Saved**: episodes downloaded to the SD card, plus download progress.
- **Tap** an episode to play. **Long-press** for Download, Delete download, Mark as played/unplayed, Go to show.
- **Player**: skip back/forward (set the amounts in Settings), seek bar, show notes. Menu key → sleep timer, mark as played, download, stop.
- **Headset buttons**: play/pause; next/previous skip forward/back. Playback pauses when headphones are unplugged and during calls.
Colored dots: blue = new, amber = in progress (with a bar), grey = played.
## Progress sync
Playback opens an Audiobookshelf session and syncs every 15 seconds and on pause, so progress follows you to other apps. Saved episodes play offline; their positions are kept on the phone and sent to the server next time you're online (or tap refresh on the Saved tab).
## Limits
- Episodes are streamed with direct play. Formats Android 2.x can't decode (e.g. Opus) can't be played, since the transcoded HLS stream isn't supported on 2.x.
- No playback speed control: Android 2.x's media player doesn't support it.
- Downloads need an SD card. "Download on Wi-Fi only" is on by default.
## Building
Needs a JDK, `aapt`, `dalvik-exchange` (dx), `zipalign`, `apksigner` (Ubuntu: `apt install aapt dalvik-exchange zipalign apksigner`) and an API 8 `android.jar`:
ANDROID_JAR=/path/to/android-8/android.jar ./build.sh
Compiling against the API 8 jar guarantees no newer Android APIs are used. The APK is signed with `miniabs.keystore` (password `miniabs`); keep it to install updates over the top.
`test/run_tests.sh` runs the API, JSON and progress code on the desktop against a mock Audiobookshelf server (`test/mock_abs.py`).
Executable
+37
View File
@@ -0,0 +1,37 @@
#!/bin/sh
# Builds MiniABS.apk for Android 2.2+ (API 8) using the plain SDK tools, no Gradle.
# ANDROID_JAR android.jar for API 8 (compiling against it guarantees no newer APIs sneak in)
# KEYSTORE signing keystore (created on first build if missing)
set -e
cd "$(dirname "$0")"
ANDROID_JAR=${ANDROID_JAR:-/home/claude/sable/android-platforms/android-8/android.jar}
KEYSTORE=${KEYSTORE:-miniabs.keystore}
OUT=build
rm -rf "$OUT"
mkdir -p "$OUT/gen" "$OUT/classes"
echo "== resources"
aapt package -f -m -J "$OUT/gen" -M AndroidManifest.xml -S res -I "$ANDROID_JAR" \
-F "$OUT/MiniABS.unsigned.apk"
echo "== compile (Java 8 bytecode, API 8 class library)"
javac -nowarn -Xlint:-options -encoding UTF-8 -source 8 -target 8 \
-bootclasspath "$ANDROID_JAR" -classpath "$ANDROID_JAR" \
-d "$OUT/classes" $(find src "$OUT/gen" -name '*.java')
echo "== dex"
dalvik-exchange --dex --min-sdk-version=8 --output="$OUT/classes.dex" "$OUT/classes"
(cd "$OUT" && aapt add MiniABS.unsigned.apk classes.dex > /dev/null)
echo "== align + sign (v1 JAR signature with SHA-1, which Android 2.x requires)"
if [ ! -f "$KEYSTORE" ]; then
keytool -genkeypair -keystore "$KEYSTORE" -storepass miniabs -keypass miniabs -alias miniabs \
-keyalg RSA -keysize 2048 -validity 10000 -dname "CN=MiniABS" > /dev/null 2>&1
fi
zipalign -f 4 "$OUT/MiniABS.unsigned.apk" "$OUT/MiniABS.aligned.apk"
apksigner sign --ks "$KEYSTORE" --ks-pass pass:miniabs --key-pass pass:miniabs --ks-key-alias miniabs \
--min-sdk-version 8 --v1-signing-enabled true --v2-signing-enabled false --v3-signing-enabled false \
--out "$OUT/MiniABS.apk" "$OUT/MiniABS.aligned.apk"
apksigner verify --min-sdk-version 8 "$OUT/MiniABS.apk"
ls -l "$OUT/MiniABS.apk"
BIN
View File
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 499 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 346 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 650 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 880 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 816 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 690 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 381 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 349 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 470 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 527 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 377 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 326 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 408 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 413 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 513 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 656 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 463 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 457 B

+12
View File
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_pressed="true">
<shape><solid android:color="#FFC766" /><corners android:radius="6dp" /></shape>
</item>
<item android:state_enabled="false">
<shape><solid android:color="@color/dim" /><corners android:radius="6dp" /></shape>
</item>
<item>
<shape><solid android:color="@color/accent" /><corners android:radius="6dp" /></shape>
</item>
</selector>
+24
View File
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_pressed="true">
<shape>
<solid android:color="@color/pressed" />
<corners android:radius="6dp" />
<stroke android:width="1dp" android:color="@color/accent" />
</shape>
</item>
<item android:state_enabled="false">
<shape>
<solid android:color="@color/surface" />
<corners android:radius="6dp" />
<stroke android:width="1dp" android:color="@color/line" />
</shape>
</item>
<item>
<shape>
<solid android:color="@color/surface" />
<corners android:radius="6dp" />
<stroke android:width="1dp" android:color="@color/line" />
</shape>
</item>
</selector>
+9
View File
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_pressed="true">
<shape android:shape="oval"><solid android:color="#FFC766" /></shape>
</item>
<item>
<shape android:shape="oval"><solid android:color="@color/accent" /></shape>
</item>
</selector>
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_pressed="true" android:drawable="@color/pressed" />
<item android:state_focused="true" android:drawable="@color/surface" />
<item android:drawable="@android:color/transparent" />
</selector>
+5
View File
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="oval">
<solid android:color="@color/accent" />
<size android:width="14dp" android:height="14dp" />
</shape>
+12
View File
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:id="@android:id/background">
<shape><solid android:color="@color/line" /><corners android:radius="2dp" /></shape>
</item>
<item android:id="@android:id/secondaryProgress">
<clip><shape><solid android:color="#4A535E" /><corners android:radius="2dp" /></shape></clip>
</item>
<item android:id="@android:id/progress">
<clip><shape><solid android:color="@color/accent" /><corners android:radius="2dp" /></shape></clip>
</item>
</layer-list>
+11
View File
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_pressed="true" android:drawable="@color/pressed" />
<item android:state_selected="true">
<layer-list>
<item><shape><solid android:color="@color/accent" /></shape></item>
<item android:bottom="3dp"><shape><solid android:color="@color/surface" /></shape></item>
</layer-list>
</item>
<item><shape><solid android:color="@color/surface" /></shape></item>
</selector>
+12
View File
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:id="@android:id/background">
<shape><solid android:color="@color/line" /></shape>
</item>
<item android:id="@android:id/secondaryProgress">
<clip><shape><solid android:color="#4A535E" /></shape></clip>
</item>
<item android:id="@android:id/progress">
<clip><shape><solid android:color="@color/accent" /></shape></clip>
</item>
</layer-list>
+51
View File
@@ -0,0 +1,51 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="fill_parent"
android:layout_height="fill_parent"
android:orientation="vertical">
<LinearLayout style="@style/Header" android:orientation="horizontal">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="MiniABS"
android:textSize="15sp"
android:textStyle="bold"
android:textColor="@color/accent" />
<TextView
android:id="@+id/library"
android:layout_width="0dp"
android:layout_height="fill_parent"
android:layout_weight="1"
android:gravity="center_vertical"
android:paddingLeft="8dp"
android:textSize="11sp"
android:textColor="@color/sub"
android:singleLine="true"
android:ellipsize="end"
android:background="@drawable/row_sel" />
<ImageButton
android:id="@+id/refresh"
android:layout_width="36dp"
android:layout_height="fill_parent"
android:background="@drawable/row_sel"
android:src="@drawable/ic_refresh" />
</LinearLayout>
<LinearLayout
android:layout_width="fill_parent"
android:layout_height="32dp"
android:orientation="horizontal">
<TextView android:id="@+id/tab_latest" style="@style/Tab" android:text="Latest" />
<TextView android:id="@+id/tab_shows" style="@style/Tab" android:text="Shows" />
<TextView android:id="@+id/tab_saved" style="@style/Tab" android:text="Saved" />
</LinearLayout>
<include layout="@layout/list_area" />
<include layout="@layout/mini_bar" />
</LinearLayout>
+108
View File
@@ -0,0 +1,108 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="fill_parent"
android:layout_height="fill_parent"
android:fillViewport="true">
<LinearLayout
android:layout_width="fill_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="12dp">
<LinearLayout
android:layout_width="fill_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:layout_marginBottom="4dp">
<ImageView
android:layout_width="36dp"
android:layout_height="36dp"
android:src="@drawable/ic_launcher" />
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingLeft="8dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="MiniABS"
android:textSize="18sp"
android:textStyle="bold"
android:textColor="@color/accent" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Audiobookshelf podcasts"
android:textSize="11sp"
android:textColor="@color/sub" />
</LinearLayout>
</LinearLayout>
<TextView style="@style/Label" android:text="Server address" />
<EditText
android:id="@+id/server"
style="@style/Field"
android:hint="http://192.168.1.10:13378"
android:inputType="textUri" />
<TextView android:id="@+id/user_label" style="@style/Label" android:text="Username" />
<EditText
android:id="@+id/user"
style="@style/Field"
android:inputType="text" />
<TextView android:id="@+id/pass_label" style="@style/Label" android:text="Password" />
<EditText
android:id="@+id/pass"
style="@style/Field"
android:inputType="textPassword" />
<CheckBox
android:id="@+id/use_key"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="12sp"
android:text="Use an API key instead" />
<CheckBox
android:id="@+id/trust"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="12sp"
android:text="Accept any HTTPS certificate (insecure)" />
<Button
android:id="@+id/connect"
android:layout_width="fill_parent"
android:layout_height="42dp"
android:layout_marginTop="6dp"
android:background="@drawable/btn_accent"
android:text="Connect"
android:textStyle="bold"
android:textSize="14sp"
android:textColor="#121519" />
<TextView
android:id="@+id/status"
android:layout_width="fill_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:textSize="12sp"
android:textColor="@color/accent" />
<TextView
android:layout_width="fill_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:textSize="10sp"
android:textColor="@color/dim"
android:text="Tip: Android 2.x can't talk to most modern HTTPS servers. Use your server's local http:// address (on Wi-Fi) for the most reliable connection." />
</LinearLayout>
</ScrollView>
+162
View File
@@ -0,0 +1,162 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="fill_parent"
android:layout_height="fill_parent"
android:orientation="vertical">
<LinearLayout style="@style/Header" android:orientation="horizontal">
<TextView
android:id="@+id/show"
android:layout_width="0dp"
android:layout_height="fill_parent"
android:layout_weight="1"
android:gravity="center_vertical"
android:textSize="12sp"
android:textColor="@color/accent"
android:singleLine="true"
android:ellipsize="end"
android:background="@drawable/row_sel" />
</LinearLayout>
<LinearLayout
android:layout_width="fill_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:padding="8dp"
android:gravity="center_vertical">
<ImageView
android:id="@+id/cover"
android:layout_width="84dp"
android:layout_height="84dp"
android:layout_marginRight="8dp"
android:scaleType="centerCrop"
android:background="@color/surface" />
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:id="@+id/title"
android:layout_width="fill_parent"
android:layout_height="wrap_content"
android:textSize="14sp"
android:textStyle="bold"
android:textColor="@color/text"
android:maxLines="3"
android:ellipsize="end" />
<TextView
android:id="@+id/date"
android:layout_width="fill_parent"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:textSize="10sp"
android:textColor="@color/sub"
android:singleLine="true" />
<TextView
android:id="@+id/status"
android:layout_width="fill_parent"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:textSize="10sp"
android:textColor="@color/fresh"
android:maxLines="2" />
</LinearLayout>
</LinearLayout>
<SeekBar
android:id="@+id/seek"
android:layout_width="fill_parent"
android:layout_height="wrap_content"
android:paddingLeft="14dp"
android:paddingRight="14dp"
android:minHeight="4dp"
android:maxHeight="4dp"
android:progressDrawable="@drawable/seek_track"
android:thumb="@drawable/seek_thumb"
android:thumbOffset="7dp"
android:max="1000" />
<RelativeLayout
android:layout_width="fill_parent"
android:layout_height="wrap_content"
android:paddingLeft="10dp"
android:paddingRight="10dp"
android:paddingTop="2dp">
<TextView
android:id="@+id/pos"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentLeft="true"
android:textSize="11sp"
android:textColor="@color/sub" />
<TextView
android:id="@+id/left"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentRight="true"
android:textSize="11sp"
android:textColor="@color/sub" />
</RelativeLayout>
<LinearLayout
android:layout_width="fill_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center"
android:paddingTop="4dp"
android:paddingBottom="6dp">
<Button
android:id="@+id/back"
android:layout_width="58dp"
android:layout_height="38dp"
android:background="@drawable/btn_flat"
android:textColor="@color/text"
android:textSize="13sp"
android:textStyle="bold" />
<ImageButton
android:id="@+id/play"
android:layout_width="56dp"
android:layout_height="56dp"
android:layout_marginLeft="16dp"
android:layout_marginRight="16dp"
android:background="@drawable/btn_round"
android:src="@drawable/ic_play" />
<Button
android:id="@+id/fwd"
android:layout_width="58dp"
android:layout_height="38dp"
android:background="@drawable/btn_flat"
android:textColor="@color/text"
android:textSize="13sp"
android:textStyle="bold" />
</LinearLayout>
<View android:layout_width="fill_parent" android:layout_height="1px" android:background="@color/line" />
<ScrollView
android:layout_width="fill_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:fadingEdge="vertical">
<TextView
android:id="@+id/desc"
android:layout_width="fill_parent"
android:layout_height="wrap_content"
android:padding="8dp"
android:textSize="12sp"
android:textColor="@color/sub"
android:lineSpacingExtra="2dp" />
</ScrollView>
</LinearLayout>
+53
View File
@@ -0,0 +1,53 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="fill_parent"
android:layout_height="fill_parent"
android:orientation="vertical">
<LinearLayout
android:layout_width="fill_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:background="@color/header"
android:padding="6dp"
android:gravity="center_vertical">
<ImageView
android:id="@+id/cover"
android:layout_width="44dp"
android:layout_height="44dp"
android:layout_marginRight="8dp"
android:scaleType="centerCrop"
android:background="@color/surface" />
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:id="@+id/title"
android:layout_width="fill_parent"
android:layout_height="wrap_content"
android:textSize="14sp"
android:textStyle="bold"
android:textColor="@color/text"
android:maxLines="2"
android:ellipsize="end" />
<TextView
android:id="@+id/subtitle"
android:layout_width="fill_parent"
android:layout_height="wrap_content"
android:textSize="10sp"
android:textColor="@color/sub"
android:singleLine="true"
android:ellipsize="end" />
</LinearLayout>
</LinearLayout>
<include layout="@layout/list_area" />
<include layout="@layout/mini_bar" />
</LinearLayout>
+27
View File
@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="fill_parent"
android:layout_height="0dp"
android:layout_weight="1">
<ListView android:id="@+id/list" style="@style/List" />
<TextView
android:id="@+id/empty"
android:layout_width="fill_parent"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:gravity="center"
android:padding="20dp"
android:textSize="13sp"
android:textColor="@color/sub"
android:visibility="gone" />
<ProgressBar
android:id="@+id/loading"
style="?android:attr/progressBarStyleSmall"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:visibility="gone" />
</FrameLayout>
+60
View File
@@ -0,0 +1,60 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/mini_bar"
android:layout_width="fill_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:background="@color/header"
android:visibility="gone">
<ProgressBar
android:id="@+id/mini_progress"
style="?android:attr/progressBarStyleHorizontal"
android:layout_width="fill_parent"
android:layout_height="2dp"
android:progressDrawable="@drawable/thin_progress"
android:max="1000" />
<LinearLayout
android:layout_width="fill_parent"
android:layout_height="44dp"
android:orientation="horizontal"
android:gravity="center_vertical">
<LinearLayout
android:id="@+id/mini_info"
android:layout_width="0dp"
android:layout_height="fill_parent"
android:layout_weight="1"
android:orientation="vertical"
android:gravity="center_vertical"
android:paddingLeft="8dp"
android:background="@drawable/row_sel">
<TextView
android:id="@+id/mini_title"
android:layout_width="fill_parent"
android:layout_height="wrap_content"
android:textSize="13sp"
android:textColor="@color/text"
android:singleLine="true"
android:ellipsize="end" />
<TextView
android:id="@+id/mini_sub"
android:layout_width="fill_parent"
android:layout_height="wrap_content"
android:textSize="10sp"
android:textColor="@color/sub"
android:singleLine="true"
android:ellipsize="end" />
</LinearLayout>
<ImageButton
android:id="@+id/mini_play"
android:layout_width="44dp"
android:layout_height="44dp"
android:background="@drawable/row_sel"
android:src="@drawable/ic_play_small" />
</LinearLayout>
</LinearLayout>
+62
View File
@@ -0,0 +1,62 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="fill_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:minHeight="48dp"
android:paddingLeft="6dp"
android:paddingRight="6dp"
android:paddingTop="5dp"
android:paddingBottom="5dp">
<View
android:id="@+id/dot"
android:layout_width="7dp"
android:layout_height="7dp"
android:layout_marginTop="5dp"
android:layout_marginRight="6dp" />
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:id="@+id/show"
android:layout_width="fill_parent"
android:layout_height="wrap_content"
android:textSize="10sp"
android:textColor="@color/accent"
android:singleLine="true"
android:ellipsize="end" />
<TextView
android:id="@+id/title"
android:layout_width="fill_parent"
android:layout_height="wrap_content"
android:textSize="13sp"
android:textColor="@color/text"
android:maxLines="2"
android:ellipsize="end" />
<ProgressBar
android:id="@+id/progress"
style="?android:attr/progressBarStyleHorizontal"
android:layout_width="fill_parent"
android:layout_height="2dp"
android:layout_marginTop="3dp"
android:layout_marginBottom="1dp"
android:progressDrawable="@drawable/thin_progress"
android:max="1000" />
<TextView
android:id="@+id/meta"
android:layout_width="fill_parent"
android:layout_height="wrap_content"
android:textSize="10sp"
android:textColor="@color/sub"
android:singleLine="true"
android:ellipsize="end" />
</LinearLayout>
</LinearLayout>
+12
View File
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<TextView xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/title"
android:layout_width="fill_parent"
android:layout_height="wrap_content"
android:background="@color/surface"
android:paddingLeft="8dp"
android:paddingTop="4dp"
android:paddingBottom="4dp"
android:textSize="10sp"
android:textStyle="bold"
android:textColor="@color/sub" />
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<TextView xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/title"
android:layout_width="fill_parent"
android:layout_height="44dp"
android:gravity="center"
android:textSize="13sp"
android:textColor="@color/accent" />
+42
View File
@@ -0,0 +1,42 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="fill_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:minHeight="48dp"
android:padding="5dp"
android:gravity="center_vertical">
<ImageView
android:id="@+id/cover"
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_marginRight="8dp"
android:scaleType="centerCrop"
android:background="@color/surface" />
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:id="@+id/title"
android:layout_width="fill_parent"
android:layout_height="wrap_content"
android:textSize="13sp"
android:textColor="@color/text"
android:maxLines="2"
android:ellipsize="end" />
<TextView
android:id="@+id/meta"
android:layout_width="fill_parent"
android:layout_height="wrap_content"
android:textSize="10sp"
android:textColor="@color/sub"
android:singleLine="true"
android:ellipsize="end" />
</LinearLayout>
</LinearLayout>
+19
View File
@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string-array name="skip_labels">
<item>5 seconds</item>
<item>10 seconds</item>
<item>15 seconds</item>
<item>30 seconds</item>
<item>45 seconds</item>
<item>60 seconds</item>
</string-array>
<string-array name="skip_values">
<item>5</item>
<item>10</item>
<item>15</item>
<item>30</item>
<item>45</item>
<item>60</item>
</string-array>
</resources>
+68
View File
@@ -0,0 +1,68 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">MiniABS</string>
<color name="bg">#121519</color>
<color name="surface">#1C2127</color>
<color name="header">#0B0D10</color>
<color name="line">#2A3038</color>
<color name="text">#ECEFF2</color>
<color name="sub">#9AA4AE</color>
<color name="dim">#5C6670</color>
<color name="accent">#F5A623</color>
<color name="fresh">#6EC1E4</color>
<color name="pressed">#2C333C</color>
<style name="MiniTheme" parent="@android:style/Theme.NoTitleBar">
<item name="android:windowBackground">@color/bg</item>
<item name="android:textColor">@color/text</item>
<item name="android:textColorPrimary">@color/text</item>
<item name="android:textColorSecondary">@color/sub</item>
</style>
<style name="Header">
<item name="android:layout_width">fill_parent</item>
<item name="android:layout_height">32dp</item>
<item name="android:background">@color/header</item>
<item name="android:gravity">center_vertical</item>
<item name="android:paddingLeft">8dp</item>
<item name="android:paddingRight">2dp</item>
</style>
<style name="Tab">
<item name="android:layout_width">0dp</item>
<item name="android:layout_height">fill_parent</item>
<item name="android:layout_weight">1</item>
<item name="android:gravity">center</item>
<item name="android:textSize">13sp</item>
<item name="android:textStyle">bold</item>
<item name="android:textColor">@color/sub</item>
<item name="android:background">@drawable/tab_bg</item>
</style>
<style name="List">
<item name="android:layout_width">fill_parent</item>
<item name="android:layout_height">fill_parent</item>
<item name="android:cacheColorHint">@color/bg</item>
<item name="android:divider">@color/line</item>
<item name="android:dividerHeight">1px</item>
<item name="android:listSelector">@drawable/row_sel</item>
<item name="android:fastScrollEnabled">true</item>
</style>
<style name="Field">
<item name="android:layout_width">fill_parent</item>
<item name="android:layout_height">wrap_content</item>
<item name="android:textSize">14sp</item>
<item name="android:singleLine">true</item>
<item name="android:textColor">#000000</item>
</style>
<style name="Label">
<item name="android:layout_width">wrap_content</item>
<item name="android:layout_height">wrap_content</item>
<item name="android:textSize">11sp</item>
<item name="android:textColor">@color/sub</item>
<item name="android:layout_marginTop">6dp</item>
</style>
</resources>
+53
View File
@@ -0,0 +1,53 @@
<?xml version="1.0" encoding="utf-8"?>
<PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android">
<PreferenceCategory android:title="Playback">
<ListPreference
android:key="skip_back"
android:title="Skip back"
android:entries="@array/skip_labels"
android:entryValues="@array/skip_values"
android:defaultValue="15" />
<ListPreference
android:key="skip_fwd"
android:title="Skip forward"
android:entries="@array/skip_labels"
android:entryValues="@array/skip_values"
android:defaultValue="30" />
</PreferenceCategory>
<PreferenceCategory android:title="Downloads">
<CheckBoxPreference
android:key="wifi_only"
android:title="Download on Wi-Fi only"
android:summary="Saves your mobile data"
android:defaultValue="true" />
<Preference
android:key="delete_all"
android:title="Delete all downloads" />
</PreferenceCategory>
<PreferenceCategory android:title="Display">
<CheckBoxPreference
android:key="covers"
android:title="Show cover art"
android:summary="Turn off to save data and memory"
android:defaultValue="true" />
</PreferenceCategory>
<PreferenceCategory android:title="Account">
<Preference
android:key="account"
android:title="Signed in" />
<Preference
android:key="logout"
android:title="Log out" />
</PreferenceCategory>
<PreferenceCategory android:title="About">
<Preference
android:key="about"
android:title="MiniABS 1.0"
android:summary="A tiny Audiobookshelf podcast client for Android 2.2+" />
</PreferenceCategory>
</PreferenceScreen>
+542
View File
@@ -0,0 +1,542 @@
package com.miniabs;
import java.io.BufferedInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.io.Reader;
import java.net.HttpURLConnection;
import java.net.InetSocketAddress;
import java.net.ProtocolException;
import java.net.Socket;
import java.net.URL;
import java.net.URLEncoder;
import java.security.cert.X509Certificate;
import javax.net.ssl.HostnameVerifier;
import javax.net.ssl.HttpsURLConnection;
import javax.net.ssl.SSLContext;
import javax.net.ssl.SSLException;
import javax.net.ssl.SSLSession;
import javax.net.ssl.SSLSocket;
import javax.net.ssl.SSLSocketFactory;
import javax.net.ssl.TrustManager;
import javax.net.ssl.X509TrustManager;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
import android.content.Context;
import android.os.Build;
/** Audiobookshelf REST client (API as of server 2.x, including the 2.26+ token system). */
final class Api {
static final String VERSION = "1.0";
static class ApiException extends IOException {
final int code;
ApiException(int code, String msg) {
super(msg);
this.code = code;
}
}
/** Sign-in is no longer valid; the user must log in again. */
static class AuthException extends IOException {
AuthException(String msg) {
super(msg);
}
}
private static final Object REFRESH_LOCK = new Object();
private static SSLSocketFactory trustAllFactory;
static {
// Froyo's HttpURLConnection reuses dead pooled sockets; turn pooling off.
System.setProperty("http.keepAlive", "false");
}
private Api() {
}
// ------------------------------------------------------------------ plumbing
static HttpURLConnection open(Context c, String url, String method) throws IOException {
HttpURLConnection conn = (HttpURLConnection) new URL(url).openConnection();
conn.setConnectTimeout(15000);
conn.setReadTimeout(30000);
conn.setUseCaches(false);
conn.setInstanceFollowRedirects(true);
if (conn instanceof HttpsURLConnection && Prefs.get(c).trustAll()) trustAll((HttpsURLConnection) conn);
setMethod(conn, method);
conn.setRequestProperty("User-Agent", "MiniABS/" + VERSION + " (Android " + Build.VERSION.RELEASE + ")");
return conn;
}
private static void setMethod(HttpURLConnection conn, String method) throws ProtocolException {
conn.setRequestMethod(method);
}
private static synchronized SSLSocketFactory trustAllFactory() throws Exception {
if (trustAllFactory == null) {
TrustManager tm = new X509TrustManager() {
public void checkClientTrusted(X509Certificate[] chain, String type) {
}
public void checkServerTrusted(X509Certificate[] chain, String type) {
}
public X509Certificate[] getAcceptedIssuers() {
return new X509Certificate[0];
}
};
SSLContext ctx = SSLContext.getInstance("TLS");
ctx.init(null, new TrustManager[]{tm}, null);
trustAllFactory = ctx.getSocketFactory();
}
return trustAllFactory;
}
private static void trustAll(HttpsURLConnection conn) {
try {
conn.setSSLSocketFactory(trustAllFactory());
conn.setHostnameVerifier(new HostnameVerifier() {
public boolean verify(String host, SSLSession session) {
return true;
}
});
} catch (Exception ignored) {
}
}
/**
* PATCH over a plain socket. Android 2.x's HttpURLConnection only sends a body with POST or PUT,
* and Audiobookshelf uses PATCH for "mark as played" and for saving progress without a session.
*/
private static Object rawPatch(Context c, String path, JSONObject body, boolean mayRefresh) throws IOException {
Prefs p = Prefs.get(c);
if (mayRefresh) ensureFresh(c, 120);
String used = p.token();
URL u = new URL(p.server() + path);
boolean https = "https".equalsIgnoreCase(u.getProtocol());
int port = u.getPort() > 0 ? u.getPort() : (https ? 443 : 80);
Socket sock = new Socket();
int code;
try {
sock.connect(new InetSocketAddress(u.getHost(), port), 15000);
sock.setSoTimeout(30000);
Socket io = sock;
if (https) {
boolean trust = p.trustAll();
SSLSocketFactory f;
try {
f = trust ? trustAllFactory() : (SSLSocketFactory) SSLSocketFactory.getDefault();
} catch (Exception e) {
throw new SSLException("TLS unavailable");
}
SSLSocket ss = (SSLSocket) f.createSocket(sock, u.getHost(), port, true);
ss.startHandshake();
if (!trust && !HttpsURLConnection.getDefaultHostnameVerifier().verify(u.getHost(), ss.getSession())) {
ss.close();
throw new SSLException("Certificate doesn't match " + u.getHost());
}
io = ss;
}
byte[] b = (body == null ? "{}" : body.toString()).getBytes("UTF-8");
String host = u.getHost() + (u.getPort() > 0 ? ":" + u.getPort() : "");
String head = "PATCH " + u.getFile() + " HTTP/1.1\r\n"
+ "Host: " + host + "\r\n"
+ "Authorization: Bearer " + used + "\r\n"
+ "Content-Type: application/json\r\n"
+ "Accept: application/json\r\n"
+ "User-Agent: MiniABS/" + VERSION + "\r\n"
+ "Content-Length: " + b.length + "\r\n"
+ "Connection: close\r\n\r\n";
OutputStream os = io.getOutputStream();
os.write(head.getBytes("UTF-8"));
os.write(b);
os.flush();
InputStream in = io.getInputStream();
StringBuilder line = new StringBuilder();
int ch;
while ((ch = in.read()) != -1 && ch != '\n' && line.length() < 200) line.append((char) ch);
String[] parts = line.toString().trim().split(" ");
if (parts.length < 2 || !parts[0].startsWith("HTTP/")) throw new IOException("Unexpected reply from server");
try {
code = Integer.parseInt(parts[1]);
} catch (NumberFormatException e) {
throw new IOException("Unexpected reply from server");
}
byte[] rest = new byte[1024];
int total = 0, n;
while (total < 16384 && (n = in.read(rest)) > 0) total += n; // read the reply politely
io.close();
} finally {
try {
sock.close();
} catch (IOException ignored) {
}
}
if (code == 401) {
if (mayRefresh && p.canRefresh()) {
refreshIf(c, used, 0);
return rawPatch(c, path, body, false);
}
throw new AuthException("Your sign-in has expired. Please log in again.");
}
if (code >= 400) throw new ApiException(code, "");
if (code >= 300) throw new ApiException(code, "The server redirected; check the address in Settings.");
return "OK";
}
/** Old Android throws instead of returning 401 when the server sends no WWW-Authenticate header. */
static int responseCode(HttpURLConnection conn) throws IOException {
try {
return conn.getResponseCode();
} catch (IOException e) {
String m = e.getMessage();
if (m != null && m.toLowerCase().contains("authentication challenge")) return 401;
throw e;
}
}
private static void writeBody(HttpURLConnection conn, JSONObject body) throws IOException {
byte[] b = (body == null ? "{}" : body.toString()).getBytes("UTF-8");
conn.setDoOutput(true);
conn.setRequestProperty("Content-Type", "application/json");
conn.setFixedLengthStreamingMode(b.length);
OutputStream os = conn.getOutputStream();
try {
os.write(b);
} finally {
os.close();
}
}
private static Object readBody(HttpURLConnection conn, int maxString, String[] deepSkip) throws IOException {
InputStream is = new BufferedInputStream(conn.getInputStream(), 8192);
try {
String type = conn.getContentType();
Reader r = new InputStreamReader(is, "UTF-8");
if (type != null && type.contains("json")) return Json.parse(r, maxString, deepSkip);
StringBuilder sb = new StringBuilder();
char[] b = new char[512];
int n;
while ((n = r.read(b)) > 0 && sb.length() < 4000) sb.append(b, 0, n);
return sb.toString();
} finally {
is.close();
}
}
private static String errorText(HttpURLConnection conn) {
try {
InputStream es = conn.getErrorStream();
if (es == null) return "";
Reader r = new InputStreamReader(es, "UTF-8");
StringBuilder sb = new StringBuilder();
char[] b = new char[256];
int n;
while ((n = r.read(b)) > 0 && sb.length() < 200) sb.append(b, 0, n);
es.close();
String t = sb.toString().trim();
if (t.startsWith("<")) return ""; // an HTML error page is no use on a tiny screen
return t.length() > 120 ? t.substring(0, 120) : t;
} catch (Exception e) {
return "";
}
}
static Object call(Context c, String method, String path, JSONObject body) throws IOException {
return call(c, method, path, body, 2000, null, true);
}
static Object call(Context c, String method, String path, JSONObject body, int maxString, String[] deepSkip,
boolean mayRefresh) throws IOException {
Prefs p = Prefs.get(c);
if (!p.loggedIn()) throw new AuthException("Please log in.");
if ("PATCH".equals(method)) return rawPatch(c, path, body, mayRefresh);
if (mayRefresh) ensureFresh(c, 120);
String used = p.token();
HttpURLConnection conn = open(c, p.server() + path, method);
try {
conn.setRequestProperty("Accept", "application/json");
conn.setRequestProperty("Authorization", "Bearer " + used);
if (body != null || "POST".equals(method)) writeBody(conn, body);
int code = responseCode(conn);
if (code == 401) {
conn.disconnect();
if (mayRefresh && p.canRefresh()) {
refreshIf(c, used, 0);
return call(c, method, path, body, maxString, deepSkip, false);
}
throw new AuthException("Your sign-in has expired. Please log in again.");
}
if (code >= 400) throw new ApiException(code, errorText(conn));
return readBody(conn, maxString, deepSkip);
} finally {
conn.disconnect();
}
}
static JSONObject obj(Object o) throws IOException {
if (o instanceof JSONObject) return (JSONObject) o;
throw new IOException("Unexpected reply from server");
}
// ------------------------------------------------------------------ sign-in
/** Checks the address points at an Audiobookshelf server. Returns its version, or "" if unknown. */
static String status(Context c, String server) throws IOException {
HttpURLConnection conn = open(c, server + "/status", "GET");
try {
int code = responseCode(conn);
if (code == 404) throw new IOException("No Audiobookshelf server at this address.");
if (code >= 400) throw new ApiException(code, errorText(conn));
Object o = readBody(conn, 200, null);
if (!(o instanceof JSONObject)) throw new IOException("No Audiobookshelf server at this address.");
JSONObject s = (JSONObject) o;
if (s.has("isInit") && !s.optBoolean("isInit", true)) {
throw new IOException("This server hasn't been set up yet. Finish setup in a browser first.");
}
return Util.str(s, "serverVersion");
} finally {
conn.disconnect();
}
}
/** Logs in with username and password. Returns the user's default library id (may be empty). */
static String login(Context c, String server, String user, String pass) throws IOException {
HttpURLConnection conn = open(c, server + "/login", "POST");
try {
conn.setRequestProperty("Accept", "application/json");
conn.setRequestProperty("x-return-tokens", "true");
JSONObject body = new JSONObject();
try {
body.put("username", user).put("password", pass);
} catch (JSONException ignored) {
}
writeBody(conn, body);
int code = responseCode(conn);
if (code == 401) throw new ApiException(401, "Wrong username or password.");
if (code == 429) throw new ApiException(429, "Too many attempts. Wait a minute and try again.");
if (code >= 400) throw new ApiException(code, errorText(conn));
JSONObject o = obj(readBody(conn, 500, null));
JSONObject u = o.optJSONObject("user");
if (u == null) throw new IOException("Unexpected reply from server");
String access = Util.str(u, "accessToken");
String refresh = Util.str(u, "refreshToken");
String legacy = Util.str(u, "token");
Prefs p = Prefs.get(c);
if (access.length() > 0) p.setAuth(user, Prefs.MODE_JWT, access, refresh);
else if (legacy.length() > 0) p.setAuth(user, Prefs.MODE_LEGACY, legacy, "");
else throw new IOException("The server didn't return a sign-in token.");
return Util.str(o, "userDefaultLibraryId");
} finally {
conn.disconnect();
}
}
/** Signs in with an API key by checking it against /api/me. Returns the username. */
static String loginWithKey(Context c, String key) throws IOException {
Prefs p = Prefs.get(c);
p.setAuth("", Prefs.MODE_KEY, key, "");
try {
JSONObject me = obj(call(c, "GET", "/api/me", null, 100, null, false));
String name = Util.str(me, "username");
p.setAuth(name, Prefs.MODE_KEY, key, "");
return name;
} catch (IOException e) {
p.logout();
if (e instanceof AuthException) throw new ApiException(401, "That API key wasn't accepted.");
throw e;
}
}
/** Refreshes the access token if it has less than minSeconds left. */
static void ensureFresh(Context c, long minSeconds) throws IOException {
Prefs p = Prefs.get(c);
if (!p.canRefresh()) return;
if (p.tokenSecondsLeft() >= minSeconds) return;
refreshIf(c, null, minSeconds);
}
/** Gets a new access token now (after a 401, or before reconnecting a stream). */
static void refresh(Context c, boolean force) throws IOException {
refreshIf(c, force ? Prefs.get(c).token() : null, 120);
}
/**
* stale: the token the server just rejected. If another thread has already replaced it,
* there's nothing to do. With no stale token, refreshes only if fewer than minSeconds are left.
*/
private static void refreshIf(Context c, String stale, long minSeconds) throws IOException {
synchronized (REFRESH_LOCK) {
Prefs p = Prefs.get(c);
if (!p.canRefresh()) throw new AuthException("Your sign-in has expired. Please log in again.");
if (stale != null) {
if (!stale.equals(p.token())) return;
} else if (p.tokenSecondsLeft() >= minSeconds) {
return;
}
HttpURLConnection conn = open(c, p.server() + "/auth/refresh", "POST");
try {
conn.setRequestProperty("Accept", "application/json");
conn.setRequestProperty("x-refresh-token", p.refreshToken());
writeBody(conn, null);
int code = responseCode(conn);
if (code == 401 || code == 403) {
throw new AuthException("Your sign-in has expired. Please log in again.");
}
if (code >= 400) throw new ApiException(code, errorText(conn));
JSONObject u = obj(readBody(conn, 500, null)).optJSONObject("user");
String access = u == null ? "" : Util.str(u, "accessToken");
if (access.length() == 0) throw new AuthException("Your sign-in has expired. Please log in again.");
p.setTokens(access, Util.str(u, "refreshToken"));
} finally {
conn.disconnect();
}
}
}
// ------------------------------------------------------------------ URLs
static String enc(String s) {
try {
return URLEncoder.encode(s, "UTF-8");
} catch (Exception e) {
return s;
}
}
/** Appends the access token as a query parameter (MediaPlayer on Android 2.x can't send headers). */
static String withToken(Context c, String url) {
return url + (url.indexOf('?') >= 0 ? "&" : "?") + "token=" + enc(Prefs.get(c).token());
}
/** Turns a server-relative content URL into a full URL, handling servers hosted under a sub-path. */
static String absolute(Context c, String contentUrl) {
if (contentUrl.startsWith("http://") || contentUrl.startsWith("https://")) return contentUrl;
String server = Prefs.get(c).server();
try {
URL u = new URL(server);
String base = u.getPath();
if (base.length() > 1 && contentUrl.startsWith(base + "/")) {
String origin = u.getProtocol() + "://" + u.getHost() + (u.getPort() > 0 ? ":" + u.getPort() : "");
return origin + contentUrl;
}
} catch (Exception ignored) {
}
return server + (contentUrl.startsWith("/") ? "" : "/") + contentUrl;
}
static String coverUrl(Context c, String itemId, int width) {
return withToken(c, Prefs.get(c).server() + "/api/items/" + itemId + "/cover?format=jpeg&width=" + width);
}
static String[] downloadUrls(Context c, Episode e) {
String base = Prefs.get(c).server() + "/api/items/" + e.itemId + "/file/" + e.ino;
return new String[]{withToken(c, base + "/download"), withToken(c, base)};
}
// ------------------------------------------------------------------ data
static JSONArray libraries(Context c) throws IOException {
Object o = call(c, "GET", "/api/libraries", null);
if (o instanceof JSONArray) return (JSONArray) o;
JSONArray a = obj(o).optJSONArray("libraries");
return a == null ? new JSONArray() : a;
}
static JSONObject libraryItems(Context c, String lib, int page, int limit) throws IOException {
return obj(call(c, "GET", "/api/libraries/" + lib + "/items?minified=1&sort=media.metadata.title&limit="
+ limit + "&page=" + page, null, 300, null, true));
}
static JSONObject recentEpisodes(Context c, String lib, int page, int limit) throws IOException {
return obj(call(c, "GET", "/api/libraries/" + lib + "/recent-episodes?limit=" + limit + "&page=" + page,
null, 1200, new String[]{"episodes", "audioFiles", "tracks"}, true));
}
static JSONObject itemsInProgress(Context c) throws IOException {
return obj(call(c, "GET", "/api/me/items-in-progress?limit=15", null, 1200,
new String[]{"episodes"}, true));
}
/** A podcast with all its episodes. Show notes are cut to keep memory low. */
static JSONObject item(Context c, String itemId) throws IOException {
return obj(call(c, "GET", "/api/items/" + itemId, null, 800, null, true));
}
static JSONObject me(Context c) throws IOException {
return obj(call(c, "GET", "/api/me", null, 200, null, true));
}
private static JSONObject deviceInfo(Context c) throws JSONException {
return new JSONObject()
.put("deviceId", Prefs.get(c).deviceId())
.put("clientName", "MiniABS")
.put("clientVersion", VERSION)
.put("manufacturer", Build.MANUFACTURER)
.put("model", Build.MODEL)
.put("sdkVersion", Build.VERSION.SDK_INT);
}
/** Opens a playback session. Asks for direct play: Android 2.x can't play HLS transcodes. */
static JSONObject startPlayback(Context c, Episode e) throws IOException {
JSONObject body = new JSONObject();
try {
JSONArray mimes = new JSONArray();
String[] ok = {"audio/mpeg", "audio/mp3", "audio/mp4", "audio/x-m4a", "audio/aac", "audio/x-aac",
"audio/ogg", "audio/wav", "audio/x-wav", "audio/3gpp", "audio/amr"};
for (String m : ok) mimes.put(m);
body.put("deviceInfo", deviceInfo(c))
.put("forceDirectPlay", true)
.put("forceTranscode", false)
.put("supportedMimeTypes", mimes)
.put("mediaPlayer", "MiniABS");
} catch (JSONException ignored) {
}
return obj(call(c, "POST", "/api/items/" + e.itemId + "/play/" + e.id, body, 200,
new String[]{"episodes"}, true));
}
private static JSONObject syncBody(double cur, double listened, double dur) {
JSONObject b = new JSONObject();
try {
b.put("currentTime", cur).put("timeListened", listened);
if (dur > 0) b.put("duration", dur);
} catch (JSONException ignored) {
}
return b;
}
static void syncSession(Context c, String sid, double cur, double listened, double dur) throws IOException {
call(c, "POST", "/api/session/" + sid + "/sync", syncBody(cur, listened, dur));
}
static void closeSession(Context c, String sid, double cur, double listened, double dur) throws IOException {
call(c, "POST", "/api/session/" + sid + "/close", syncBody(cur, listened, dur));
}
/** Saves progress without a session (offline catch-up, mark played/unplayed). */
static void setProgress(Context c, String itemId, String epId, double cur, double dur, Boolean finished)
throws IOException {
JSONObject b = new JSONObject();
try {
if (cur >= 0) {
b.put("currentTime", cur);
if (dur > 0) {
b.put("duration", dur);
b.put("progress", Math.min(1.0, cur / dur));
}
}
if (finished != null) b.put("isFinished", finished.booleanValue());
} catch (JSONException ignored) {
}
call(c, "PATCH", "/api/me/progress/" + itemId + "/" + epId, b);
}
}
+134
View File
@@ -0,0 +1,134 @@
package com.miniabs;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.json.JSONArray;
import org.json.JSONObject;
import android.content.Context;
import android.content.SharedPreferences;
/**
* Shared in-memory state: listening progress from the server, plus positions
* saved on the phone that still have to reach the server (offline listening).
*/
final class App {
private App() {
}
/** episodeId -> {currentTime, duration, finished (0/1)} */
private static final HashMap<String, double[]> progress = new HashMap<String, double[]>();
private static long progressLoadedAt;
static synchronized double[] progress(String epId) {
return progress.get(epId);
}
static synchronized void setProgress(String epId, double cur, double dur, boolean finished) {
progress.put(epId, new double[]{cur, dur, finished ? 1 : 0});
}
static synchronized boolean progressFresh() {
return System.currentTimeMillis() - progressLoadedAt < 60000;
}
/** Forces the next screen to re-read progress from the server, keeping what we have meanwhile. */
static synchronized void invalidate() {
progressLoadedAt = 0;
}
static synchronized void clear() {
progress.clear();
progressLoadedAt = 0;
}
/** Pulls the user's progress for every episode they've touched. */
static void loadProgress(Context c) throws Exception {
JSONObject me = Api.me(c);
JSONArray list = me.optJSONArray("mediaProgress");
HashMap<String, double[]> fresh = new HashMap<String, double[]>();
if (list != null) {
for (int i = 0; i < list.length(); i++) {
JSONObject p = list.optJSONObject(i);
if (p == null) continue;
String ep = Util.str(p, "episodeId");
if (ep.length() == 0) continue;
fresh.put(ep, new double[]{p.optDouble("currentTime", 0), p.optDouble("duration", 0),
p.optBoolean("isFinished", false) ? 1 : 0});
}
}
// Positions saved offline are newer than what the server knows.
SharedPreferences sp = local(c);
for (Map.Entry<String, ?> e : sp.getAll().entrySet()) {
String[] v = String.valueOf(e.getValue()).split("\\|");
if (v.length >= 4 && "1".equals(v[3])) {
fresh.put(e.getKey(), new double[]{parse(v[1]), parse(v[2]), 0});
}
}
synchronized (App.class) {
progress.clear();
progress.putAll(fresh);
progressLoadedAt = System.currentTimeMillis();
}
}
// ------------------------------------------------------------- local positions
private static SharedPreferences local(Context c) {
return c.getApplicationContext().getSharedPreferences("positions", Context.MODE_PRIVATE);
}
private static double parse(String s) {
try {
return Double.parseDouble(s);
} catch (Exception e) {
return 0;
}
}
/** Remembers a position on the phone. pending = not yet confirmed by the server. */
static void saveLocal(Context c, Episode e, double cur, double dur, boolean pending) {
local(c).edit().putString(e.id, e.itemId + "|" + cur + "|" + dur + "|" + (pending ? "1" : "0")).commit();
}
static double localPosition(Context c, String epId) {
String[] v = local(c).getString(epId, "").split("\\|");
return v.length >= 2 ? parse(v[1]) : -1;
}
static boolean localPending(Context c, String epId) {
String[] v = local(c).getString(epId, "").split("\\|");
return v.length >= 4 && "1".equals(v[3]);
}
static void forgetLocal(Context c, String epId) {
local(c).edit().remove(epId).commit();
}
/** Sends positions saved while offline. Returns how many reached the server. */
static int flushPending(Context c) {
SharedPreferences sp = local(c);
List<String[]> todo = new ArrayList<String[]>();
for (Map.Entry<String, ?> e : sp.getAll().entrySet()) {
String[] v = String.valueOf(e.getValue()).split("\\|");
if (v.length >= 4 && "1".equals(v[3])) todo.add(new String[]{e.getKey(), v[0], v[1], v[2]});
}
int sent = 0;
for (String[] t : todo) {
try {
double cur = parse(t[2]), dur = parse(t[3]);
boolean done = dur > 0 && cur >= dur - 1; // "played" marked while offline
Api.setProgress(c, t[1], t[0], cur, dur, done ? Boolean.TRUE : null);
// keep the position for downloaded files, but mark it as synced
sp.edit().putString(t[0], t[1] + "|" + cur + "|" + dur + "|0").commit();
sent++;
} catch (Exception ex) {
break; // still offline; try again later
}
}
return sent;
}
}
+237
View File
@@ -0,0 +1,237 @@
package com.miniabs;
import android.app.Activity;
import android.app.AlertDialog;
import android.content.DialogInterface;
import android.content.Intent;
import android.os.Handler;
import android.view.ContextMenu;
import android.view.MenuItem;
import android.view.View;
import android.widget.ImageButton;
import android.widget.ProgressBar;
import android.widget.TextView;
import android.widget.Toast;
/** Shared bits: the mini player bar, a 1-second UI tick, sign-in errors and episode actions. */
abstract class BaseActivity extends Activity {
protected final Handler handler = new Handler();
private boolean ticking;
private View mini;
private TextView miniTitle, miniSub;
private ImageButton miniPlay;
private ProgressBar miniProgress;
private final Runnable ticker = new Runnable() {
public void run() {
onTick();
if (ticking) handler.postDelayed(this, 1000);
}
};
@Override
protected void onResume() {
super.onResume();
ticking = true;
handler.post(ticker);
}
@Override
protected void onPause() {
ticking = false;
handler.removeCallbacks(ticker);
super.onPause();
}
protected void onTick() {
updateMiniBar();
}
protected void toast(String s) {
Toast.makeText(this, s, s.length() > 60 ? Toast.LENGTH_LONG : Toast.LENGTH_SHORT).show();
}
/** Shows an error; sends the user to the login screen if their sign-in expired. */
protected void showError(Exception e) {
if (e instanceof Api.AuthException) {
Prefs.get(this).logout();
App.clear();
HomeActivity.clearCache();
startActivity(new Intent(this, LoginActivity.class)
.putExtra("message", e.getMessage())
.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP));
finish();
return;
}
toast(Util.describe(e));
}
// ------------------------------------------------------------ mini player
protected void setupMiniBar() {
mini = findViewById(R.id.mini_bar);
if (mini == null) return;
miniTitle = (TextView) findViewById(R.id.mini_title);
miniSub = (TextView) findViewById(R.id.mini_sub);
miniPlay = (ImageButton) findViewById(R.id.mini_play);
miniProgress = (ProgressBar) findViewById(R.id.mini_progress);
findViewById(R.id.mini_info).setOnClickListener(new View.OnClickListener() {
public void onClick(View v) {
startActivity(new Intent(BaseActivity.this, PlayerActivity.class));
}
});
miniPlay.setOnClickListener(new View.OnClickListener() {
public void onClick(View v) {
PlayerService ps = PlayerService.get();
if (ps != null && ps.episode() != null) {
PlayerService.send(BaseActivity.this, PlayerService.A_TOGGLE);
} else {
Episode last = Prefs.get(BaseActivity.this).lastEpisode();
if (last != null) PlayerService.play(BaseActivity.this, last);
}
handler.postDelayed(new Runnable() {
public void run() {
updateMiniBar();
}
}, 300);
}
});
}
protected void updateMiniBar() {
if (mini == null) return;
PlayerService ps = PlayerService.get();
Episode e = ps != null ? ps.episode() : null;
if (e == null) e = Prefs.get(this).lastEpisode();
if (e == null) {
mini.setVisibility(View.GONE);
return;
}
mini.setVisibility(View.VISIBLE);
miniTitle.setText(e.title);
boolean playing = ps != null && ps.episode() != null && ps.isPlaying();
miniPlay.setImageResource(playing ? R.drawable.ic_pause_small : R.drawable.ic_play_small);
int pos, dur;
String sub;
if (ps != null && ps.episode() != null) {
pos = ps.positionMs();
dur = ps.durationMs();
int st = ps.state();
if (st == PlayerService.LOADING || st == PlayerService.ERROR) sub = ps.status();
else sub = Util.join(e.show, dur > 0 ? Util.clock(dur - pos) + " left" : "");
} else {
double[] p = App.progress(e.id);
double cur = p != null ? p[0] : Math.max(0, App.localPosition(this, e.id));
pos = (int) (cur * 1000);
dur = (int) (e.duration * 1000);
sub = Util.join(e.show, "Paused");
}
miniSub.setText(sub);
miniProgress.setProgress(dur > 0 ? (int) (1000L * pos / dur) : 0);
}
// ------------------------------------------------------------ episode actions
protected static final int M_PLAY = 1, M_DOWNLOAD = 2, M_DELETE = 3, M_PLAYED = 4, M_UNPLAYED = 5,
M_SHOW = 6, M_CANCEL = 7;
protected void episodeMenu(ContextMenu menu, Episode e, boolean offerShow) {
menu.setHeaderTitle(e.title);
menu.add(0, M_PLAY, 0, "Play");
if (DownloadService.busyWith(e.id)) menu.add(0, M_CANCEL, 0, "Cancel download");
else if (Downloads.has(this, e.id)) menu.add(0, M_DELETE, 0, "Delete download");
else menu.add(0, M_DOWNLOAD, 0, "Download");
double[] p = App.progress(e.id);
if (p != null && p[2] > 0) menu.add(0, M_UNPLAYED, 0, "Mark as unplayed");
else menu.add(0, M_PLAYED, 0, "Mark as played");
if (offerShow) menu.add(0, M_SHOW, 0, "Go to show");
}
/** Returns true if handled. onChanged runs on the UI thread after the list should refresh. */
protected boolean episodeAction(int id, final Episode e, final Runnable onChanged) {
switch (id) {
case M_PLAY:
playEpisode(e);
return true;
case M_DOWNLOAD:
if (Prefs.get(this).wifiOnly() && !Util.onWifi(this)) {
new AlertDialog.Builder(this)
.setMessage("You're not on Wi-Fi. Download over mobile data this time?")
.setPositiveButton("Download", new DialogInterface.OnClickListener() {
public void onClick(DialogInterface d, int w) {
Prefs.get(BaseActivity.this).sp.edit().putBoolean("wifi_only", false).commit();
DownloadService.enqueue(BaseActivity.this, e);
toast("Downloading. Wi-Fi-only is now off (change it in Settings).");
if (onChanged != null) onChanged.run();
}
})
.setNegativeButton("Cancel", null)
.show();
} else {
DownloadService.enqueue(this, e);
toast("Downloading…");
}
if (onChanged != null) onChanged.run();
return true;
case M_CANCEL:
DownloadService.cancel(e.id);
toast("Download cancelled");
if (onChanged != null) onChanged.run();
return true;
case M_DELETE:
PlayerService ps = PlayerService.get();
if (ps != null && ps.episode() != null && ps.episode().id.equals(e.id) && ps.isLocal()) {
ps.stop();
}
Downloads.delete(this, e);
toast("Download deleted");
if (onChanged != null) onChanged.run();
return true;
case M_PLAYED:
case M_UNPLAYED:
setPlayed(e, id == M_PLAYED, onChanged);
return true;
case M_SHOW:
startActivity(new Intent(this, PodcastActivity.class)
.putExtra("itemId", e.itemId).putExtra("title", e.show));
return true;
default:
return false;
}
}
protected void playEpisode(Episode e) {
PlayerService.play(this, e);
startActivity(new Intent(this, PlayerActivity.class).putExtra("ep", e.toJson().toString()));
}
private void setPlayed(final Episode e, final boolean played, final Runnable onChanged) {
final double dur = e.duration;
final double[] before = App.progress(e.id);
App.setProgress(e.id, played ? dur : 0, dur, played);
if (onChanged != null) onChanged.run();
new Job<Void>() {
Void work() throws Exception {
Api.setProgress(BaseActivity.this, e.itemId, e.id, played ? dur : 0, dur, Boolean.valueOf(played));
App.forgetLocal(BaseActivity.this, e.id);
return null;
}
void done(Void r) {
toast(played ? "Marked as played" : "Marked as unplayed");
}
void failed(Exception ex) {
if (before != null) App.setProgress(e.id, before[0], before[1], before[2] > 0);
else App.setProgress(e.id, 0, dur, false);
if (onChanged != null) onChanged.run();
showError(ex);
}
}.start();
}
protected boolean onEpisodeContextItem(MenuItem item, Episode e, Runnable onChanged) {
return e != null && episodeAction(item.getItemId(), e, onChanged);
}
}
+119
View File
@@ -0,0 +1,119 @@
package com.miniabs;
import java.io.File;
import java.io.FileOutputStream;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import android.content.Context;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.os.Handler;
import android.widget.ImageView;
/** Loads small JPEG covers with a tiny memory cache and a disk cache (saves data on repeat visits). */
final class Covers {
private Covers() {
}
private static final int MAX_ENTRIES = 30;
private static final Handler ui = new Handler(android.os.Looper.getMainLooper());
private static final ExecutorService pool = Executors.newFixedThreadPool(2);
private static final Map<String, Bitmap> cache = new LinkedHashMap<String, Bitmap>(16, 0.75f, true) {
@Override
protected boolean removeEldestEntry(Map.Entry<String, Bitmap> eldest) {
return size() > MAX_ENTRIES;
}
};
/** px: size in physical pixels (covers are requested at roughly that size). */
static void load(final Context c, final ImageView view, final String itemId, int px) {
if (!Prefs.get(c).covers() || itemId == null || itemId.length() == 0) {
view.setImageDrawable(null);
view.setVisibility(android.view.View.GONE);
return;
}
view.setVisibility(android.view.View.VISIBLE);
final int size = px <= 64 ? 64 : 128;
final String key = itemId + "_" + size;
view.setTag(key);
Bitmap b;
synchronized (cache) {
b = cache.get(key);
}
if (b != null) {
view.setImageBitmap(b);
return;
}
view.setImageDrawable(null);
final Context app = c.getApplicationContext();
pool.execute(new Runnable() {
public void run() {
final Bitmap bmp = fetch(app, itemId, size, key);
if (bmp == null) return;
synchronized (cache) {
cache.put(key, bmp);
}
ui.post(new Runnable() {
public void run() {
if (key.equals(view.getTag())) view.setImageBitmap(bmp);
}
});
}
});
}
private static Bitmap fetch(Context c, String itemId, int size, String key) {
File dir = new File(c.getCacheDir(), "covers");
File f = new File(dir, key + ".jpg");
try {
if (!f.exists()) {
if (!Util.online(c)) return null;
dir.mkdirs();
HttpURLConnection conn = Api.open(c, Api.coverUrl(c, itemId, size), "GET");
try {
if (Api.responseCode(conn) != 200) return null;
File tmp = new File(dir, key + ".tmp");
InputStream in = conn.getInputStream();
OutputStream out = new FileOutputStream(tmp);
try {
byte[] buf = new byte[4096];
int n;
while ((n = in.read(buf)) > 0) out.write(buf, 0, n);
} finally {
out.close();
in.close();
}
tmp.renameTo(f);
} finally {
conn.disconnect();
}
}
BitmapFactory.Options o = new BitmapFactory.Options();
o.inJustDecodeBounds = true;
BitmapFactory.decodeFile(f.getPath(), o);
int sample = 1;
while (o.outWidth / (sample * 2) >= size) sample *= 2;
o = new BitmapFactory.Options();
o.inSampleSize = sample;
o.inPreferredConfig = Bitmap.Config.RGB_565;
Bitmap b = BitmapFactory.decodeFile(f.getPath(), o);
if (b == null) f.delete();
return b;
} catch (Throwable t) { // includes OutOfMemoryError on very small heaps
return null;
}
}
static void clearMemory() {
synchronized (cache) {
cache.clear();
}
}
}
+230
View File
@@ -0,0 +1,230 @@
package com.miniabs;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.util.ArrayList;
import java.util.List;
import org.json.JSONObject;
import android.app.IntentService;
import android.app.Notification;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.content.Context;
import android.content.Intent;
import android.net.wifi.WifiManager;
import android.os.PowerManager;
/** Downloads episodes one at a time to the SD card, resuming partial files. */
public class DownloadService extends IntentService {
private static final int NOTIF_ID = 2;
private static final List<Episode> queue = new ArrayList<Episode>();
static volatile Episode current;
static volatile int percent;
static volatile String lastError;
private static volatile String cancelId;
public DownloadService() {
super("MiniABS downloads");
}
static void enqueue(Context c, Episode e) {
synchronized (queue) {
if (current != null && current.id.equals(e.id)) return;
for (Episode q : queue) if (q.id.equals(e.id)) return;
queue.add(e);
}
lastError = null;
c.startService(new Intent(c, DownloadService.class).putExtra("ep", e.toJson().toString()));
}
static void cancel(String epId) {
synchronized (queue) {
for (int i = queue.size() - 1; i >= 0; i--) if (queue.get(i).id.equals(epId)) queue.remove(i);
}
Episode cur = current;
if (cur != null && cur.id.equals(epId)) cancelId = epId;
}
static List<Episode> queued() {
synchronized (queue) {
return new ArrayList<Episode>(queue);
}
}
static boolean busyWith(String epId) {
Episode cur = current;
if (cur != null && cur.id.equals(epId)) return true;
synchronized (queue) {
for (Episode q : queue) if (q.id.equals(epId)) return true;
}
return false;
}
@Override
protected void onHandleIntent(Intent intent) {
Episode e;
try {
e = Episode.fromStored(new JSONObject(intent.getStringExtra("ep")));
} catch (Exception ex) {
return;
}
synchronized (queue) {
boolean found = false;
for (int i = 0; i < queue.size(); i++) {
if (queue.get(i).id.equals(e.id)) {
queue.remove(i);
found = true;
break;
}
}
if (!found) return; // cancelled while waiting
current = e;
}
percent = 0;
cancelId = null;
PowerManager.WakeLock wake = ((PowerManager) getSystemService(POWER_SERVICE))
.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "MiniABS download");
WifiManager.WifiLock wifi = ((WifiManager) getSystemService(WIFI_SERVICE))
.createWifiLock(WifiManager.WIFI_MODE_FULL, "MiniABS download");
wake.acquire();
wifi.acquire();
try {
download(e);
if (e.id.equals(cancelId)) {
Downloads.delete(this, e);
}
} catch (Exception ex) {
if (!e.id.equals(cancelId)) {
lastError = "Download failed: " + Util.describe(ex);
notifyDone(e.title, lastError);
}
} finally {
current = null;
cancelId = null;
stopForeground(true);
wifi.release();
wake.release();
}
}
private void download(Episode e) throws IOException {
if (Prefs.get(this).wifiOnly() && !Util.onWifi(this)) {
throw new IOException("not on Wi-Fi (Settings: Download on Wi-Fi only)");
}
File dir = Downloads.dir(this);
if (Downloads.audio(this, e) != null) {
Downloads.saved(this, e);
return;
}
if (e.ino.length() == 0) throw new IOException("this episode has no audio file");
File part = new File(dir, e.id + ".part");
Notification n = progressNotification(e, 0);
startForeground(NOTIF_ID, n);
Api.ensureFresh(this, 600);
HttpURLConnection conn = null;
int code = 0;
long have = part.exists() ? part.length() : 0;
boolean refreshed = false;
String[] urls = Api.downloadUrls(this, e);
for (int i = 0; i < urls.length; i++) {
conn = Api.open(this, urls[i], "GET");
conn.setReadTimeout(60000);
if (have > 0) conn.setRequestProperty("Range", "bytes=" + have + "-");
code = Api.responseCode(conn);
if (code == 401 && !refreshed && Prefs.get(this).canRefresh()) {
conn.disconnect();
Api.refresh(this, true);
refreshed = true;
urls = Api.downloadUrls(this, e);
i--;
continue;
}
if (code == 200 || code == 206) break;
conn.disconnect();
if (code == 416 && have > 0) { // stale partial file: start over
part.delete();
have = 0;
i--;
continue;
}
if (code != 404 && code != 403) break;
}
if (code != 200 && code != 206) {
if (code == 401) throw new Api.AuthException("sign-in expired; log in again");
if (code == 403) throw new IOException("your account isn't allowed to download");
throw new Api.ApiException(code, "");
}
try {
boolean append = code == 206 && have > 0;
if (!append) have = 0;
long len = conn.getContentLength();
long total = len > 0 ? have + len : (e.size > 0 ? e.size : -1);
InputStream in = conn.getInputStream();
OutputStream out = new FileOutputStream(part, append);
try {
byte[] buf = new byte[16384];
long done = have, lastUi = 0;
int r;
while ((r = in.read(buf)) > 0) {
if (e.id.equals(cancelId)) return;
out.write(buf, 0, r);
done += r;
long now = System.currentTimeMillis();
if (now - lastUi > 1500) {
lastUi = now;
percent = total > 0 ? (int) (done * 100 / total) : 0;
n = progressNotification(e, percent);
nm().notify(NOTIF_ID, n);
}
}
} finally {
out.close();
in.close();
}
} finally {
conn.disconnect();
}
if (e.id.equals(cancelId)) return;
File dest = new File(dir, e.id + e.ext);
if (!part.renameTo(dest)) throw new IOException("couldn't save the file");
Downloads.saved(this, e);
percent = 100;
}
private NotificationManager nm() {
return (NotificationManager) getSystemService(NOTIFICATION_SERVICE);
}
private PendingIntent openApp() {
Intent i = new Intent(this, HomeActivity.class).putExtra("tab", HomeActivity.TAB_SAVED)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
return PendingIntent.getActivity(this, 1, i, PendingIntent.FLAG_UPDATE_CURRENT);
}
private Notification progressNotification(Episode e, int pct) {
Notification n = new Notification(android.R.drawable.stat_sys_download, null, System.currentTimeMillis());
n.flags |= Notification.FLAG_ONGOING_EVENT | Notification.FLAG_ONLY_ALERT_ONCE;
int left;
synchronized (queue) {
left = queue.size();
}
String text = "Downloading " + pct + "%" + (left > 0 ? " · " + left + " more queued" : "");
n.setLatestEventInfo(this, e.title, text, openApp());
return n;
}
private void notifyDone(String title, String text) {
Notification n = new Notification(android.R.drawable.stat_notify_error, text, System.currentTimeMillis());
n.flags |= Notification.FLAG_AUTO_CANCEL;
n.setLatestEventInfo(this, title, text, openApp());
nm().notify(3, n);
}
}
+143
View File
@@ -0,0 +1,143 @@
package com.miniabs;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.OutputStreamWriter;
import java.io.Reader;
import java.io.Writer;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashSet;
import java.util.List;
import org.json.JSONObject;
import android.content.Context;
import android.os.Environment;
/** Downloaded episodes live on the SD card: <id>.<ext> plus <id>.json with the episode details. */
final class Downloads {
private Downloads() {
}
private static HashSet<String> ids;
static File dir(Context c) throws IOException {
if (!Environment.MEDIA_MOUNTED.equals(Environment.getExternalStorageState())) {
throw new IOException("No SD card available. Insert or unmount it from USB to download.");
}
File d = c.getExternalFilesDir(Environment.DIRECTORY_PODCASTS);
if (d == null) throw new IOException("Can't use the SD card.");
if (!d.exists() && !d.mkdirs()) throw new IOException("Can't create a folder on the SD card.");
File nomedia = new File(d, ".nomedia");
if (!nomedia.exists()) {
try {
nomedia.createNewFile();
} catch (IOException ignored) {
}
}
return d;
}
static File audio(Context c, Episode e) {
try {
File f = new File(dir(c), e.id + e.ext);
return f.exists() && f.length() > 0 ? f : null;
} catch (IOException ex) {
return null;
}
}
static synchronized boolean has(Context c, String epId) {
if (ids == null) scan(c);
return ids.contains(epId);
}
/** Reads the download folder and returns saved episodes, newest first. */
static synchronized List<Episode> list(Context c) {
List<Episode> out = new ArrayList<Episode>();
HashSet<String> found = new HashSet<String>();
try {
File[] files = dir(c).listFiles();
if (files != null) {
for (File f : files) {
if (!f.getName().endsWith(".json")) continue;
try {
Episode e = Episode.fromStored(new JSONObject(readFile(f)));
if (audio(c, e) != null) {
out.add(e);
found.add(e.id);
}
} catch (Exception ignored) {
}
}
}
} catch (IOException ignored) {
}
ids = found;
Collections.sort(out, new Comparator<Episode>() {
public int compare(Episode a, Episode b) {
return a.published == b.published ? 0 : (a.published > b.published ? -1 : 1);
}
});
return out;
}
static synchronized void scan(Context c) {
list(c);
}
static synchronized void saved(Context c, Episode e) throws IOException {
Writer w = new OutputStreamWriter(new FileOutputStream(new File(dir(c), e.id + ".json")), "UTF-8");
try {
w.write(e.toJson().toString());
} finally {
w.close();
}
if (ids == null) scan(c);
ids.add(e.id);
}
static synchronized void delete(Context c, Episode e) {
try {
File d = dir(c);
new File(d, e.id + e.ext).delete();
new File(d, e.id + ".json").delete();
new File(d, e.id + ".part").delete();
} catch (IOException ignored) {
}
if (ids != null) ids.remove(e.id);
App.forgetLocal(c, e.id);
}
static synchronized int deleteAll(Context c) {
int n = 0;
for (Episode e : list(c)) {
delete(c, e);
n++;
}
try {
File[] parts = dir(c).listFiles();
if (parts != null) for (File f : parts) if (f.getName().endsWith(".part")) f.delete();
} catch (IOException ignored) {
}
return n;
}
private static String readFile(File f) throws IOException {
Reader r = new InputStreamReader(new FileInputStream(f), "UTF-8");
try {
StringBuilder sb = new StringBuilder();
char[] b = new char[2048];
int n;
while ((n = r.read(b)) > 0) sb.append(b, 0, n);
return sb.toString();
} finally {
r.close();
}
}
}
+95
View File
@@ -0,0 +1,95 @@
package com.miniabs;
import org.json.JSONException;
import org.json.JSONObject;
/** One podcast episode, trimmed down to what MiniABS needs. */
final class Episode {
String itemId = ""; // library item (the podcast) id
String id = ""; // episode id
String title = "";
String show = ""; // podcast title
String author = "";
String desc = "";
String ino = ""; // audio file inode, used for downloads
String ext = ".mp3";
String mime = "";
long published; // ms since epoch
double duration; // seconds
long size; // bytes
/** Builds an episode from an Audiobookshelf episode object. Returns null if it has no audio. */
static Episode fromApi(JSONObject e, String itemId, String show, String author) {
if (e == null) return null;
Episode ep = new Episode();
ep.id = Util.str(e, "id");
ep.itemId = itemId != null && itemId.length() > 0 ? itemId : Util.str(e, "libraryItemId");
ep.title = Util.str(e, "title");
ep.show = show == null ? "" : show;
ep.author = author == null ? "" : author;
ep.desc = Util.str(e, "description");
if (ep.desc.length() == 0) ep.desc = Util.str(e, "subtitle");
ep.published = e.optLong("publishedAt", 0);
JSONObject af = e.optJSONObject("audioFile");
if (af != null) {
ep.duration = af.optDouble("duration", 0);
ep.ino = Util.str(af, "ino");
ep.mime = Util.str(af, "mimeType");
JSONObject meta = af.optJSONObject("metadata");
if (meta != null) {
String x = Util.str(meta, "ext");
if (x.length() > 0) ep.ext = x;
ep.size = meta.optLong("size", 0);
}
}
if (ep.duration <= 0) ep.duration = e.optDouble("duration", 0);
if (ep.id.length() == 0 || ep.itemId.length() == 0 || af == null) return null;
ep.ext = safeExt(ep.ext);
// The podcast object (when the API sends it) carries the show name
JSONObject pod = e.optJSONObject("podcast");
if (pod != null && ep.show.length() == 0) {
JSONObject md = pod.optJSONObject("metadata");
if (md != null) {
ep.show = Util.str(md, "title");
ep.author = Util.str(md, "author");
}
}
return ep;
}
private static String safeExt(String x) {
x = x.toLowerCase();
if (!x.startsWith(".")) x = "." + x;
if (!x.matches("\\.[a-z0-9]{1,5}")) x = ".mp3";
return x;
}
JSONObject toJson() {
JSONObject o = new JSONObject();
try {
o.put("itemId", itemId).put("id", id).put("title", title).put("show", show)
.put("author", author).put("desc", desc).put("ino", ino).put("ext", ext)
.put("mime", mime).put("published", published).put("duration", duration)
.put("size", size);
} catch (JSONException ignored) {
}
return o;
}
static Episode fromStored(JSONObject o) {
Episode e = new Episode();
e.itemId = Util.str(o, "itemId");
e.id = Util.str(o, "id");
e.title = Util.str(o, "title");
e.show = Util.str(o, "show");
e.author = Util.str(o, "author");
e.desc = Util.str(o, "desc");
e.ino = Util.str(o, "ino");
e.ext = safeExt(o.optString("ext", ".mp3"));
e.mime = Util.str(o, "mime");
e.published = o.optLong("published", 0);
e.duration = o.optDouble("duration", 0);
e.size = o.optLong("size", 0);
return e;
}
}
+506
View File
@@ -0,0 +1,506 @@
package com.miniabs;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import org.json.JSONArray;
import org.json.JSONObject;
import android.app.AlertDialog;
import android.content.DialogInterface;
import android.content.Intent;
import android.os.Bundle;
import android.view.ContextMenu;
import android.view.Menu;
import android.view.MenuItem;
import android.view.View;
import android.widget.AdapterView;
import android.widget.ListView;
import android.widget.ProgressBar;
import android.widget.TextView;
public class HomeActivity extends BaseActivity {
static final int TAB_LATEST = 0, TAB_SHOWS = 1, TAB_SAVED = 2;
private static final int PAGE = 20;
// Kept across screen changes so going back is instant on a slow phone.
private static List<String[]> podcastLibs;
private static List<Rows.Row> latestCache, showsCache;
private static int latestPage;
private static boolean latestMore;
private ListView list;
private TextView empty, library;
private ProgressBar loading;
private Rows adapter;
private TextView[] tabs;
private int tab;
private boolean loadingLatest, loadingShows;
private String latestError, showsError;
private String savedSignature = "";
private int ticks;
static void clearCache() {
podcastLibs = null;
latestCache = null;
showsCache = null;
latestPage = 0;
latestMore = false;
}
@Override
protected void onCreate(Bundle b) {
super.onCreate(b);
if (!Prefs.get(this).loggedIn()) {
startActivity(new Intent(this, LoginActivity.class));
finish();
return;
}
setContentView(R.layout.activity_home);
list = (ListView) findViewById(R.id.list);
empty = (TextView) findViewById(R.id.empty);
loading = (ProgressBar) findViewById(R.id.loading);
library = (TextView) findViewById(R.id.library);
adapter = new Rows(this);
list.setAdapter(adapter);
registerForContextMenu(list);
setupMiniBar();
tabs = new TextView[]{(TextView) findViewById(R.id.tab_latest), (TextView) findViewById(R.id.tab_shows),
(TextView) findViewById(R.id.tab_saved)};
for (int i = 0; i < tabs.length; i++) {
final int t = i;
tabs[i].setOnClickListener(new View.OnClickListener() {
public void onClick(View v) {
selectTab(t);
}
});
}
library.setOnClickListener(new View.OnClickListener() {
public void onClick(View v) {
chooseLibrary();
}
});
findViewById(R.id.refresh).setOnClickListener(new View.OnClickListener() {
public void onClick(View v) {
refresh();
}
});
list.setOnItemClickListener(new AdapterView.OnItemClickListener() {
public void onItemClick(AdapterView<?> parent, View view, int pos, long id) {
onRowClick(adapter.getItem(pos));
}
});
updateLibraryLabel();
int start = getIntent().getIntExtra("tab", Prefs.get(this).sp.getInt("tab", TAB_LATEST));
selectTab(start);
}
@Override
protected void onNewIntent(Intent intent) {
super.onNewIntent(intent);
if (intent.hasExtra("tab")) selectTab(intent.getIntExtra("tab", TAB_LATEST));
}
@Override
protected void onResume() {
super.onResume();
if (adapter == null) return;
if (!Prefs.get(this).loggedIn()) { // logged out from Settings
startActivity(new Intent(this, LoginActivity.class));
finish();
return;
}
updateLibraryLabel();
if (tab == TAB_SAVED) showSaved();
else if ((tab == TAB_LATEST && latestCache == null && !loadingLatest)
|| (tab == TAB_SHOWS && showsCache == null && !loadingShows)) selectTab(tab); // signed in again
else adapter.notifyDataSetChanged();
}
@Override
protected void onTick() {
super.onTick();
if (adapter == null) return;
ticks++;
if (tab == TAB_SAVED) {
Episode cur = DownloadService.current;
String sig = (cur == null ? "" : cur.id) + "/" + DownloadService.queued().size()
+ "/" + DownloadService.lastError;
if (!sig.equals(savedSignature)) showSaved();
else if (cur != null) adapter.notifyDataSetChanged();
} else if (ticks % 3 == 0) {
adapter.notifyDataSetChanged();
}
}
private void updateLibraryLabel() {
String name = Prefs.get(this).libraryName();
library.setText(name.length() > 0 ? name : "");
}
// ------------------------------------------------------------------ tabs
private void selectTab(int t) {
if (t < 0 || t > 2) t = TAB_LATEST;
tab = t;
Prefs.get(this).sp.edit().putInt("tab", t).commit();
for (int i = 0; i < tabs.length; i++) {
tabs[i].setSelected(i == t);
tabs[i].setTextColor(getResources().getColor(i == t ? R.color.text : R.color.sub));
}
adapter.showPodcastName = t != TAB_SHOWS;
if (t == TAB_LATEST) {
if (latestCache != null) show(latestCache, null);
else if (!loadingLatest) loadLatest(false);
else show(null, null);
} else if (t == TAB_SHOWS) {
if (showsCache != null) show(showsCache, null);
else if (!loadingShows) loadShows();
else show(null, null);
} else {
showSaved();
}
list.setSelection(0);
}
/** rows == null means "still loading". */
private void show(List<Rows.Row> rows, String emptyText) {
boolean busy = rows == null && ((tab == TAB_LATEST && loadingLatest) || (tab == TAB_SHOWS && loadingShows));
String err = tab == TAB_LATEST ? latestError : tab == TAB_SHOWS ? showsError : null;
adapter.set(rows == null ? new ArrayList<Rows.Row>() : rows);
loading.setVisibility(busy ? View.VISIBLE : View.GONE);
String msg = null;
if (!busy && (rows == null || rows.isEmpty())) msg = err != null ? err : emptyText;
empty.setText(msg == null ? "" : msg);
empty.setVisibility(msg == null ? View.GONE : View.VISIBLE);
}
private void refresh() {
App.invalidate();
Covers.clearMemory();
latestCache = null;
showsCache = null;
podcastLibs = null;
latestError = null;
showsError = null;
if (tab == TAB_SHOWS) loadShows();
else if (tab == TAB_LATEST) loadLatest(false);
else {
showSaved();
new Job<Integer>() {
Integer work() {
return App.flushPending(HomeActivity.this);
}
void done(Integer n) {
if (n > 0) toast("Synced offline progress");
}
}.start();
}
}
// ------------------------------------------------------------------ data
/** Picks a podcast library (the saved one if it still exists). Runs in the background. */
private String ensureLibrary() throws Exception {
Prefs p = Prefs.get(this);
if (podcastLibs == null) {
JSONArray libs = Api.libraries(this);
List<String[]> found = new ArrayList<String[]>();
for (int i = 0; i < libs.length(); i++) {
JSONObject l = libs.optJSONObject(i);
if (l != null && "podcast".equals(Util.str(l, "mediaType"))) {
found.add(new String[]{Util.str(l, "id"), Util.str(l, "name")});
}
}
if (found.isEmpty()) throw new IOException("This server has no podcast libraries.");
podcastLibs = found;
}
String id = p.libraryId();
for (String[] l : podcastLibs) {
if (l[0].equals(id)) {
if (!l[1].equals(p.libraryName())) p.setLibrary(l[0], l[1]);
return id;
}
}
String[] first = podcastLibs.get(0);
p.setLibrary(first[0], first[1]);
return first[0];
}
private void loadLatest(final boolean more) {
loadingLatest = true;
latestError = null;
if (!more && tab == TAB_LATEST) show(null, null);
final int page = more ? latestPage + 1 : 0;
new Job<List<Rows.Row>>() {
boolean hasMore;
List<Rows.Row> work() throws Exception {
String lib = ensureLibrary();
List<Rows.Row> rows = new ArrayList<Rows.Row>();
if (!more) {
App.flushPending(HomeActivity.this);
App.loadProgress(HomeActivity.this);
List<Rows.Row> cont = new ArrayList<Rows.Row>();
try {
JSONArray items = Api.itemsInProgress(HomeActivity.this).optJSONArray("libraryItems");
for (int i = 0; items != null && i < items.length(); i++) {
JSONObject it = items.optJSONObject(i);
if (it == null || !lib.equals(Util.str(it, "libraryId"))) continue;
JSONObject media = it.optJSONObject("media");
JSONObject md = media == null ? null : media.optJSONObject("metadata");
Episode e = Episode.fromApi(it.optJSONObject("recentEpisode"), Util.str(it, "id"),
Util.str(md, "title"), Util.str(md, "author"));
if (e == null) continue;
double[] p = App.progress(e.id);
if (p != null && p[2] > 0) continue;
cont.add(Rows.Row.episode(e));
if (cont.size() >= 5) break;
}
} catch (Api.AuthException ae) {
throw ae;
} catch (Exception ignored) {
// older servers: just skip this section
}
if (!cont.isEmpty()) {
rows.add(Rows.Row.header("CONTINUE LISTENING"));
rows.addAll(cont);
}
rows.add(Rows.Row.header("LATEST EPISODES"));
}
JSONArray eps = Api.recentEpisodes(HomeActivity.this, lib, page, PAGE).optJSONArray("episodes");
int n = eps == null ? 0 : eps.length();
for (int i = 0; i < n; i++) {
Episode e = Episode.fromApi(eps.optJSONObject(i), null, null, null);
if (e != null) rows.add(Rows.Row.episode(e));
}
hasMore = n >= PAGE;
return rows;
}
void done(List<Rows.Row> rows) {
loadingLatest = false;
if (isFinishing()) return;
updateLibraryLabel();
if (more && latestCache != null) {
int last = latestCache.size() - 1;
if (last >= 0 && latestCache.get(last).type == Rows.MORE) latestCache.remove(last);
latestCache.addAll(rows);
} else {
latestCache = rows;
}
latestPage = page;
latestMore = hasMore;
if (hasMore) latestCache.add(Rows.Row.more("Load more"));
if (tab == TAB_LATEST) show(latestCache, "No episodes yet.");
}
void failed(Exception e) {
loadingLatest = false;
if (isFinishing()) return;
if (e instanceof Api.AuthException) {
showError(e);
return;
}
if (more) {
toast(Util.describe(e));
if (latestCache != null && !latestCache.isEmpty()) {
latestCache.get(latestCache.size() - 1).text = "Load more";
adapter.notifyDataSetChanged();
}
return;
}
latestError = Util.describe(e) + "\n\nTap refresh to try again.";
if (tab == TAB_LATEST) offlineFallback(e);
}
}.start();
}
private void loadShows() {
loadingShows = true;
showsError = null;
if (tab == TAB_SHOWS) show(null, null);
new Job<List<Rows.Row>>() {
List<Rows.Row> work() throws Exception {
String lib = ensureLibrary();
List<Rows.Row> rows = new ArrayList<Rows.Row>();
for (int page = 0; page < 20; page++) {
JSONObject r = Api.libraryItems(HomeActivity.this, lib, page, 100);
JSONArray res = r.optJSONArray("results");
int n = res == null ? 0 : res.length();
for (int i = 0; i < n; i++) {
JSONObject it = res.optJSONObject(i);
if (it == null) continue;
JSONObject media = it.optJSONObject("media");
JSONObject md = media == null ? null : media.optJSONObject("metadata");
Rows.Show s = new Rows.Show();
s.id = Util.str(it, "id");
s.title = Util.str(md, "title");
s.author = Util.str(md, "author");
s.episodes = media == null ? 0 : media.optInt("numEpisodes", 0);
if (s.episodes == 0 && media != null && media.optJSONArray("episodes") != null) {
s.episodes = media.optJSONArray("episodes").length();
}
rows.add(Rows.Row.show(s));
}
int total = r.optInt("total", 0);
if (n < 100 || rows.size() >= total) break;
}
return rows;
}
void done(List<Rows.Row> rows) {
loadingShows = false;
if (isFinishing()) return;
updateLibraryLabel();
showsCache = rows;
if (tab == TAB_SHOWS) show(rows, "No podcasts in this library.");
}
void failed(Exception e) {
loadingShows = false;
if (isFinishing()) return;
if (e instanceof Api.AuthException) {
showError(e);
return;
}
showsError = Util.describe(e) + "\n\nTap refresh to try again.";
if (tab == TAB_SHOWS) offlineFallback(e);
}
}.start();
}
/** When the server can't be reached but episodes are saved, show those instead. */
private void offlineFallback(Exception e) {
if (!(e instanceof Api.ApiException) && !Downloads.list(this).isEmpty()) {
toast("Can't reach the server. Showing saved episodes.");
selectTab(TAB_SAVED);
} else {
show(null, null);
}
}
private void showSaved() {
List<Rows.Row> rows = new ArrayList<Rows.Row>();
Episode cur = DownloadService.current;
List<Episode> queued = DownloadService.queued();
savedSignature = (cur == null ? "" : cur.id) + "/" + queued.size() + "/" + DownloadService.lastError;
if (DownloadService.lastError != null) rows.add(Rows.Row.header(DownloadService.lastError));
if (cur != null || !queued.isEmpty()) {
rows.add(Rows.Row.header("DOWNLOADING"));
if (cur != null) rows.add(Rows.Row.episode(cur));
for (Episode q : queued) rows.add(Rows.Row.episode(q));
}
List<Episode> saved = Downloads.list(this);
if (!saved.isEmpty()) {
rows.add(Rows.Row.header("SAVED ON SD CARD (" + saved.size() + ")"));
for (Episode e : saved) rows.add(Rows.Row.episode(e));
}
if (rows.isEmpty()) {
show(rows, "Nothing saved yet.\n\nLong-press an episode and choose Download to listen offline.");
} else {
show(rows, null);
}
}
// ------------------------------------------------------------------ interaction
private void onRowClick(Rows.Row r) {
if (r == null) return;
switch (r.type) {
case Rows.EPISODE:
playEpisode(r.ep);
break;
case Rows.SHOW:
startActivity(new Intent(this, PodcastActivity.class)
.putExtra("itemId", r.show.id).putExtra("title", r.show.title)
.putExtra("author", r.show.author));
break;
case Rows.MORE:
if (!loadingLatest && latestMore) {
r.text = "Loading…";
adapter.notifyDataSetChanged();
loadLatest(true);
}
break;
default:
break;
}
}
private Episode menuEpisode;
@Override
public void onCreateContextMenu(ContextMenu menu, View v, ContextMenu.ContextMenuInfo info) {
AdapterView.AdapterContextMenuInfo mi = (AdapterView.AdapterContextMenuInfo) info;
Rows.Row r = adapter.getItem(mi.position);
if (r.type != Rows.EPISODE) return;
menuEpisode = r.ep;
episodeMenu(menu, r.ep, true);
}
@Override
public boolean onContextItemSelected(MenuItem item) {
return onEpisodeContextItem(item, menuEpisode, new Runnable() {
public void run() {
if (tab == TAB_SAVED) showSaved();
else adapter.notifyDataSetChanged();
}
}) || super.onContextItemSelected(item);
}
private void chooseLibrary() {
if (podcastLibs == null || podcastLibs.size() < 2) {
toast(podcastLibs == null ? "Still loading libraries…" : "This is your only podcast library.");
return;
}
String[] names = new String[podcastLibs.size()];
for (int i = 0; i < names.length; i++) names[i] = podcastLibs.get(i)[1];
new AlertDialog.Builder(this).setTitle("Podcast library")
.setItems(names, new DialogInterface.OnClickListener() {
public void onClick(DialogInterface d, int which) {
String[] l = podcastLibs.get(which);
Prefs.get(HomeActivity.this).setLibrary(l[0], l[1]);
updateLibraryLabel();
latestCache = null;
showsCache = null;
selectTab(tab == TAB_SAVED ? TAB_LATEST : tab);
}
}).show();
}
// ------------------------------------------------------------------ menu key
@Override
public boolean onCreateOptionsMenu(Menu menu) {
menu.add(0, 1, 0, "Refresh").setIcon(android.R.drawable.ic_menu_rotate);
menu.add(0, 2, 0, "Library").setIcon(android.R.drawable.ic_menu_agenda);
menu.add(0, 3, 0, "Now playing").setIcon(android.R.drawable.ic_media_play);
menu.add(0, 4, 0, "Settings").setIcon(android.R.drawable.ic_menu_preferences);
return true;
}
@Override
public boolean onOptionsItemSelected(MenuItem item) {
switch (item.getItemId()) {
case 1:
refresh();
return true;
case 2:
chooseLibrary();
return true;
case 3:
startActivity(new Intent(this, PlayerActivity.class));
return true;
case 4:
startActivity(new Intent(this, SettingsActivity.class));
return true;
default:
return super.onOptionsItemSelected(item);
}
}
}
+36
View File
@@ -0,0 +1,36 @@
package com.miniabs;
import android.os.AsyncTask;
/** Background work with the result (or error) delivered on the UI thread. */
abstract class Job<T> extends AsyncTask<Void, Void, T> {
private Exception error;
abstract T work() throws Exception;
abstract void done(T result);
void failed(Exception e) {
}
@Override
protected final T doInBackground(Void... v) {
try {
return work();
} catch (Exception e) {
error = e;
return null;
}
}
@Override
protected final void onPostExecute(T result) {
if (error != null) failed(error);
else done(result);
}
Job<T> start() {
execute();
return this;
}
}
+252
View File
@@ -0,0 +1,252 @@
package com.miniabs;
import java.io.IOException;
import java.io.Reader;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
/**
* Small streaming JSON parser that builds org.json objects straight from the
* socket. Very long strings (episode show notes) are cut short and bulky keys
* are dropped while reading, so a podcast with hundreds of episodes still fits
* in the Galaxy Mini's small heap.
*/
final class Json {
/** Keys never kept, at any depth. */
private static final String[] ALWAYS_SKIP = {"chapters", "libraryFiles", "metaTags", "rssFeed"};
private final Reader in;
private final char[] buf = new char[8192];
private int pos, len;
private final int maxString;
private final String[] deepSkip;
private int depth;
private Json(Reader in, int maxString, String[] deepSkip) {
this.in = in;
this.maxString = maxString;
this.deepSkip = deepSkip;
}
static Object parse(Reader r) throws IOException {
return parse(r, 2000, null);
}
/**
* @param maxString longest string kept (longer ones end with an ellipsis)
* @param deepSkip keys dropped inside objects nested three or more levels deep
*/
static Object parse(Reader r, int maxString, String[] deepSkip) throws IOException {
Json j = new Json(r, maxString, deepSkip);
int c = j.skipWs();
if (c == -1) return null;
return j.value(c);
}
private int read() throws IOException {
if (pos >= len) {
len = in.read(buf, 0, buf.length);
pos = 0;
if (len <= 0) {
len = 0;
return -1;
}
}
return buf[pos++];
}
private void unread() {
pos--;
}
private int skipWs() throws IOException {
int c;
do {
c = read();
} while (c == ' ' || c == '\n' || c == '\r' || c == '\t');
return c;
}
private Object value(int c) throws IOException {
switch (c) {
case '{':
return object();
case '[':
return array();
case '"':
return string(maxString);
case 't':
expect("rue");
return Boolean.TRUE;
case 'f':
expect("alse");
return Boolean.FALSE;
case 'n':
expect("ull");
return JSONObject.NULL;
default:
if (c == '-' || (c >= '0' && c <= '9')) return number(c);
throw new IOException("Unexpected reply from server");
}
}
private void expect(String rest) throws IOException {
for (int i = 0; i < rest.length(); i++) {
if (read() != rest.charAt(i)) throw new IOException("Unexpected reply from server");
}
}
private Object number(int first) throws IOException {
StringBuilder sb = new StringBuilder(16);
sb.append((char) first);
boolean dbl = false;
int c;
while (true) {
c = read();
if ((c >= '0' && c <= '9') || c == '-' || c == '+') {
sb.append((char) c);
} else if (c == '.' || c == 'e' || c == 'E') {
dbl = true;
sb.append((char) c);
} else {
break;
}
}
if (c != -1) unread();
String s = sb.toString();
try {
if (!dbl) {
long l = Long.parseLong(s);
if (l >= Integer.MIN_VALUE && l <= Integer.MAX_VALUE) return Integer.valueOf((int) l);
return Long.valueOf(l);
}
return Double.valueOf(s);
} catch (NumberFormatException e) {
try {
return Double.valueOf(s);
} catch (NumberFormatException e2) {
return Integer.valueOf(0);
}
}
}
private boolean skipKey(String key) {
for (String k : ALWAYS_SKIP) if (k.equals(key)) return true;
if (deepSkip != null && depth >= 3) {
for (String k : deepSkip) if (k.equals(key)) return true;
}
return false;
}
private JSONObject object() throws IOException {
depth++;
JSONObject o = new JSONObject();
int c = skipWs();
if (c == '}') {
depth--;
return o;
}
while (true) {
if (c != '"') throw new IOException("Unexpected reply from server");
String key = string(512);
c = skipWs();
if (c != ':') throw new IOException("Unexpected reply from server");
c = skipWs();
if (skipKey(key)) {
skipValue(c);
} else {
Object v = value(c);
try {
o.put(key, v);
} catch (JSONException ignored) {
// NaN or similar; drop it
}
}
c = skipWs();
if (c == ',') {
c = skipWs();
continue;
}
if (c == '}') {
depth--;
return o;
}
throw new IOException("Unexpected reply from server");
}
}
private JSONArray array() throws IOException {
JSONArray a = new JSONArray();
int c = skipWs();
if (c == ']') return a;
while (true) {
a.put(value(c));
c = skipWs();
if (c == ',') {
c = skipWs();
continue;
}
if (c == ']') return a;
throw new IOException("Unexpected reply from server");
}
}
private void skipValue(int c) throws IOException {
if (c == '"') {
string(0);
return;
}
if (c == '{' || c == '[') {
int d = 1;
while (d > 0) {
int ch = read();
if (ch == -1) throw new IOException("Reply was cut off");
if (ch == '"') string(0);
else if (ch == '{' || ch == '[') d++;
else if (ch == '}' || ch == ']') d--;
}
return;
}
value(c);
}
private String string(int max) throws IOException {
StringBuilder sb = new StringBuilder(max > 0 ? Math.min(max, 32) : 0);
boolean cut = false;
while (true) {
int c = read();
if (c == -1) throw new IOException("Reply was cut off");
if (c == '"') break;
if (c == '\\') {
c = read();
switch (c) {
case 'n': c = '\n'; break;
case 't': c = '\t'; break;
case 'r': c = '\r'; break;
case 'b': c = '\b'; break;
case 'f': c = '\f'; break;
case 'u': {
int v = 0;
for (int i = 0; i < 4; i++) {
int d = Character.digit(read(), 16);
if (d < 0) throw new IOException("Unexpected reply from server");
v = v * 16 + d;
}
c = v;
break;
}
case -1:
throw new IOException("Reply was cut off");
default:
break; // \" \\ \/
}
}
if (sb.length() < max) sb.append((char) c);
else cut = true;
}
if (cut && max > 0) sb.append('…');
return sb.toString();
}
}
+117
View File
@@ -0,0 +1,117 @@
package com.miniabs;
import android.app.Activity;
import android.content.Intent;
import android.os.Bundle;
import android.text.InputType;
import android.view.View;
import android.view.inputmethod.InputMethodManager;
import android.widget.Button;
import android.widget.CheckBox;
import android.widget.CompoundButton;
import android.widget.EditText;
import android.widget.TextView;
public class LoginActivity extends Activity {
private EditText server, user, pass;
private CheckBox useKey, trust;
private TextView status, userLabel, passLabel;
private Button connect;
@Override
protected void onCreate(Bundle b) {
super.onCreate(b);
setContentView(R.layout.activity_login);
server = (EditText) findViewById(R.id.server);
user = (EditText) findViewById(R.id.user);
pass = (EditText) findViewById(R.id.pass);
useKey = (CheckBox) findViewById(R.id.use_key);
trust = (CheckBox) findViewById(R.id.trust);
status = (TextView) findViewById(R.id.status);
userLabel = (TextView) findViewById(R.id.user_label);
passLabel = (TextView) findViewById(R.id.pass_label);
connect = (Button) findViewById(R.id.connect);
Prefs p = Prefs.get(this);
server.setText(p.server());
user.setText(p.username());
trust.setChecked(p.trustAll());
String msg = getIntent().getStringExtra("message");
if (msg != null) status.setText(msg);
useKey.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() {
public void onCheckedChanged(CompoundButton v, boolean on) {
int vis = on ? View.GONE : View.VISIBLE;
user.setVisibility(vis);
userLabel.setVisibility(vis);
passLabel.setText(on ? "API key" : "Password");
pass.setInputType(on ? InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS
: InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PASSWORD);
pass.setText("");
}
});
connect.setOnClickListener(new View.OnClickListener() {
public void onClick(View v) {
go();
}
});
}
static String normalize(String s) {
s = s.trim();
while (s.endsWith("/")) s = s.substring(0, s.length() - 1);
if (s.endsWith("/login")) s = s.substring(0, s.length() - 6);
if (s.length() > 0 && !s.startsWith("http://") && !s.startsWith("https://")) s = "http://" + s;
return s;
}
private void go() {
final String srv = normalize(server.getText().toString());
final String u = user.getText().toString().trim();
final String pw = pass.getText().toString();
final boolean key = useKey.isChecked();
if (srv.length() == 0) {
status.setText("Enter your server address.");
return;
}
if (key ? pw.trim().length() == 0 : u.length() == 0) {
status.setText(key ? "Paste your API key." : "Enter your username.");
return;
}
server.setText(srv);
((InputMethodManager) getSystemService(INPUT_METHOD_SERVICE)).hideSoftInputFromWindow(pass.getWindowToken(), 0);
connect.setEnabled(false);
status.setText("Connecting…");
final Prefs p = Prefs.get(this);
p.logout();
App.clear();
HomeActivity.clearCache();
p.setServer(srv, trust.isChecked());
new Job<String>() {
String work() throws Exception {
String version = Api.status(LoginActivity.this, srv);
String lib;
if (key) {
Api.loginWithKey(LoginActivity.this, pw.trim());
lib = "";
} else {
lib = Api.login(LoginActivity.this, srv, u, pw);
}
if (lib.length() > 0) p.setLibrary(lib, "");
return version;
}
void done(String version) {
startActivity(new Intent(LoginActivity.this, HomeActivity.class)
.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP));
finish();
}
void failed(Exception e) {
connect.setEnabled(true);
status.setText(Util.describe(e));
}
}.start();
}
}
+41
View File
@@ -0,0 +1,41 @@
package com.miniabs;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.view.KeyEvent;
/** Headset and Bluetooth buttons: play/pause, next = skip forward, previous = skip back. */
public class MediaButtonReceiver extends BroadcastReceiver {
@Override
public void onReceive(Context c, Intent intent) {
if (!Intent.ACTION_MEDIA_BUTTON.equals(intent.getAction())) return;
KeyEvent ev = (KeyEvent) intent.getParcelableExtra(Intent.EXTRA_KEY_EVENT);
if (ev == null || ev.getAction() != KeyEvent.ACTION_DOWN || ev.getRepeatCount() > 0) return;
String action;
switch (ev.getKeyCode()) {
case KeyEvent.KEYCODE_HEADSETHOOK:
case KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE:
action = PlayerService.A_TOGGLE;
break;
case KeyEvent.KEYCODE_MEDIA_NEXT:
case KeyEvent.KEYCODE_MEDIA_FAST_FORWARD:
action = PlayerService.A_FWD;
break;
case KeyEvent.KEYCODE_MEDIA_PREVIOUS:
case KeyEvent.KEYCODE_MEDIA_REWIND:
action = PlayerService.A_BACK;
break;
case KeyEvent.KEYCODE_MEDIA_STOP:
action = PlayerService.A_PAUSE;
break;
default:
return;
}
// Only start playback from a button press if there is something to play.
if (PlayerService.get() == null && !PlayerService.A_TOGGLE.equals(action)) return;
if (PlayerService.get() == null && Prefs.get(c).lastEpisode() == null) return;
PlayerService.send(c, action);
if (isOrderedBroadcast()) abortBroadcast();
}
}
+301
View File
@@ -0,0 +1,301 @@
package com.miniabs;
import org.json.JSONObject;
import android.app.AlertDialog;
import android.content.DialogInterface;
import android.content.Intent;
import android.os.Bundle;
import android.os.SystemClock;
import android.view.Menu;
import android.view.MenuItem;
import android.view.View;
import android.widget.Button;
import android.widget.ImageButton;
import android.widget.ImageView;
import android.widget.SeekBar;
import android.widget.TextView;
/** The now-playing screen. */
public class PlayerActivity extends BaseActivity {
private TextView show, title, date, status, pos, left, desc;
private ImageView cover;
private SeekBar seek;
private ImageButton play;
private Button back, fwd;
private Episode shown;
private Episode pending; // episode just requested, until the service picks it up
private long pendingAt;
private boolean tracking;
@Override
protected void onCreate(Bundle b) {
super.onCreate(b);
setContentView(R.layout.activity_player);
show = (TextView) findViewById(R.id.show);
title = (TextView) findViewById(R.id.title);
date = (TextView) findViewById(R.id.date);
status = (TextView) findViewById(R.id.status);
pos = (TextView) findViewById(R.id.pos);
left = (TextView) findViewById(R.id.left);
desc = (TextView) findViewById(R.id.desc);
cover = (ImageView) findViewById(R.id.cover);
seek = (SeekBar) findViewById(R.id.seek);
play = (ImageButton) findViewById(R.id.play);
back = (Button) findViewById(R.id.back);
fwd = (Button) findViewById(R.id.fwd);
takePending(getIntent());
play.setOnClickListener(new View.OnClickListener() {
public void onClick(View v) {
PlayerService ps = PlayerService.get();
if (ps != null && ps.episode() != null) {
ps.toggle();
} else {
Episode e = current();
if (e != null) {
PlayerService.play(PlayerActivity.this, e);
pending = e;
pendingAt = SystemClock.elapsedRealtime();
}
}
refresh();
}
});
back.setOnClickListener(new View.OnClickListener() {
public void onClick(View v) {
PlayerService ps = PlayerService.get();
if (ps != null) ps.skip(-Prefs.get(PlayerActivity.this).skipBack());
refresh();
}
});
fwd.setOnClickListener(new View.OnClickListener() {
public void onClick(View v) {
PlayerService ps = PlayerService.get();
if (ps != null) ps.skip(Prefs.get(PlayerActivity.this).skipFwd());
refresh();
}
});
show.setOnClickListener(new View.OnClickListener() {
public void onClick(View v) {
Episode e = current();
if (e != null) {
startActivity(new Intent(PlayerActivity.this, PodcastActivity.class)
.putExtra("itemId", e.itemId).putExtra("title", e.show).putExtra("author", e.author));
}
}
});
seek.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener() {
public void onProgressChanged(SeekBar s, int progress, boolean fromUser) {
if (!fromUser) return;
int dur = duration();
if (dur > 0) {
int p = (int) ((long) dur * progress / 1000);
pos.setText(Util.clock(p));
left.setText("-" + Util.clock(dur - p));
}
}
public void onStartTrackingTouch(SeekBar s) {
tracking = true;
}
public void onStopTrackingTouch(SeekBar s) {
tracking = false;
PlayerService ps = PlayerService.get();
int dur = duration();
if (ps != null && ps.episode() != null && dur > 0) {
ps.seekTo((int) ((long) dur * s.getProgress() / 1000));
}
refresh();
}
});
}
@Override
protected void onNewIntent(Intent intent) {
super.onNewIntent(intent);
takePending(intent);
refresh();
}
private void takePending(Intent i) {
String j = i.getStringExtra("ep");
if (j == null) return;
try {
pending = Episode.fromStored(new JSONObject(j));
pendingAt = SystemClock.elapsedRealtime();
} catch (Exception ignored) {
}
}
@Override
protected void onResume() {
super.onResume();
Prefs p = Prefs.get(this);
back.setText("-" + p.skipBack());
fwd.setText("+" + p.skipFwd());
}
@Override
protected void onTick() {
refresh();
}
/** The episode this screen is about. */
private Episode current() {
PlayerService ps = PlayerService.get();
Episode active = ps != null ? ps.episode() : null;
if (pending != null) {
boolean picked = active != null && active.id.equals(pending.id);
if (!picked && SystemClock.elapsedRealtime() - pendingAt < 5000) return pending;
pending = null;
}
if (active != null) return active;
return Prefs.get(this).lastEpisode();
}
private int duration() {
PlayerService ps = PlayerService.get();
if (ps != null && ps.episode() != null) return ps.durationMs();
Episode e = current();
return e == null ? 0 : (int) (e.duration * 1000);
}
private void refresh() {
Episode e = current();
if (e == null) {
show.setText("");
title.setText("Nothing playing");
date.setText("");
status.setText("Pick an episode from the list.");
desc.setText("");
cover.setVisibility(View.GONE);
setControls(false);
return;
}
if (shown == null || !shown.id.equals(e.id)) {
shown = e;
show.setText(e.show);
title.setText(e.title);
date.setText(Util.join(Util.date(e.published), e.duration > 0 ? Util.duration(e.duration) : ""));
desc.setText(Util.plainText(e.desc));
Covers.load(this, cover, e.itemId, (int) (84 * getResources().getDisplayMetrics().density));
}
PlayerService ps = PlayerService.get();
boolean active = ps != null && ps.episode() != null && ps.episode().id.equals(e.id);
int p, dur;
String st;
boolean playing = false;
if (active) {
p = ps.positionMs();
dur = ps.durationMs();
playing = ps.isPlaying() || (ps.state() == PlayerService.LOADING);
st = ps.status();
long sleep = ps.sleepLeftMs();
if (sleep == -1) st = Util.join(st, "Sleep at end");
else if (sleep > 0) st = Util.join(st, "Sleep in " + Util.clock(sleep));
seek.setSecondaryProgress(ps.bufferedPercent() * 10);
setControls(true);
} else {
double[] pr = App.progress(e.id);
double cur = pr != null ? pr[0] : Math.max(0, App.localPosition(this, e.id));
p = (int) (cur * 1000);
dur = (int) (e.duration * 1000);
st = pending != null ? "Connecting…" : (Downloads.has(this, e.id) ? "Saved on SD card" : "");
playing = pending != null;
seek.setSecondaryProgress(0);
setControls(true);
}
play.setImageResource(playing ? R.drawable.ic_pause : R.drawable.ic_play);
status.setText(st);
if (!tracking) {
pos.setText(Util.clock(p));
left.setText(dur > 0 ? "-" + Util.clock(Math.max(0, dur - p)) : "");
seek.setProgress(dur > 0 ? (int) (1000L * p / dur) : 0);
}
}
private void setControls(boolean on) {
play.setEnabled(on);
back.setEnabled(on);
fwd.setEnabled(on);
seek.setEnabled(on);
}
// ------------------------------------------------------------------ menu
@Override
public boolean onPrepareOptionsMenu(Menu menu) {
menu.clear();
Episode e = current();
if (e == null) return false;
PlayerService ps = PlayerService.get();
boolean active = ps != null && ps.episode() != null && ps.episode().id.equals(e.id);
if (active) menu.add(0, 1, 0, "Sleep timer").setIcon(android.R.drawable.ic_lock_idle_alarm);
menu.add(0, 2, 0, "Mark as played").setIcon(android.R.drawable.ic_menu_agenda);
if (DownloadService.busyWith(e.id)) {
menu.add(0, 3, 0, "Cancel download").setIcon(android.R.drawable.ic_menu_close_clear_cancel);
} else if (Downloads.has(this, e.id)) {
menu.add(0, 3, 0, "Delete download").setIcon(android.R.drawable.ic_menu_delete);
} else {
menu.add(0, 3, 0, "Download").setIcon(android.R.drawable.ic_menu_save);
}
menu.add(0, 4, 0, "Go to show").setIcon(android.R.drawable.ic_menu_view);
if (active) menu.add(0, 5, 0, "Stop").setIcon(android.R.drawable.ic_menu_close_clear_cancel);
return true;
}
@Override
public boolean onOptionsItemSelected(MenuItem item) {
final Episode e = current();
if (e == null) return false;
PlayerService ps = PlayerService.get();
boolean active = ps != null && ps.episode() != null && ps.episode().id.equals(e.id);
switch (item.getItemId()) {
case 1:
sleepDialog();
return true;
case 2:
if (active) {
ps.finishAndStop();
toast("Marked as played");
} else {
episodeAction(M_PLAYED, e, null);
}
refresh();
return true;
case 3:
int action = DownloadService.busyWith(e.id) ? M_CANCEL : Downloads.has(this, e.id) ? M_DELETE : M_DOWNLOAD;
episodeAction(action, e, null);
return true;
case 4:
episodeAction(M_SHOW, e, null);
return true;
case 5:
if (active) ps.stop();
refresh();
return true;
default:
return super.onOptionsItemSelected(item);
}
}
private void sleepDialog() {
final int[] minutes = {0, 5, 10, 15, 30, 45, 60, -1};
String[] labels = {"Off", "5 minutes", "10 minutes", "15 minutes", "30 minutes", "45 minutes", "1 hour",
"End of episode"};
new AlertDialog.Builder(this).setTitle("Sleep timer")
.setItems(labels, new DialogInterface.OnClickListener() {
public void onClick(DialogInterface d, int which) {
PlayerService ps = PlayerService.get();
if (ps == null) return;
ps.setSleep(minutes[which]);
toast(minutes[which] == 0 ? "Sleep timer off" : "Sleep timer set");
refresh();
}
}).show();
}
}
+762
View File
@@ -0,0 +1,762 @@
package com.miniabs;
import java.io.File;
import java.io.FileInputStream;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import org.json.JSONArray;
import org.json.JSONObject;
import android.app.Notification;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.app.Service;
import android.content.BroadcastReceiver;
import android.content.ComponentName;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.media.AudioManager;
import android.media.MediaPlayer;
import android.net.wifi.WifiManager;
import android.os.Handler;
import android.os.IBinder;
import android.os.PowerManager;
import android.os.SystemClock;
/**
* Plays one episode at a time, keeps the Audiobookshelf playback session in
* sync, and falls back to saved files and local progress when offline.
*/
public class PlayerService extends Service implements MediaPlayer.OnPreparedListener,
MediaPlayer.OnCompletionListener, MediaPlayer.OnErrorListener, MediaPlayer.OnBufferingUpdateListener,
AudioManager.OnAudioFocusChangeListener {
static final String A_PLAY = "com.miniabs.PLAY";
static final String A_TOGGLE = "com.miniabs.TOGGLE";
static final String A_PAUSE = "com.miniabs.PAUSE";
static final String A_BACK = "com.miniabs.BACK";
static final String A_FWD = "com.miniabs.FWD";
static final String A_STOP = "com.miniabs.STOP";
static final int IDLE = 0, LOADING = 1, PLAYING = 2, PAUSED = 3, ENDED = 4, ERROR = 5;
private static final int NOTIF_ID = 1;
private static final long SYNC_EVERY = 15000;
private static final long IDLE_STOP = 30 * 60 * 1000;
private static PlayerService instance;
static PlayerService get() {
return instance;
}
/** Starts playing an episode (or resumes it if it's already loaded). */
static void play(Context c, Episode e) {
c.startService(new Intent(c, PlayerService.class).setAction(A_PLAY).putExtra("ep", e.toJson().toString()));
}
static void send(Context c, String action) {
c.startService(new Intent(c, PlayerService.class).setAction(action));
}
private final Handler h = new Handler();
private final ExecutorService net = Executors.newSingleThreadExecutor();
private AudioManager am;
private WifiManager.WifiLock wifi;
private ComponentName buttons;
private MediaPlayer mp;
private boolean prepared;
private Episode ep;
private volatile String sessionId;
private String contentUrl; // server-relative stream URL, re-tokenised on retry
private boolean local; // playing a downloaded file
private int state = IDLE;
private String status = "";
private int resumeMs; // where to start once prepared
private boolean playOnPrepared;
private int lastPosMs;
private int buffered; // percent
private int generation; // drops results of superseded async work
private int retries;
private long playingSince;
private long lastTick;
private float listened; // seconds listened since the last sync
private long lastSync;
private volatile boolean syncing;
private long pausedAt;
private long sleepAt; // elapsedRealtime when the sleep timer fires, 0 = off
private boolean sleepAtEnd;
private boolean pausedByFocus;
private boolean focusHeld;
private final BroadcastReceiver noisy = new BroadcastReceiver() {
@Override
public void onReceive(Context context, Intent intent) {
if (state == PLAYING) pause(); // headphones unplugged
}
};
private final Runnable ticker = new Runnable() {
public void run() {
tick();
h.postDelayed(this, 1000);
}
};
// --------------------------------------------------------------- lifecycle
@Override
public void onCreate() {
super.onCreate();
instance = this;
am = (AudioManager) getSystemService(AUDIO_SERVICE);
wifi = ((WifiManager) getSystemService(WIFI_SERVICE)).createWifiLock(WifiManager.WIFI_MODE_FULL, "MiniABS");
wifi.setReferenceCounted(false);
buttons = new ComponentName(this, MediaButtonReceiver.class);
registerReceiver(noisy, new IntentFilter(AudioManager.ACTION_AUDIO_BECOMING_NOISY));
h.post(ticker);
}
@Override
public void onDestroy() {
h.removeCallbacks(ticker);
if (ep != null && prepared && state != ENDED) syncNow(true);
releasePlayer();
net.shutdown();
try {
unregisterReceiver(noisy);
} catch (Exception ignored) {
}
am.unregisterMediaButtonEventReceiver(buttons);
dropFocus();
wifi.release();
if (instance == this) instance = null;
super.onDestroy();
}
@Override
public IBinder onBind(Intent intent) {
return null;
}
@Override
public int onStartCommand(Intent intent, int flags, int startId) {
if (intent == null || intent.getAction() == null) return START_NOT_STICKY;
String a = intent.getAction();
if (A_PLAY.equals(a)) {
try {
play(Episode.fromStored(new JSONObject(intent.getStringExtra("ep"))));
} catch (Exception ignored) {
}
} else if (A_TOGGLE.equals(a)) {
toggle();
} else if (A_PAUSE.equals(a)) {
if (state == PLAYING) pause();
} else if (A_BACK.equals(a)) {
skip(-Prefs.get(this).skipBack());
} else if (A_FWD.equals(a)) {
skip(Prefs.get(this).skipFwd());
} else if (A_STOP.equals(a)) {
stop();
}
if (ep == null) stopSelf();
return START_NOT_STICKY;
}
// --------------------------------------------------------------- public state
Episode episode() { return ep; }
int state() { return state; }
String status() { return status; }
boolean isLocal() { return local; }
int bufferedPercent() { return local ? 100 : buffered; }
boolean isPlaying() {
return state == PLAYING;
}
int positionMs() {
if (mp != null && prepared) {
try {
return mp.getCurrentPosition();
} catch (Exception ignored) {
}
}
return state == ENDED ? durationMs() : resumeMs > 0 ? resumeMs : lastPosMs;
}
int durationMs() {
if (ep != null && ep.duration > 0) return (int) (ep.duration * 1000);
if (mp != null && prepared) {
try {
return mp.getDuration();
} catch (Exception ignored) {
}
}
return 0;
}
/** Milliseconds until the sleep timer fires; 0 = off, -1 = end of episode. */
long sleepLeftMs() {
if (sleepAtEnd) return -1;
if (sleepAt == 0) return 0;
return Math.max(0, sleepAt - SystemClock.elapsedRealtime());
}
void setSleep(int minutes) {
sleepAtEnd = minutes < 0;
sleepAt = minutes > 0 ? SystemClock.elapsedRealtime() + minutes * 60000L : 0;
}
// --------------------------------------------------------------- controls
void play(Episode e) {
if (ep != null && ep.id.equals(e.id) && mp != null && state != ERROR && state != ENDED) {
if (state == PAUSED) resume();
return;
}
if (ep != null && prepared && state != ENDED) syncNow(true);
releasePlayer();
ep = e;
sessionId = null;
contentUrl = null;
retries = 0;
lastPosMs = 0;
resumeMs = 0;
Prefs.get(this).setLastEpisode(e);
open(e, -1, true);
}
void toggle() {
if (ep == null) {
Episode last = Prefs.get(this).lastEpisode();
if (last != null) play(last);
return;
}
if (state == PLAYING) pause();
else if (state == LOADING) playOnPrepared = !playOnPrepared;
else resume();
}
void pause() {
if (mp == null || !prepared) {
playOnPrepared = false;
return;
}
try {
mp.pause();
} catch (Exception ignored) {
}
lastPosMs = positionMs();
state = PAUSED;
status = "Paused";
pausedAt = SystemClock.elapsedRealtime();
syncNow(false);
wifi.release();
showNotification(false);
}
void resume() {
if (ep == null) return;
if (state == ENDED) { // listen again from the start, in a fresh session
App.setProgress(ep.id, 0, ep.duration, false);
open(ep, 0, true);
return;
}
if (mp == null || state == ERROR) {
open(ep, lastPosMs / 1000.0, true);
return;
}
if (prepared) start();
else playOnPrepared = true;
}
void seekTo(int ms) {
int dur = durationMs();
if (dur > 0 && ms > dur - 1000) ms = dur - 1000;
if (ms < 0) ms = 0;
if (mp != null && prepared) {
try {
mp.seekTo(ms);
} catch (Exception ignored) {
}
lastPosMs = ms;
if (state == ENDED) state = PAUSED;
} else {
resumeMs = ms;
lastPosMs = ms;
}
}
void skip(int seconds) {
if (ep == null) return;
seekTo(positionMs() + seconds * 1000);
}
void stop() {
if (ep != null && prepared && state != ENDED) syncNow(true);
releasePlayer();
state = IDLE;
status = "";
am.unregisterMediaButtonEventReceiver(buttons);
dropFocus();
wifi.release();
stopForeground(true);
((NotificationManager) getSystemService(NOTIFICATION_SERVICE)).cancel(NOTIF_ID);
ep = null;
stopSelf();
}
/** Marks the current episode as played on the server. */
void markFinished() {
if (ep == null) return;
final Episode e = ep;
final double dur = durationMs() / 1000.0;
App.setProgress(e.id, dur, dur, true);
final String sid = sessionId;
sessionId = null;
net.execute(new Runnable() {
public void run() {
try {
if (sid != null) Api.closeSession(PlayerService.this, sid, dur, 0, dur);
} catch (Exception ignored) {
}
try {
Api.setProgress(PlayerService.this, e.itemId, e.id, dur, dur, Boolean.TRUE);
App.forgetLocal(PlayerService.this, e.id);
} catch (Exception ex) {
App.saveLocal(PlayerService.this, e, dur, dur, true);
}
}
});
}
/** "Mark as played" from the player screen: stop here and tell the server. */
void finishAndStop() {
if (ep == null) return;
int dur = durationMs();
markFinished();
releasePlayer();
state = ENDED;
status = "Marked as played";
lastPosMs = dur;
pausedAt = SystemClock.elapsedRealtime();
wifi.release();
showNotification(false);
}
// --------------------------------------------------------------- opening
private static final class Source {
String url, sessionId, contentUrl, error;
boolean local, offline;
double start;
}
/** Opens a session and the audio. startSec < 0 means "resume where the server says". */
private void open(final Episode e, final double startSec, boolean playNow) {
releasePlayer();
state = LOADING;
status = "Connecting…";
playOnPrepared = playNow;
showNotification(true);
final int gen = ++generation;
final Context ctx = getApplicationContext();
net.execute(new Runnable() {
public void run() {
final Source s = findSource(ctx, e, startSec);
h.post(new Runnable() {
public void run() {
if (gen == generation) startSource(s);
}
});
}
});
}
private Source findSource(Context c, Episode e, double startSec) {
Source s = new Source();
File f = Downloads.audio(c, e);
double localPos = App.localPosition(c, e.id);
boolean localNewer = App.localPending(c, e.id);
try {
if (f != null && !Util.online(c)) throw new java.io.IOException("offline");
Api.ensureFresh(c, 1800);
JSONObject sess = Api.startPlayback(c, e);
s.sessionId = Util.str(sess, "id");
s.start = sess.optDouble("currentTime", 0);
if (localNewer && localPos >= 0) s.start = localPos;
if (f != null) {
s.url = f.getAbsolutePath();
s.local = true;
} else {
JSONArray tracks = sess.optJSONArray("audioTracks");
JSONObject t = tracks == null ? null : tracks.optJSONObject(0);
String cu = t == null ? "" : Util.str(t, "contentUrl");
if (cu.length() == 0) {
s.error = "The server sent no audio for this episode.";
} else if (cu.contains(".m3u8")) {
s.error = "This episode needs transcoding, which Android 2.x can't play. Try downloading it.";
} else {
s.contentUrl = cu;
s.url = Api.withToken(c, Api.absolute(c, cu));
}
}
} catch (Exception ex) {
if (f != null) {
s.url = f.getAbsolutePath();
s.local = true;
s.offline = true;
s.start = Math.max(0, localPos);
} else {
s.error = Util.describe(ex);
}
}
if (startSec >= 0) s.start = startSec;
double[] p = App.progress(e.id);
if (startSec < 0 && p != null && p[2] > 0) s.start = 0; // finished before: start over
if (e.duration > 0 && s.start > e.duration - 5) s.start = 0;
return s;
}
private void startSource(Source s) {
if (s.error != null) {
fail(s.error);
return;
}
sessionId = s.sessionId;
contentUrl = s.contentUrl;
local = s.local;
resumeMs = (int) (s.start * 1000);
lastPosMs = resumeMs;
prepare(s.url, s.offline ? "Offline · progress will sync later" : null);
}
private void prepare(String url, String note) {
releasePlayer();
mp = new MediaPlayer();
mp.setAudioStreamType(AudioManager.STREAM_MUSIC);
mp.setWakeMode(getApplicationContext(), PowerManager.PARTIAL_WAKE_LOCK);
mp.setOnPreparedListener(this);
mp.setOnCompletionListener(this);
mp.setOnErrorListener(this);
mp.setOnBufferingUpdateListener(this);
try {
if (local) {
FileInputStream in = new FileInputStream(url);
try {
mp.setDataSource(in.getFD());
} finally {
in.close();
}
} else {
mp.setDataSource(url);
wifi.acquire();
}
state = LOADING;
status = note != null ? note : local ? "Opening saved file…" : "Buffering…";
mp.prepareAsync();
} catch (Exception ex) {
fail("Can't open the audio: " + Util.describe(ex));
}
}
@Override
public void onPrepared(MediaPlayer p) {
if (p != mp) return;
prepared = true;
int dur = durationMs();
if (resumeMs > 1000 && (dur <= 0 || resumeMs < dur - 2000)) {
try {
mp.seekTo(resumeMs);
} catch (Exception ignored) {
}
}
resumeMs = 0;
if (playOnPrepared) {
start();
} else {
state = PAUSED;
status = "Paused";
showNotification(false);
}
}
private void start() {
if (mp == null) return;
if (!focusHeld) {
int r = am.requestAudioFocus(this, AudioManager.STREAM_MUSIC, AudioManager.AUDIOFOCUS_GAIN);
focusHeld = r == AudioManager.AUDIOFOCUS_REQUEST_GRANTED;
}
am.registerMediaButtonEventReceiver(buttons);
try {
mp.setVolume(1f, 1f);
mp.start();
} catch (Exception ex) {
fail("Playback failed: " + Util.describe(ex));
return;
}
if (!local) wifi.acquire();
state = PLAYING;
status = local ? (sessionId == null ? "Saved file · offline" : "Saved file") : "Streaming";
playingSince = SystemClock.elapsedRealtime();
lastTick = playingSince;
if (lastSync == 0) lastSync = playingSince;
showNotification(true);
}
private void fail(String msg) {
releasePlayer();
state = ERROR;
status = msg;
pausedAt = SystemClock.elapsedRealtime();
wifi.release();
showNotification(false);
}
private void releasePlayer() {
if (mp != null) {
if (prepared) lastPosMs = positionMs();
try {
mp.reset();
mp.release();
} catch (Exception ignored) {
}
}
mp = null;
prepared = false;
buffered = 0;
}
// --------------------------------------------------------------- player callbacks
@Override
public void onBufferingUpdate(MediaPlayer p, int percent) {
if (p == mp) buffered = percent;
}
@Override
public void onCompletion(MediaPlayer p) {
if (p != mp) return;
int pos = positionMs(), dur = durationMs();
// Old Android's streamer "completes" when the connection drops. Treat that as an error.
if (!local && dur > 0 && pos < dur - 30000) {
lastPosMs = pos;
retryStream("Connection lost, reconnecting…");
return;
}
state = ENDED;
status = "Finished";
lastPosMs = dur;
pausedAt = SystemClock.elapsedRealtime();
wifi.release();
if (sleepAtEnd) sleepAtEnd = false;
markFinished();
showNotification(false);
}
@Override
public boolean onError(MediaPlayer p, int what, int extra) {
if (p != mp) return true;
if (prepared) lastPosMs = positionMs();
if (!local && contentUrl != null) {
retryStream("Connection lost, reconnecting…");
} else {
fail("Playback error (" + what + "/" + extra + ").");
}
return true;
}
/** Re-opens the stream at the last position with a fresh token. Gives up after three tries. */
private void retryStream(final String msg) {
if (retries >= 3 || contentUrl == null) {
fail("Lost connection to the server. Press play to try again.");
retries = 0;
return;
}
retries++;
final boolean wasPlaying = state == PLAYING || playOnPrepared;
final int pos = lastPosMs;
final String cu = contentUrl;
releasePlayer();
state = LOADING;
status = msg;
final int gen = ++generation;
final Context ctx = getApplicationContext();
h.postDelayed(new Runnable() {
public void run() {
if (gen != generation) return;
net.execute(new Runnable() {
public void run() {
try {
if (Prefs.get(ctx).canRefresh()) Api.refresh(ctx, true);
} catch (Exception ignored) {
}
h.post(new Runnable() {
public void run() {
if (gen != generation) return;
resumeMs = pos;
playOnPrepared = wasPlaying;
prepare(Api.withToken(ctx, Api.absolute(ctx, cu)), msg);
}
});
}
});
}
}, 1500L * retries);
}
@Override
public void onAudioFocusChange(int change) {
switch (change) {
case AudioManager.AUDIOFOCUS_LOSS:
focusHeld = false;
pausedByFocus = false;
if (state == PLAYING) pause();
break;
case AudioManager.AUDIOFOCUS_LOSS_TRANSIENT:
if (state == PLAYING) {
pause();
pausedByFocus = true;
}
break;
case AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK:
if (mp != null && state == PLAYING) mp.setVolume(0.2f, 0.2f);
break;
case AudioManager.AUDIOFOCUS_GAIN:
focusHeld = true;
if (mp != null) mp.setVolume(1f, 1f);
if (pausedByFocus) {
pausedByFocus = false;
if (state == PAUSED) start();
}
break;
default:
break;
}
}
private void dropFocus() {
am.abandonAudioFocus(this);
focusHeld = false;
}
// --------------------------------------------------------------- sync
private void tick() {
if (ep == null) return;
long now = SystemClock.elapsedRealtime();
if (state == PLAYING) {
float d = (now - lastTick) / 1000f;
if (d > 0 && d < 5) listened += d;
lastTick = now;
lastPosMs = positionMs();
double dur = durationMs() / 1000.0;
App.setProgress(ep.id, lastPosMs / 1000.0, dur, false);
if (now - lastSync > SYNC_EVERY) syncNow(false);
if (now - playingSince > 60000) retries = 0;
if (sleepAt > 0 && now >= sleepAt) {
sleepAt = 0;
pause();
status = "Sleep timer: paused";
}
} else if (state == PAUSED || state == ENDED || state == ERROR) {
// Free the phone's memory if left paused for a long time.
if (pausedAt > 0 && now - pausedAt > IDLE_STOP) {
pausedAt = 0;
stop();
}
}
}
/** Sends the position to the server (or keeps it on the phone when offline). */
private void syncNow(final boolean close) {
if (ep == null) return;
if (syncing && !close) return;
final Episode e = ep;
final double cur = (prepared ? positionMs() : lastPosMs) / 1000.0;
final double dur = durationMs() / 1000.0;
final float tl = listened;
final String sid = sessionId;
final Context c = getApplicationContext();
listened = 0;
lastSync = SystemClock.elapsedRealtime();
if (close) sessionId = null;
if (cur <= 0 && tl <= 0) return;
syncing = true;
final boolean saved = Downloads.has(c, e.id);
Runnable job = new Runnable() {
public void run() {
boolean ok = false;
try {
if (sid != null) {
try {
if (close) Api.closeSession(c, sid, cur, tl, dur);
else Api.syncSession(c, sid, cur, tl, dur);
ok = true;
} catch (Api.ApiException ex) {
if (ex.code == 404) {
// Session expired on the server (e.g. it restarted): save progress directly.
h.post(new Runnable() {
public void run() {
if (sid.equals(sessionId)) sessionId = null;
}
});
} else {
throw ex;
}
}
}
if (!ok && Util.online(c)) {
Api.setProgress(c, e.itemId, e.id, cur, dur, null);
ok = true;
}
} catch (Exception ignored) {
ok = false;
}
if (!ok) App.saveLocal(c, e, cur, dur, true);
else if (saved) App.saveLocal(c, e, cur, dur, false);
else App.forgetLocal(c, e.id);
syncing = false;
}
};
try {
net.execute(job);
} catch (Exception ex) {
// executor already shut down (service ending): keep the position on the phone
App.saveLocal(c, e, cur, dur, true);
syncing = false;
}
}
// --------------------------------------------------------------- notification
private void showNotification(boolean foreground) {
if (ep == null) return;
Notification n = new Notification(R.drawable.ic_stat, null, 0);
n.flags |= foreground ? Notification.FLAG_ONGOING_EVENT : 0;
Intent i = new Intent(this, PlayerActivity.class).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
PendingIntent pi = PendingIntent.getActivity(this, 0, i, PendingIntent.FLAG_UPDATE_CURRENT);
String what;
switch (state) {
case PLAYING: what = "Playing"; break;
case LOADING: what = "Loading…"; break;
case ENDED: what = "Finished"; break;
case ERROR: what = "Stopped: tap to retry"; break;
default: what = "Paused"; break;
}
n.setLatestEventInfo(this, ep.title, Util.join(what, ep.show), pi);
if (foreground) {
startForeground(NOTIF_ID, n);
} else {
stopForeground(false);
((NotificationManager) getSystemService(NOTIFICATION_SERVICE)).notify(NOTIF_ID, n);
}
}
}
+203
View File
@@ -0,0 +1,203 @@
package com.miniabs;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.List;
import org.json.JSONArray;
import org.json.JSONObject;
import android.content.Intent;
import android.os.Bundle;
import android.view.ContextMenu;
import android.view.Menu;
import android.view.MenuItem;
import android.view.View;
import android.widget.AdapterView;
import android.widget.ImageView;
import android.widget.ListView;
import android.widget.ProgressBar;
import android.widget.TextView;
/** All episodes of one podcast. */
public class PodcastActivity extends BaseActivity {
private String itemId;
private ListView list;
private TextView empty, title, subtitle;
private ProgressBar loading;
private Rows adapter;
private List<Episode> episodes;
private boolean oldestFirst, hidePlayed;
private int ticks;
private Episode menuEpisode;
@Override
protected void onCreate(Bundle b) {
super.onCreate(b);
setContentView(R.layout.activity_podcast);
itemId = getIntent().getStringExtra("itemId");
list = (ListView) findViewById(R.id.list);
empty = (TextView) findViewById(R.id.empty);
loading = (ProgressBar) findViewById(R.id.loading);
title = (TextView) findViewById(R.id.title);
subtitle = (TextView) findViewById(R.id.subtitle);
title.setText(getIntent().getStringExtra("title"));
subtitle.setText(getIntent().getStringExtra("author"));
Covers.load(this, (ImageView) findViewById(R.id.cover), itemId,
(int) (44 * getResources().getDisplayMetrics().density));
Prefs p = Prefs.get(this);
oldestFirst = p.sp.getBoolean("oldest_first", false);
hidePlayed = p.sp.getBoolean("hide_played", false);
adapter = new Rows(this);
adapter.showPodcastName = false;
list.setAdapter(adapter);
registerForContextMenu(list);
list.setOnItemClickListener(new AdapterView.OnItemClickListener() {
public void onItemClick(AdapterView<?> parent, View v, int pos, long id) {
Rows.Row r = adapter.getItem(pos);
if (r.type == Rows.EPISODE) playEpisode(r.ep);
}
});
setupMiniBar();
load();
}
@Override
protected void onResume() {
super.onResume();
if (episodes != null) adapter.notifyDataSetChanged();
}
@Override
protected void onTick() {
super.onTick();
if (++ticks % 3 == 0) adapter.notifyDataSetChanged();
}
private void load() {
loading.setVisibility(View.VISIBLE);
empty.setVisibility(View.GONE);
new Job<List<Episode>>() {
String author = "";
String showTitle = "";
List<Episode> work() throws Exception {
if (!App.progressFresh()) App.loadProgress(PodcastActivity.this);
JSONObject it = Api.item(PodcastActivity.this, itemId);
JSONObject media = it.optJSONObject("media");
JSONObject md = media == null ? null : media.optJSONObject("metadata");
showTitle = Util.str(md, "title");
author = Util.str(md, "author");
JSONArray eps = media == null ? null : media.optJSONArray("episodes");
List<Episode> out = new ArrayList<Episode>();
for (int i = 0; eps != null && i < eps.length(); i++) {
Episode e = Episode.fromApi(eps.optJSONObject(i), itemId, showTitle, author);
if (e != null) out.add(e);
}
return out;
}
void done(List<Episode> list) {
loading.setVisibility(View.GONE);
episodes = list;
if (showTitle.length() > 0) title.setText(showTitle);
String n = list.size() == 1 ? "1 episode" : list.size() + " episodes";
subtitle.setText(Util.join(author, n));
render();
}
void failed(Exception e) {
loading.setVisibility(View.GONE);
if (e instanceof Api.AuthException) {
showError(e);
return;
}
empty.setText(Util.describe(e));
empty.setVisibility(View.VISIBLE);
}
}.start();
}
private void render() {
if (episodes == null) return;
List<Episode> sorted = new ArrayList<Episode>(episodes);
Collections.sort(sorted, new Comparator<Episode>() {
public int compare(Episode a, Episode b) {
int c = a.published == b.published ? 0 : (a.published < b.published ? -1 : 1);
return oldestFirst ? c : -c;
}
});
List<Rows.Row> rows = new ArrayList<Rows.Row>();
int hidden = 0;
for (Episode e : sorted) {
double[] p = App.progress(e.id);
if (hidePlayed && p != null && p[2] > 0) {
hidden++;
continue;
}
rows.add(Rows.Row.episode(e));
}
if (hidden > 0) rows.add(0, Rows.Row.header(hidden + " PLAYED EPISODES HIDDEN"));
adapter.set(rows);
boolean none = rows.isEmpty();
empty.setText(none ? "No episodes." : "");
empty.setVisibility(none ? View.VISIBLE : View.GONE);
}
@Override
public void onCreateContextMenu(ContextMenu menu, View v, ContextMenu.ContextMenuInfo info) {
Rows.Row r = adapter.getItem(((AdapterView.AdapterContextMenuInfo) info).position);
if (r.type != Rows.EPISODE) return;
menuEpisode = r.ep;
episodeMenu(menu, r.ep, false);
}
@Override
public boolean onContextItemSelected(MenuItem item) {
return onEpisodeContextItem(item, menuEpisode, new Runnable() {
public void run() {
render();
}
}) || super.onContextItemSelected(item);
}
@Override
public boolean onPrepareOptionsMenu(Menu menu) {
menu.clear();
menu.add(0, 1, 0, oldestFirst ? "Newest first" : "Oldest first").setIcon(android.R.drawable.ic_menu_sort_by_size);
menu.add(0, 2, 0, hidePlayed ? "Show played" : "Hide played").setIcon(android.R.drawable.ic_menu_view);
menu.add(0, 3, 0, "Refresh").setIcon(android.R.drawable.ic_menu_rotate);
menu.add(0, 4, 0, "Now playing").setIcon(android.R.drawable.ic_media_play);
return true;
}
@Override
public boolean onOptionsItemSelected(MenuItem item) {
Prefs p = Prefs.get(this);
switch (item.getItemId()) {
case 1:
oldestFirst = !oldestFirst;
p.sp.edit().putBoolean("oldest_first", oldestFirst).commit();
render();
list.setSelection(0);
return true;
case 2:
hidePlayed = !hidePlayed;
p.sp.edit().putBoolean("hide_played", hidePlayed).commit();
render();
return true;
case 3:
App.invalidate();
load();
return true;
case 4:
startActivity(new Intent(this, PlayerActivity.class));
return true;
default:
return super.onOptionsItemSelected(item);
}
}
}
+146
View File
@@ -0,0 +1,146 @@
package com.miniabs;
import java.util.UUID;
import org.json.JSONObject;
import android.content.Context;
import android.content.SharedPreferences;
import android.preference.PreferenceManager;
import android.util.Base64;
/** Settings and sign-in state. */
final class Prefs {
static final String MODE_JWT = "jwt"; // ABS 2.26+: short access token + refresh token
static final String MODE_LEGACY = "legacy"; // older servers: one long-lived token
static final String MODE_KEY = "key"; // API key pasted by the user
private static Prefs instance;
final SharedPreferences sp;
private Prefs(Context c) {
sp = PreferenceManager.getDefaultSharedPreferences(c);
}
static synchronized Prefs get(Context c) {
if (instance == null) instance = new Prefs(c.getApplicationContext());
return instance;
}
private String s(String k) {
return sp.getString(k, "");
}
String server() { return s("server"); }
String username() { return s("username"); }
String token() { return s("access"); }
String refreshToken() { return s("refresh"); }
String mode() { return s("mode"); }
boolean trustAll() { return sp.getBoolean("trust_all", false); }
boolean wifiOnly() { return sp.getBoolean("wifi_only", true); }
boolean covers() { return sp.getBoolean("covers", true); }
String libraryId() { return s("library_id"); }
String libraryName() { return s("library_name"); }
int skipBack() { return num("skip_back", 15); }
int skipFwd() { return num("skip_fwd", 30); }
private int num(String k, int def) {
try {
return Integer.parseInt(sp.getString(k, String.valueOf(def)));
} catch (Exception e) {
return def;
}
}
boolean loggedIn() {
return server().length() > 0 && token().length() > 0;
}
boolean canRefresh() {
return MODE_JWT.equals(mode()) && refreshToken().length() > 0;
}
void setServer(String server, boolean trustAll) {
sp.edit().putString("server", server).putBoolean("trust_all", trustAll).commit();
}
synchronized void setAuth(String user, String mode, String access, String refresh) {
SharedPreferences.Editor e = sp.edit()
.putString("username", user)
.putString("mode", mode)
.putString("refresh", refresh == null ? "" : refresh);
putToken(e, access);
e.commit();
}
synchronized void setTokens(String access, String refresh) {
SharedPreferences.Editor e = sp.edit();
if (refresh != null && refresh.length() > 0) e.putString("refresh", refresh);
putToken(e, access);
e.commit();
}
/** Stores the token with its lifetime measured on this phone's clock (old phones' clocks drift). */
private void putToken(SharedPreferences.Editor e, String access) {
long lifetime = 0;
try {
String[] parts = access.split("\\.");
if (parts.length == 3) {
JSONObject p = new JSONObject(new String(Base64.decode(parts[1], Base64.URL_SAFE), "UTF-8"));
long exp = p.optLong("exp", 0), iat = p.optLong("iat", 0);
if (exp > 0 && iat > 0 && exp > iat) lifetime = exp - iat;
}
} catch (Exception ignored) {
// not a JWT, or unreadable: treat as non-expiring
}
e.putString("access", access)
.putLong("token_lifetime", lifetime)
.putLong("token_obtained", System.currentTimeMillis());
}
/** Seconds left on the access token; Long.MAX_VALUE when it doesn't expire. */
long tokenSecondsLeft() {
long life = sp.getLong("token_lifetime", 0);
if (life <= 0) return Long.MAX_VALUE;
long age = (System.currentTimeMillis() - sp.getLong("token_obtained", 0)) / 1000;
if (age < 0) return 0; // clock went backwards; be safe and refresh
return life - age;
}
long tokenObtainedAt() {
return sp.getLong("token_obtained", 0);
}
void logout() {
sp.edit().remove("access").remove("refresh").remove("mode")
.remove("library_id").remove("library_name").remove("last_episode").commit();
}
void setLibrary(String id, String name) {
sp.edit().putString("library_id", id).putString("library_name", name).commit();
}
String deviceId() {
String id = s("device_id");
if (id.length() == 0) {
id = UUID.randomUUID().toString();
sp.edit().putString("device_id", id).commit();
}
return id;
}
void setLastEpisode(Episode e) {
sp.edit().putString("last_episode", e == null ? "" : e.toJson().toString()).commit();
}
Episode lastEpisode() {
String j = s("last_episode");
if (j.length() == 0) return null;
try {
return Episode.fromStored(new JSONObject(j));
} catch (Exception e) {
return null;
}
}
}
+207
View File
@@ -0,0 +1,207 @@
package com.miniabs;
import java.util.ArrayList;
import java.util.List;
import android.app.Activity;
import android.graphics.drawable.GradientDrawable;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.BaseAdapter;
import android.widget.ImageView;
import android.widget.ProgressBar;
import android.widget.TextView;
/** One list adapter for every screen: section headers, episodes, shows and "load more". */
final class Rows extends BaseAdapter {
static final int HEADER = 0, EPISODE = 1, SHOW = 2, MORE = 3;
static final class Row {
final int type;
String text;
Episode ep;
Show show;
Row(int type) {
this.type = type;
}
static Row header(String t) {
Row r = new Row(HEADER);
r.text = t;
return r;
}
static Row episode(Episode e) {
Row r = new Row(EPISODE);
r.ep = e;
return r;
}
static Row show(Show s) {
Row r = new Row(SHOW);
r.show = s;
return r;
}
static Row more(String t) {
Row r = new Row(MORE);
r.text = t;
return r;
}
}
static final class Show {
String id, title, author;
int episodes;
}
private final Activity act;
private final LayoutInflater inf;
final List<Row> rows = new ArrayList<Row>();
boolean showPodcastName = true;
private final int coverPx;
private final android.util.SparseArray<GradientDrawable> dots = new android.util.SparseArray<GradientDrawable>();
private GradientDrawable dot(int color) {
GradientDrawable cached = dots.get(color);
if (cached != null) return cached;
GradientDrawable d = new GradientDrawable();
dots.put(color, d);
d.setShape(GradientDrawable.OVAL);
d.setColor(color);
return d;
}
Rows(Activity a) {
act = a;
inf = LayoutInflater.from(a);
coverPx = (int) (40 * a.getResources().getDisplayMetrics().density);
}
void set(List<Row> list) {
rows.clear();
rows.addAll(list);
notifyDataSetChanged();
}
@Override
public int getCount() {
return rows.size();
}
@Override
public Row getItem(int i) {
return rows.get(i);
}
@Override
public long getItemId(int i) {
return i;
}
@Override
public int getViewTypeCount() {
return 4;
}
@Override
public int getItemViewType(int i) {
return rows.get(i).type;
}
@Override
public boolean areAllItemsEnabled() {
return false;
}
@Override
public boolean isEnabled(int i) {
return rows.get(i).type != HEADER;
}
@Override
public View getView(int i, View v, ViewGroup parent) {
Row r = rows.get(i);
switch (r.type) {
case HEADER:
if (v == null) v = inf.inflate(R.layout.row_header, parent, false);
((TextView) v).setText(r.text);
return v;
case MORE:
if (v == null) v = inf.inflate(R.layout.row_more, parent, false);
((TextView) v).setText(r.text);
return v;
case SHOW:
if (v == null) v = inf.inflate(R.layout.row_show, parent, false);
bindShow(v, r.show);
return v;
default:
if (v == null) v = inf.inflate(R.layout.row_episode, parent, false);
bindEpisode(v, r.ep);
return v;
}
}
private void bindShow(View v, Show s) {
((TextView) v.findViewById(R.id.title)).setText(s.title);
String n = s.episodes == 1 ? "1 episode" : s.episodes + " episodes";
((TextView) v.findViewById(R.id.meta)).setText(Util.join(s.author, n));
Covers.load(act, (ImageView) v.findViewById(R.id.cover), s.id, coverPx);
}
private void bindEpisode(View v, Episode e) {
TextView show = (TextView) v.findViewById(R.id.show);
TextView title = (TextView) v.findViewById(R.id.title);
TextView meta = (TextView) v.findViewById(R.id.meta);
ProgressBar bar = (ProgressBar) v.findViewById(R.id.progress);
View dotView = v.findViewById(R.id.dot);
if (showPodcastName && e.show.length() > 0) {
show.setVisibility(View.VISIBLE);
show.setText(e.show);
} else {
show.setVisibility(View.GONE);
}
title.setText(e.title);
double[] p = App.progress(e.id);
boolean finished = p != null && p[2] > 0;
double cur = p == null ? 0 : p[0];
double dur = e.duration > 0 ? e.duration : (p == null ? 0 : p[1]);
boolean started = !finished && cur > 5;
String m = Util.date(e.published);
if (finished) {
m = Util.join(m, "Played");
} else if (started && dur > 0) {
m = Util.join(m, Util.duration(Math.max(0, dur - cur)) + " left");
} else if (dur > 0) {
m = Util.join(m, Util.duration(dur));
}
if (DownloadService.busyWith(e.id)) {
Episode now = DownloadService.current;
m = Util.join(m, now != null && now.id.equals(e.id) ? "Saving " + DownloadService.percent + "%" : "Queued");
} else if (Downloads.has(act, e.id)) {
m = Util.join(m, "Saved");
}
PlayerService ps = PlayerService.get();
if (ps != null && ps.episode() != null && ps.episode().id.equals(e.id) && ps.isPlaying()) {
m = Util.join("Now playing", m);
}
meta.setText(m);
int res = act.getResources().getColor(finished ? R.color.dim : R.color.text);
title.setTextColor(res);
if (started && dur > 0) {
bar.setVisibility(View.VISIBLE);
bar.setProgress((int) (1000 * Math.min(1.0, cur / dur)));
} else {
bar.setVisibility(View.GONE);
}
int color = act.getResources().getColor(finished ? R.color.line : started ? R.color.accent : R.color.fresh);
dotView.setBackgroundDrawable(dot(color));
}
}
+77
View File
@@ -0,0 +1,77 @@
package com.miniabs;
import android.app.AlertDialog;
import android.content.DialogInterface;
import android.os.Bundle;
import android.preference.ListPreference;
import android.preference.Preference;
import android.preference.PreferenceActivity;
import android.widget.Toast;
public class SettingsActivity extends PreferenceActivity {
@Override
protected void onCreate(Bundle b) {
super.onCreate(b);
addPreferencesFromResource(R.xml.settings);
final Prefs p = Prefs.get(this);
Preference.OnPreferenceChangeListener skipSummary = new Preference.OnPreferenceChangeListener() {
public boolean onPreferenceChange(Preference pref, Object value) {
pref.setSummary(value + " seconds");
return true;
}
};
ListPreference back = (ListPreference) findPreference("skip_back");
ListPreference fwd = (ListPreference) findPreference("skip_fwd");
back.setSummary(p.skipBack() + " seconds");
fwd.setSummary(p.skipFwd() + " seconds");
back.setOnPreferenceChangeListener(skipSummary);
fwd.setOnPreferenceChangeListener(skipSummary);
findPreference("covers").setOnPreferenceChangeListener(new Preference.OnPreferenceChangeListener() {
public boolean onPreferenceChange(Preference pref, Object value) {
Covers.clearMemory();
return true;
}
});
String who = p.username().length() > 0 ? p.username() : "API key";
findPreference("account").setSummary(who + " @ " + p.server());
findPreference("delete_all").setOnPreferenceClickListener(new Preference.OnPreferenceClickListener() {
public boolean onPreferenceClick(Preference pref) {
new AlertDialog.Builder(SettingsActivity.this)
.setMessage("Delete every downloaded episode from the SD card?")
.setPositiveButton("Delete", new DialogInterface.OnClickListener() {
public void onClick(DialogInterface d, int w) {
PlayerService ps = PlayerService.get();
if (ps != null && ps.isLocal()) ps.stop();
int n = Downloads.deleteAll(SettingsActivity.this);
Toast.makeText(SettingsActivity.this, n + " deleted", Toast.LENGTH_SHORT).show();
}
})
.setNegativeButton("Cancel", null).show();
return true;
}
});
findPreference("logout").setOnPreferenceClickListener(new Preference.OnPreferenceClickListener() {
public boolean onPreferenceClick(Preference pref) {
new AlertDialog.Builder(SettingsActivity.this)
.setMessage("Log out of " + p.server() + "? Downloads stay on the SD card.")
.setPositiveButton("Log out", new DialogInterface.OnClickListener() {
public void onClick(DialogInterface d, int w) {
PlayerService ps = PlayerService.get();
if (ps != null) ps.stop();
p.logout();
App.clear();
HomeActivity.clearCache();
finish(); // Home sees the logout on resume and opens the login screen
}
})
.setNegativeButton("Cancel", null).show();
return true;
}
});
}
}
+107
View File
@@ -0,0 +1,107 @@
package com.miniabs;
import java.io.FileNotFoundException;
import java.net.ConnectException;
import java.net.SocketException;
import java.net.SocketTimeoutException;
import java.net.UnknownHostException;
import java.text.SimpleDateFormat;
import java.util.Calendar;
import java.util.Date;
import java.util.Locale;
import javax.net.ssl.SSLException;
import org.json.JSONObject;
import android.content.Context;
import android.net.ConnectivityManager;
import android.net.NetworkInfo;
import android.text.Html;
final class Util {
private Util() {
}
/** optString that treats JSON null as empty (old org.json returns "null"). */
static String str(JSONObject o, String key) {
if (o == null || o.isNull(key)) return "";
return o.optString(key, "");
}
/** "54 min", "1 h 12 min". */
static String duration(double sec) {
int m = (int) Math.round(sec / 60.0);
if (sec > 0 && m == 0) m = 1;
if (m < 60) return m + " min";
return (m / 60) + " h " + (m % 60) + " min";
}
/** "4:05", "1:02:03". */
static String clock(long ms) {
if (ms < 0) ms = 0;
long s = ms / 1000;
long h = s / 3600, m = (s / 60) % 60, sec = s % 60;
String ss = sec < 10 ? "0" + sec : String.valueOf(sec);
if (h > 0) return h + ":" + (m < 10 ? "0" + m : String.valueOf(m)) + ":" + ss;
return m + ":" + ss;
}
static String date(long ms) {
if (ms <= 0) return "";
Calendar now = Calendar.getInstance(), c = Calendar.getInstance();
c.setTimeInMillis(ms);
String pattern = c.get(Calendar.YEAR) == now.get(Calendar.YEAR) ? "d MMM" : "d MMM yyyy";
return new SimpleDateFormat(pattern, Locale.getDefault()).format(new Date(ms));
}
static String plainText(String html) {
if (html == null || html.length() == 0) return "";
try {
return Html.fromHtml(html).toString().trim();
} catch (Exception e) {
return html;
}
}
static String join(String a, String b) {
if (a == null || a.length() == 0) return b == null ? "" : b;
if (b == null || b.length() == 0) return a;
return a + " · " + b;
}
static boolean onWifi(Context c) {
ConnectivityManager cm = (ConnectivityManager) c.getSystemService(Context.CONNECTIVITY_SERVICE);
NetworkInfo ni = cm == null ? null : cm.getActiveNetworkInfo();
return ni != null && ni.isConnected() && ni.getType() == ConnectivityManager.TYPE_WIFI;
}
static boolean online(Context c) {
ConnectivityManager cm = (ConnectivityManager) c.getSystemService(Context.CONNECTIVITY_SERVICE);
NetworkInfo ni = cm == null ? null : cm.getActiveNetworkInfo();
return ni != null && ni.isConnected();
}
/** Turns an exception into a short message that fits a 240px screen. */
static String describe(Throwable e) {
if (e instanceof Api.AuthException) return e.getMessage();
if (e instanceof Api.ApiException) {
Api.ApiException a = (Api.ApiException) e;
if (a.code == 401) return "Not signed in. Please log in again.";
if (a.code == 403) return "Your account isn't allowed to do that.";
if (a.code == 404) return "Not found on the server.";
String m = a.getMessage();
return "Server error " + a.code + (m != null && m.length() > 0 ? ": " + m : "");
}
if (e instanceof UnknownHostException) return "Server not found. Check the address and your connection.";
if (e instanceof SocketTimeoutException) return "The server took too long to answer.";
if (e instanceof ConnectException) return "Can't connect to the server. Is it running and reachable?";
if (e instanceof SSLException) {
return "Secure (HTTPS) connection failed. Android 2.x can't use modern HTTPS: try your server's local http:// address.";
}
if (e instanceof SocketException) return "Connection dropped. Check your network.";
if (e instanceof FileNotFoundException) return "Not found on the server.";
String m = e.getMessage();
return m == null || m.length() == 0 ? e.getClass().getSimpleName() : m;
}
}
+215
View File
@@ -0,0 +1,215 @@
package com.miniabs;
import java.io.InputStreamReader;
import java.io.StringReader;
import java.net.HttpURLConnection;
import java.net.URL;
import org.json.JSONArray;
import org.json.JSONObject;
import android.content.Context;
/** Runs MiniABS's real API, JSON and progress code against mock_abs.py on the desktop JVM. */
public class ApiTest {
static int pass, fail;
static void check(boolean ok, String what) {
if (ok) pass++;
else fail++;
System.out.println((ok ? " PASS " : " FAIL ") + what);
}
static Object server(String base, String path) throws Exception {
HttpURLConnection c = (HttpURLConnection) new URL(base + path).openConnection();
Object o = Json.parse(new InputStreamReader(c.getInputStream(), "UTF-8"), 100000, null);
c.disconnect();
return o;
}
public static void main(String[] a) throws Exception {
String base = "http://127.0.0.1:" + a[0];
boolean legacy = a.length > 1 && a[1].equals("legacy");
Context c = new Context();
Prefs p = Prefs.get(c);
System.out.println("JSON parser");
Object j = Json.parse(new StringReader(
"{\"a\":[1,-2.5e3,true,false,null,\"caf\\u00e9 \\ud83c\\udfa7 \\\"q\\\" \\\\ /\"],\"big\":12345678901,"
+ "\"chapters\":[{\"x\":[1,2,{\"y\":\"}]\"}]}],\"n\":{}}"), 50, null);
JSONObject jo = (JSONObject) j;
JSONArray arr = jo.getJSONArray("a");
check(arr.getInt(0) == 1 && arr.getDouble(1) == -2500.0 && arr.getBoolean(2) && !arr.getBoolean(3)
&& arr.isNull(4), "numbers, booleans and null");
check(arr.getString(5).equals("café 🎧 \"q\" \\ /"), "string escapes incl. emoji surrogates");
check(jo.getLong("big") == 12345678901L, "long numbers");
check(!jo.has("chapters") && jo.has("n"), "bulky keys skipped, parsing resumes correctly after them");
JSONObject cut = (JSONObject) Json.parse(new StringReader("{\"d\":\"0123456789abcdef\"}"), 10, null);
check(cut.getString("d").equals("0123456789…"), "long strings truncated with an ellipsis");
System.out.println("Sign-in");
p.setServer(LoginActivity_normalize(base + "/login/"), false);
check(p.server().equals(base), "address normalised (" + p.server() + ")");
check(Api.status(c, p.server()).equals("2.30.0"), "status check finds the server");
try {
Api.login(c, p.server(), "demo", "wrong");
check(false, "wrong password rejected");
} catch (Api.ApiException e) {
check(e.code == 401 && e.getMessage().contains("Wrong"), "wrong password rejected with a clear message");
}
String lib = Api.login(c, p.server(), "demo", "pw");
check(lib.equals("lib-pod"), "login returns default library");
if (legacy) {
check(Prefs.MODE_LEGACY.equals(p.mode()) && !p.canRefresh(), "old server: legacy token mode");
check(p.tokenSecondsLeft() > 3500, "legacy token lifetime read");
} else {
check(Prefs.MODE_JWT.equals(p.mode()) && p.canRefresh(), "new server: access + refresh token mode");
long left = p.tokenSecondsLeft();
check(left > 3590 && left <= 3600, "token lifetime read from JWT (" + left + "s)");
}
System.out.println("Browsing");
JSONArray libs = Api.libraries(c);
check(libs.length() == 2, "libraries listed");
JSONObject recent = Api.recentEpisodes(c, "lib-pod", 0, 20);
JSONArray eps = recent.getJSONArray("episodes");
Episode e0 = Episode.fromApi(eps.getJSONObject(0), null, null, null);
check(eps.length() == 20 && e0 != null, "recent episodes parsed");
check(e0.show.equals("Mini Show") && e0.author.equals("Ana Álvarez") && e0.itemId.equals("pod0"),
"show name/author taken from nested podcast");
check(!eps.getJSONObject(0).getJSONObject("podcast").has("episodes"),
"nested podcast episode lists dropped while reading (memory)");
check(e0.ino.equals("ino100") && e0.ext.equals(".mp3") && e0.duration > 1900 && e0.size > 0,
"audio file details parsed");
check(Util.date(e0.published).length() > 0 && Util.duration(e0.duration).equals("32 min"),
"date and duration formatted (" + Util.date(e0.published) + ", " + Util.duration(e0.duration) + ")");
App.loadProgress(c);
double[] pr7 = App.progress("ep7"), pr8 = App.progress("ep8");
check(pr7 != null && pr7[0] == 600.0 && pr7[2] == 0, "in-progress episode position loaded");
check(pr8 != null && pr8[2] == 1, "finished episode flagged");
JSONArray ip = Api.itemsInProgress(c).getJSONArray("libraryItems");
JSONObject it0 = ip.getJSONObject(0);
Episode cont = Episode.fromApi(it0.optJSONObject("recentEpisode"), it0.getString("id"), "Mini Show", "Ana");
check(cont != null && cont.id.equals("ep7"), "continue-listening episode parsed");
check(Episode.fromApi(ip.getJSONObject(1).optJSONObject("recentEpisode"), "book9", "", "") == null,
"books without an episode skipped");
long t0 = System.currentTimeMillis();
JSONObject item = Api.item(c, "pod1");
JSONArray all = item.getJSONObject("media").getJSONArray("episodes");
int maxDesc = 0;
for (int i = 0; i < all.length(); i++) maxDesc = Math.max(maxDesc, all.getJSONObject(i).getString("description").length());
check(all.length() == 300, "300-episode podcast loaded in " + (System.currentTimeMillis() - t0) + " ms");
check(maxDesc <= 801, "show notes capped at 800 chars (max " + maxDesc + ")");
check(!item.has("libraryFiles") && !all.getJSONObject(0).has("chapters"), "file lists and chapters dropped");
Episode e7 = Episode.fromApi(all.getJSONObject(7), "pod1", "Mini Show", "Ana");
check(Util.plainText(e7.desc).startsWith("Show notes café"), "show notes become plain text");
System.out.println("Playback session");
JSONObject sess = Api.startPlayback(c, e7);
check(Util.str(sess, "id").equals("sess1") && sess.getDouble("currentTime") == 600.0, "session opened at saved position");
String cu = sess.getJSONArray("audioTracks").getJSONObject(0).getString("contentUrl");
String url = Api.withToken(c, Api.absolute(c, cu));
check(url.startsWith(base + "/api/items/pod1/file/ino7?token=") && url.endsWith(Api.enc(p.token())),
"stream URL carries the token");
HttpURLConnection sc = (HttpURLConnection) new URL(url).openConnection();
check(sc.getResponseCode() != 401, "server accepts the tokenised stream URL");
sc.disconnect();
Api.syncSession(c, "sess1", 615.5, 15.2, 1807.5);
Api.closeSession(c, "sess1", 620, 4.5, 1807.5);
Api.setProgress(c, "pod1", "ep7", 1807.5, 1807.5, Boolean.TRUE);
JSONArray log = (JSONArray) server(base, "/__log");
JSONObject sync = null, close = null, patch = null, play = null;
for (int i = 0; i < log.length(); i++) {
JSONObject r = log.getJSONObject(i);
String path = r.getString("p");
if (path.equals("/api/session/sess1/sync")) sync = r;
if (path.equals("/api/session/sess1/close")) close = r;
if (path.equals("/api/me/progress/pod1/ep7")) patch = r;
if (path.startsWith("/api/items/pod1/play/")) play = r;
}
check(play != null && play.getJSONObject("b").getBoolean("forceDirectPlay")
&& play.getJSONObject("b").getJSONObject("deviceInfo").getString("clientName").equals("MiniABS"),
"play request asks for direct play and names the client");
check(sync != null && sync.getJSONObject("b").getDouble("currentTime") == 615.5
&& sync.getJSONObject("b").getDouble("timeListened") == 15.2, "progress sync sent");
check(close != null, "session closed");
check(patch != null && patch.getString("m").equals("PATCH")
&& patch.getJSONObject("b").getBoolean("isFinished"), "mark-as-played sent as PATCH");
System.out.println("Offline catch-up");
Episode e9 = Episode.fromApi(all.getJSONObject(9), "pod1", "Mini Show", "Ana");
App.saveLocal(c, e9, 321.0, 1809.5, true);
check(App.localPending(c, "ep9") && App.localPosition(c, "ep9") == 321.0, "position saved on the phone");
App.loadProgress(c);
check(App.progress("ep9")[0] == 321.0, "unsent local position wins over server's");
int sent = App.flushPending(c);
check(sent == 1 && !App.localPending(c, "ep9"), "pending position sent when back online");
if (!legacy) {
System.out.println("Token refresh");
String before = p.token();
server(base, "/__expire");
JSONArray again = Api.libraries(c);
check(again.length() == 2 && !p.token().equals(before), "expired token refreshed transparently after 401");
check(p.refreshToken().equals("refresh-" + (p.token().contains("\"n\"") ? "" : "")) || p.refreshToken().startsWith("refresh-"),
"rotated refresh token stored");
server(base, "/__expire");
int r0 = ((JSONObject) server(base, "/__refreshes")).getInt("n");
final Context fc = c;
final Exception[] errs = new Exception[3];
Thread[] ts = new Thread[3];
for (int i = 0; i < 3; i++) {
final int k = i;
ts[i] = new Thread() {
public void run() {
try {
Api.libraries(fc);
} catch (Exception ex) {
errs[k] = ex;
}
}
};
ts[i].start();
}
for (Thread t : ts) t.join();
int r1 = ((JSONObject) server(base, "/__refreshes")).getInt("n");
check(errs[0] == null && errs[1] == null && errs[2] == null && r1 - r0 == 1,
"3 requests failing at once trigger exactly one refresh (" + (r1 - r0) + ")");
// Simulate the token nearing expiry on the phone's clock: refreshed before the request.
p.sp.edit().putLong("token_obtained", System.currentTimeMillis() - 3550 * 1000L).commit();
String old = p.token();
Api.libraries(c);
check(!p.token().equals(old), "token refreshed early when about to expire");
server(base, "/__revoke");
try {
Api.libraries(c);
check(false, "revoked sign-in detected");
} catch (Api.AuthException ex) {
check(true, "revoked sign-in sends user back to login: \"" + ex.getMessage() + "\"");
}
}
System.out.println("Sub-path servers");
p.setServer("http://nas.local:8080/abs", false);
check(Api.absolute(c, "/abs/api/items/x/file/1").equals("http://nas.local:8080/abs/api/items/x/file/1"),
"content URL under a sub-path not doubled");
check(Api.absolute(c, "/api/items/x/file/1").equals("http://nas.local:8080/abs/api/items/x/file/1"),
"content URL without the sub-path prefixed");
System.out.println();
System.out.println(pass + " passed, " + fail + " failed");
System.exit(fail == 0 ? 0 : 1);
}
/** Same rules as LoginActivity.normalize (the Activity itself can't load off-device). */
static String LoginActivity_normalize(String s) {
s = s.trim();
while (s.endsWith("/")) s = s.substring(0, s.length() - 1);
if (s.endsWith("/login")) s = s.substring(0, s.length() - 6);
if (s.length() > 0 && !s.startsWith("http://") && !s.startsWith("https://")) s = "http://" + s;
return s;
}
}
+179
View File
@@ -0,0 +1,179 @@
"""A small mock of the Audiobookshelf 2.x API, shaped like the real server's replies.
Usage: python3 mock_abs.py PORT [legacy]
legacy: /login returns only the old long-lived user.token (pre-2.26 servers).
Test-only endpoints: /__expire (invalidate the access token), /__revoke (invalidate the
refresh token too), /__log (requests seen so far).
"""
import base64
import json
import sys
import threading
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
PORT = int(sys.argv[1])
LEGACY = len(sys.argv) > 2 and sys.argv[2] == "legacy"
lock = threading.Lock()
state = {"access": None, "refresh": None, "n": 0, "log": [], "progress": {}, "refreshes": 0}
def b64(d):
return base64.urlsafe_b64encode(json.dumps(d).encode()).rstrip(b"=").decode()
def new_tokens():
state["n"] += 1
now = int(time.time())
state["access"] = b64({"alg": "HS256"}) + "." + b64({"userId": "u1", "iat": now, "exp": now + 3600, "n": state["n"]}) + ".sig"
state["refresh"] = "refresh-%d" % state["n"]
def episode(i, item="pod1", long_desc=False):
desc = ("<p>Show notes café \U0001F3A7 " + "x" * (5000 if long_desc else 50) + "</p>")
return {
"libraryItemId": item, "podcastId": "p-" + item, "id": "ep%d" % i, "index": i,
"title": "Episode %d – ¿Qué tal?" % i, "subtitle": "", "description": desc,
"publishedAt": 1700000000000 + i * 86400000, "pubDate": "x",
"chapters": [{"id": c, "start": c * 60, "end": c * 60 + 60, "title": "ch"} for c in range(30)],
"enclosure": {"url": "http://feed/ep%d.mp3" % i, "type": "audio/mpeg", "length": "123"},
"audioFile": {"index": 1, "ino": "ino%d" % i, "duration": 1800.5 + i, "mimeType": "audio/mpeg",
"metadata": {"filename": "ep%d.mp3" % i, "ext": ".mp3", "size": 30000000 + i},
"metaTags": {"tagAlbum": "x"}},
}
def podcast_media(n_eps, item="pod1"):
return {"id": "p-" + item, "metadata": {"title": "Mini Show", "author": "Ana Álvarez"},
"episodes": [episode(i, item, long_desc=True) for i in range(n_eps)]}
class H(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
def log_message(self, *a):
pass
def send(self, code, body=None, ctype="application/json"):
data = b""
if body is not None:
data = (json.dumps(body) if ctype == "application/json" else body).encode()
self.send_response(code)
self.send_header("Content-Type", ctype + "; charset=utf-8")
self.send_header("Content-Length", str(len(data)))
self.send_header("Connection", "close")
self.end_headers()
self.wfile.write(data)
def body(self):
n = int(self.headers.get("Content-Length") or 0)
raw = self.rfile.read(n) if n else b""
return json.loads(raw) if raw else None
def authed(self):
auth = self.headers.get("Authorization", "")
tok = auth[7:] if auth.startswith("Bearer ") else None
if tok is None and "token=" in self.path:
tok = self.path.split("token=")[1].split("&")[0]
return tok is not None and tok == state["access"]
def handle_any(self, method):
path = self.path.split("?")[0]
b = self.body() if method in ("POST", "PATCH") else None
with lock:
state["log"].append({"m": method, "p": self.path, "b": b,
"rt": self.headers.get("x-return-tokens"), "xr": self.headers.get("x-refresh-token")})
if path == "/__log":
return self.send(200, state["log"])
if path == "/__refreshes":
return self.send(200, {"n": state["refreshes"]})
if path == "/__expire":
state["access"] = "expired"
return self.send(200, {})
if path == "/__revoke":
state["access"] = "expired"
state["refresh"] = "revoked"
return self.send(200, {})
if path == "/status":
return self.send(200, {"app": "audiobookshelf", "serverVersion": "2.30.0", "isInit": True,
"authMethods": ["local"]})
if path == "/login" and method == "POST":
if not b or b.get("username") != "demo" or b.get("password") != "pw":
return self.send(401, "Unauthorized", "text/plain")
new_tokens()
user = {"id": "u1", "username": "demo", "token": state["access"] if LEGACY else "legacy-tok"}
if LEGACY:
user["token"] = state["access"]
elif self.headers.get("x-return-tokens") == "true":
user["accessToken"] = state["access"]
user["refreshToken"] = state["refresh"]
return self.send(200, {"user": user, "userDefaultLibraryId": "lib-pod",
"serverSettings": {}})
if path == "/auth/refresh" and method == "POST":
if self.headers.get("x-refresh-token") != state["refresh"]:
return self.send(401, "Unauthorized", "text/plain")
state["refreshes"] += 1
new_tokens()
return self.send(200, {"user": {"id": "u1", "accessToken": state["access"],
"refreshToken": state["refresh"]}})
if not self.authed():
# Real ABS answers 401 without a WWW-Authenticate header
return self.send(401, "Unauthorized", "text/plain")
if path == "/api/libraries":
return self.send(200, {"libraries": [
{"id": "lib-book", "name": "Books", "mediaType": "book"},
{"id": "lib-pod", "name": "Podcasts", "mediaType": "podcast"}]})
if path == "/api/libraries/lib-pod/recent-episodes":
eps = []
for i in range(20):
e = episode(100 + i, "pod%d" % (i % 3))
e["podcast"] = podcast_media(40, "pod%d" % (i % 3)) # the real server nests the whole podcast
e["podcast"]["libraryItem"] = {"id": "pod%d" % (i % 3), "libraryId": "lib-pod"}
eps.append(e)
return self.send(200, {"episodes": eps, "limit": 20, "page": 0})
if path == "/api/me/items-in-progress":
return self.send(200, {"libraryItems": [
{"id": "pod1", "libraryId": "lib-pod", "mediaType": "podcast",
"media": {"metadata": {"title": "Mini Show", "author": "Ana"}, "numEpisodes": 300},
"recentEpisode": episode(7), "progressLastUpdate": 1},
{"id": "book9", "libraryId": "lib-book", "mediaType": "book", "media": {"metadata": {"title": "B"}}}]})
if path == "/api/me":
mp = [{"libraryItemId": "pod1", "episodeId": "ep7", "currentTime": 600.0, "duration": 1807.5,
"progress": 0.33, "isFinished": False},
{"libraryItemId": "pod1", "episodeId": "ep8", "currentTime": 1808.5, "duration": 1808.5,
"progress": 1, "isFinished": True},
{"libraryItemId": "book9", "episodeId": None, "currentTime": 5, "isFinished": False}]
return self.send(200, {"id": "u1", "username": "demo", "mediaProgress": mp, "bookmarks": []})
if path == "/api/libraries/lib-pod/items":
res = [{"id": "pod%d" % i, "libraryId": "lib-pod",
"media": {"metadata": {"title": "Show %d" % i, "author": "A"}, "numEpisodes": i}}
for i in range(3)]
return self.send(200, {"results": res, "total": 3, "limit": 100, "page": 0})
if path == "/api/items/pod1":
item = {"id": "pod1", "libraryId": "lib-pod", "media": podcast_media(300),
"libraryFiles": [{"ino": "x%d" % i, "metadata": {"path": "/p" * 20}} for i in range(300)]}
return self.send(200, item)
if path.startswith("/api/items/pod1/play/") and method == "POST":
return self.send(200, {"id": "sess1", "libraryItemId": "pod1", "episodeId": path.split("/")[-1],
"currentTime": 600.0, "duration": 1807.5, "playMethod": 0,
"audioTracks": [{"index": 1, "contentUrl": "/api/items/pod1/file/ino7",
"mimeType": "audio/mpeg", "duration": 1807.5}]})
if path.startswith("/api/session/") and method == "POST":
return self.send(200, "OK", "text/plain")
if path.startswith("/api/me/progress/") and method == "PATCH":
state["progress"][path] = b
return self.send(200, "OK", "text/plain")
return self.send(404, "Not Found", "text/plain")
def do_GET(self):
self.handle_any("GET")
def do_POST(self):
self.handle_any("POST")
def do_PATCH(self):
self.handle_any("PATCH")
ThreadingHTTPServer(("127.0.0.1", PORT), H).serve_forever()
+20
View File
@@ -0,0 +1,20 @@
#!/bin/sh
# Runs MiniABS's API/JSON/progress code on the desktop JVM against mock_abs.py.
set -e
cd "$(dirname "$0")/.."
OUT=build/test
JSON_JAR=/usr/share/java/com.android.json.jar
rm -rf "$OUT" && mkdir -p "$OUT"
S=src/com/miniabs
javac -nowarn -encoding UTF-8 -cp "$JSON_JAR" -d "$OUT" $(find test/shim -name '*.java') \
$S/Json.java $S/Prefs.java $S/Episode.java $S/Util.java $S/Api.java $S/App.java test/com/miniabs/ApiTest.java
for mode in current legacy; do
PORT=$((18000 + $(od -An -N2 -tu2 /dev/urandom | tr -d ' ') % 1000))
python3 test/mock_abs.py $PORT $mode &
PID=$!
sleep 1
echo "=== $mode server"
java -cp "$OUT:$JSON_JAR" com.miniabs.ApiTest $PORT $mode || { kill $PID; exit 1; }
kill $PID
done
+15
View File
@@ -0,0 +1,15 @@
package android.content;
import java.util.HashMap;
public class Context {
public static final int MODE_PRIVATE = 0;
public static final String CONNECTIVITY_SERVICE = "connectivity";
private final HashMap<String, SharedPreferences> prefs = new HashMap<String, SharedPreferences>();
public static boolean online = true;
public Context getApplicationContext() { return this; }
public synchronized SharedPreferences getSharedPreferences(String name, int mode) {
SharedPreferences p = prefs.get(name);
if (p == null) { p = new SharedPreferences(); prefs.put(name, p); }
return p;
}
public Object getSystemService(String name) { return new android.net.ConnectivityManager(); }
}
@@ -0,0 +1,21 @@
package android.content;
import java.util.HashMap;
import java.util.Map;
public class SharedPreferences {
final HashMap<String, Object> m = new HashMap<String, Object>();
public synchronized String getString(String k, String d) { Object v = m.get(k); return v == null ? d : (String) v; }
public synchronized boolean getBoolean(String k, boolean d) { Object v = m.get(k); return v == null ? d : (Boolean) v; }
public synchronized long getLong(String k, long d) { Object v = m.get(k); return v == null ? d : (Long) v; }
public synchronized int getInt(String k, int d) { Object v = m.get(k); return v == null ? d : (Integer) v; }
public synchronized Map<String, ?> getAll() { return new HashMap<String, Object>(m); }
public Editor edit() { return new Editor(); }
public class Editor {
final HashMap<String, Object> put = new HashMap<String, Object>(); final java.util.HashSet<String> rm = new java.util.HashSet<String>();
public Editor putString(String k, String v) { put.put(k, v); return this; }
public Editor putBoolean(String k, boolean v) { put.put(k, v); return this; }
public Editor putLong(String k, long v) { put.put(k, v); return this; }
public Editor putInt(String k, int v) { put.put(k, v); return this; }
public Editor remove(String k) { rm.add(k); return this; }
public boolean commit() { synchronized (SharedPreferences.this) { for (String k : rm) m.remove(k); m.putAll(put); } return true; }
}
}
@@ -0,0 +1,5 @@
package android.net;
public class ConnectivityManager {
public static final int TYPE_WIFI = 1;
public NetworkInfo getActiveNetworkInfo() { return android.content.Context.online ? new NetworkInfo() : null; }
}
+2
View File
@@ -0,0 +1,2 @@
package android.net;
public class NetworkInfo { public boolean isConnected() { return true; } public int getType() { return 1; } }
+5
View File
@@ -0,0 +1,5 @@
package android.os;
public class Build {
public static final String MANUFACTURER = "samsung", MODEL = "GT-S5570";
public static class VERSION { public static final String RELEASE = "2.2.1"; public static final int SDK_INT = 8; }
}
@@ -0,0 +1,5 @@
package android.preference;
import android.content.Context; import android.content.SharedPreferences;
public class PreferenceManager {
public static SharedPreferences getDefaultSharedPreferences(Context c) { return c.getSharedPreferences("default", 0); }
}
+2
View File
@@ -0,0 +1,2 @@
package android.text;
public class Html { public static CharSequence fromHtml(String s) { return s.replaceAll("<[^>]+>", ""); } }
+5
View File
@@ -0,0 +1,5 @@
package android.util;
public class Base64 {
public static final int URL_SAFE = 8;
public static byte[] decode(String s, int flags) { return java.util.Base64.getUrlDecoder().decode(s); }
}
+96
View File
@@ -0,0 +1,96 @@
"""Draws MiniABS's PNG icons for ldpi (Galaxy Mini), mdpi and hdpi.
Android 2.x has no vector drawables, so every icon is rendered here at 4x and
downsampled for smooth edges.
"""
import os
import sys
from PIL import Image, ImageDraw
RES = sys.argv[1]
DENS = {"ldpi": 0.75, "mdpi": 1.0, "hdpi": 1.5}
AMBER = (245, 166, 35, 255)
DARK = (18, 21, 25, 255)
WHITE = (255, 255, 255, 255)
SS = 4 # supersampling
def canvas(px):
s = px * SS
return Image.new("RGBA", (s, s), (0, 0, 0, 0)), s
def save(img, px, folder, name):
out = img.resize((px, px), Image.LANCZOS)
d = os.path.join(RES, "drawable-" + folder)
os.makedirs(d, exist_ok=True)
out.save(os.path.join(d, name + ".png"))
def broadcast(d, s, cx, cy, scale, color, bg):
"""Podcast mark: a dot with two arcs on each side."""
r = s * 0.075 * scale
d.ellipse([cx - r, cy - r, cx + r, cy + r], fill=color)
w = max(1, int(s * 0.07 * scale))
for k in (0.22, 0.37):
rr = s * k * scale
box = [cx - rr, cy - rr, cx + rr, cy + rr]
d.arc(box, 140, 220, fill=color, width=w)
d.arc(box, -40, 40, fill=color, width=w)
def launcher(px, folder):
img, s = canvas(px)
d = ImageDraw.Draw(img)
m = s * 0.06
d.rounded_rectangle([m, m, s - m, s - m], radius=s * 0.2, fill=DARK, outline=AMBER, width=int(s * 0.04))
broadcast(d, s, s / 2, s * 0.44, 1.0, AMBER, DARK)
# little stand under the mark
w = s * 0.05
d.rectangle([s / 2 - w / 2, s * 0.5, s / 2 + w / 2, s * 0.74], fill=AMBER)
d.rounded_rectangle([s * 0.36, s * 0.72, s * 0.64, s * 0.78], radius=w / 2, fill=AMBER)
save(img, px, folder, "ic_launcher")
def status(px, folder):
img, s = canvas(px)
d = ImageDraw.Draw(img)
broadcast(d, s, s / 2, s / 2, 1.25, WHITE, None)
save(img, px, folder, "ic_stat")
def play(px, folder, name, color):
img, s = canvas(px)
d = ImageDraw.Draw(img)
d.polygon([(s * 0.32, s * 0.2), (s * 0.32, s * 0.8), (s * 0.82, s * 0.5)], fill=color)
save(img, px, folder, name)
def pause(px, folder, name, color):
img, s = canvas(px)
d = ImageDraw.Draw(img)
d.rectangle([s * 0.26, s * 0.2, s * 0.43, s * 0.8], fill=color)
d.rectangle([s * 0.57, s * 0.2, s * 0.74, s * 0.8], fill=color)
save(img, px, folder, name)
def refresh(px, folder):
img, s = canvas(px)
d = ImageDraw.Draw(img)
w = int(s * 0.1)
box = [s * 0.2, s * 0.2, s * 0.8, s * 0.8]
d.arc(box, 30, 320, fill=WHITE, width=w)
# arrow head at the arc's end (top right)
d.polygon([(s * 0.86, s * 0.14), (s * 0.88, s * 0.48), (s * 0.56, s * 0.36)], fill=WHITE)
save(img, px, folder, "ic_refresh")
for folder, k in DENS.items():
launcher(round(48 * k), folder)
status(round(25 * k), folder)
play(round(40 * k), folder, "ic_play", DARK)
pause(round(40 * k), folder, "ic_pause", DARK)
play(round(28 * k), folder, "ic_play_small", AMBER)
pause(round(28 * k), folder, "ic_pause_small", AMBER)
refresh(round(24 * k), folder)
print("icons ok")