Import miniabs 1.0 source
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
No files matched your search
@@ -0,0 +1,3 @@
|
|||||||
|
build/
|
||||||
|
.DS_Store
|
||||||
|
._*
|
||||||
@@ -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>
|
||||||
@@ -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`).
|
||||||
@@ -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"
|
||||||
|
After Width: | Height: | Size: 5.2 KiB |
|
After Width: | Height: | Size: 499 B |
|
After Width: | Height: | Size: 346 B |
|
After Width: | Height: | Size: 650 B |
|
After Width: | Height: | Size: 880 B |
|
After Width: | Height: | Size: 816 B |
|
After Width: | Height: | Size: 690 B |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 381 B |
|
After Width: | Height: | Size: 349 B |
|
After Width: | Height: | Size: 470 B |
|
After Width: | Height: | Size: 527 B |
|
After Width: | Height: | Size: 377 B |
|
After Width: | Height: | Size: 326 B |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 408 B |
|
After Width: | Height: | Size: 413 B |
|
After Width: | Height: | Size: 513 B |
|
After Width: | Height: | Size: 656 B |
|
After Width: | Height: | Size: 463 B |
|
After Width: | Height: | Size: 457 B |
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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" />
|
||||||
@@ -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" />
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
package android.net;
|
||||||
|
public class NetworkInfo { public boolean isConnected() { return true; } public int getType() { return 1; } }
|
||||||
@@ -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); }
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
package android.text;
|
||||||
|
public class Html { public static CharSequence fromHtml(String s) { return s.replaceAll("<[^>]+>", ""); } }
|
||||||
@@ -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); }
|
||||||
|
}
|
||||||
@@ -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")
|
||||||