Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c771aa01bd | ||
|
|
666a7eb8cd | ||
|
|
b2ca212c45 | ||
|
|
4044303764 | ||
|
|
04ac5ae725 | ||
|
|
084d7e9cad | ||
|
|
db1175d25c | ||
|
|
10a2aa9ef5 |
No files matched your search
@@ -1,9 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- build.sh makes one APK per Android level from this file: it sets minSdkVersion and targetSdkVersion for each,
|
||||
and versionCode = the one below x 100 + the level (13 -> 1308 for Android 2.2, 1316 for 4.1). -->
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
package="com.miniabs"
|
||||
android:versionCode="10"
|
||||
android:versionName="1.9"
|
||||
android:installLocation="auto">
|
||||
android:versionCode="13"
|
||||
android:versionName="1.12"
|
||||
android:installLocation="internalOnly">
|
||||
|
||||
<uses-sdk android:minSdkVersion="8" android:targetSdkVersion="10" />
|
||||
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
# Podlite
|
||||
|
||||
A tiny Audiobookshelf **podcast** client for the Samsung Galaxy Mini (GT-S5570) and other Android 2.2+ phones. Native Java; the APK is about 1.1 MB, nearly all of it the bundled TLS stack. Formerly MiniABS. The interface is in Galician, with large text and touch targets sized for the Mini's 240×320 screen. The launcher icon is the "Podcasts Radio" pack (master in `art/icon-master.png`); `tools/make_icons.py` draws the notification icon (a white radio silhouette) and the player icons. The purple colour scheme comes from the earlier Audiobookshelf icon.
|
||||
A tiny Audiobookshelf **podcast** client for the Samsung Galaxy Mini (GT-S5570) and other Android 2.2+ phones, with a
|
||||
second build for Android 4.1 and later (Xperia J, Galaxy S II…): see *Android 4.1 build*. Native Java; the APK is about 1.1 MB, nearly all of it the bundled TLS stack. Formerly MiniABS. The interface is in Galician, with large text and touch targets sized for the Mini's 240×320 screen. The launcher icon is the "Podcasts Radio" pack (master in `art/icon-master.png`); `tools/make_icons.py` draws the notification icon (a white radio silhouette) and the player icons. Its warm dark colours and amber accent come from the radio: brass and the glow of its dial.
|
||||
|
||||
## Install
|
||||
|
||||
1. Copy `Podlite.apk` to the phone (USB to the SD card, or download it in the browser).
|
||||
1. Copy `Podlite.apk` (Android 4.1+: `Podlite-api16.apk`; Tenda picks the right one by itself) 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 → Podlite → Move to SD card).
|
||||
3. Open the APK from a file manager and install. Podlite installs to internal storage (about 1 MB): an app on the SD card is killed whenever the phone shares its SD card over USB, which stopped playback and broke HTTPS until a restart.
|
||||
|
||||
## Connecting
|
||||
|
||||
@@ -48,19 +49,44 @@ Colored dots: blue = new, amber = in progress (with a bar), grey = played.
|
||||
|
||||
Playback opens an Audiobookshelf session and syncs every 15 seconds and on pause, so progress follows you to other apps. An episode only counts as played when playback gets within a minute of its end: a stream that drops or a saved file cut short is retried or reported, never marked as played. If playback says it's playing but stops moving for 12 seconds, Podlite says so. 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).
|
||||
|
||||
## Android 4.1 build
|
||||
|
||||
One build per **Android level**, never per phone (the same pattern as Tenda and Plexamp Lite): `build/Podlite.apk`
|
||||
for Android 2.2+ and `build/Podlite-api16.apk` for 4.1+, from the same source, package and key. `versionCode` is
|
||||
the manifest's × 100 + the level (13 → 1308 and 1316), so a phone that moves up a level gets the newer build as an
|
||||
ordinary update. What only one level can do is in `platform/api8/…/Platform.java` and `platform/api16/…/Platform.java`
|
||||
(the same methods; keep them in step), plus `platform/api16/res` (Holo). The Android 4.1 build adds:
|
||||
|
||||
- **ExoPlayer 2.18** (`platform/api16/…/ExoEngine.java`) behind the small `Engine` interface the player service
|
||||
uses (`MediaPlayerEngine` on Android 2.x), with **playback speed** (the button at the top of the player: 0,8× to 2×)
|
||||
and **skip silences** (Settings → Reprodución). It keeps up to 3 minutes buffered. An episode ExoPlayer can't
|
||||
decode is retried with Android's own player.
|
||||
- A **notification** with the cover and −15 ⏯ +30 ✕ (pull it down for a larger one); swiping it away while paused stops.
|
||||
- **Lock-screen controls** with the cover (⏮ ⏭ skip back and forward).
|
||||
- Android 4's **Holo** dialogs, menus and Settings; a ⋮ button on phones without a Menu key.
|
||||
- "Download on Wi-Fi only" means any network that doesn't cost: a phone's Wi-Fi hotspot counts as mobile data.
|
||||
|
||||
## 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.
|
||||
- No playback speed control on Android 2.x (its media player doesn't support it); the Android 4.1 build has 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`), an API 8 `android.jar`, and the renamed Bouncy Castle sources from Plexamp Lite's `tools/setup_toolchain.sh` (`BC_SRC`, default `~/android-toolchain/bcsrc`):
|
||||
With the toolchain Plexamp Lite's `tools/setup_toolchain.sh` prepares (`~/android-toolchain`: aapt2, dx, the API 8
|
||||
jar, Bouncy Castle renamed to `com.minamp.bc`, a Python venv with `cryptography`), plus for the Android 4.1 build the
|
||||
API 16 jar, ExoPlayer (`tools/fetch_exoplayer.sh`), R8 (`d8.jar` from build-tools 34, in
|
||||
`~/android-toolchain/calendar-tools/`) and a JDK 11+ (`build.sh` finds Homebrew's openjdk@17). No Gradle, no SDK:
|
||||
|
||||
ANDROID_JAR=/path/to/android-8/android.jar ./build.sh
|
||||
./build.sh # -> build/Podlite.apk (Android 2.2+) and build/Podlite-api16.apk (4.1+)
|
||||
./build.sh 16 # one level only
|
||||
|
||||
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.
|
||||
Per level: aapt2 → javac (Java 8 bytecode against that level's `android.jar`) → for Android 2.x the Dalvik 2.x link
|
||||
check and dx, for 4.1 R8 (`tools/r8-rules.pro`: keeps the app whole, shrinks ExoPlayer and Guava, one dex) →
|
||||
`tools/signapk.py` (v1, SHA-1, the classic SignApk format Android ≤ 4.2 needs) → `tools/check_dex.py`. The APK is
|
||||
signed with `miniabs.keystore` (JKS, password and alias `miniabs`; build.sh converts it to a PKCS#12 copy in `build/`
|
||||
for the run); keep it to install updates over the top.
|
||||
|
||||
All sizes (text, rows, buttons, covers) are in `res/values/dimens.xml`; change them there and rebuild.
|
||||
|
||||
|
||||
@@ -1,38 +1,104 @@
|
||||
#!/bin/sh
|
||||
# Builds Podlite.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
|
||||
#!/bin/bash
|
||||
# Builds Podlite without the Android SDK or Gradle, one APK per Android level (same package and key):
|
||||
# build/Podlite.apk Android 2.2 and later (Galaxy Mini) src + platform/api8, against android-8
|
||||
# build/Podlite-api16.apk Android 4.1 and later (Xperia J...) src + platform/api16 + ExoPlayer, against android-16
|
||||
# Each: aapt2 (resources; api16 overlays platform/api16/res) -> javac (Java 8 bytecode) -> then
|
||||
# api8: Dalvik 2.x link check -> dx (dex 035)
|
||||
# api16: R8 (shrinks ExoPlayer and Guava, desugars Java 8, dex 035 for Android 4.1)
|
||||
# -> signapk.py (v1 / SHA-1 in the classic SignApk format, plus zipalign; Android <= 4.2 rejects jarsigner's).
|
||||
# versionCode = the manifest's x 100 + the level, so a phone that moves up to Android 4.1 gets the newer build as an update.
|
||||
# Toolchain (TOOLS, default ~/android-toolchain) from Plexamp Lite's tools/setup_toolchain.sh, plus tools/fetch_exoplayer.sh,
|
||||
# the android-16 jar and R8 (d8.jar from build-tools 34) for api16.
|
||||
# ./build.sh both
|
||||
# ./build.sh 16 one level
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")"
|
||||
ANDROID_JAR=${ANDROID_JAR:-/home/claude/sable/android-platforms/android-8/android.jar}
|
||||
KEYSTORE=${KEYSTORE:-miniabs.keystore}
|
||||
BC_SRC=${BC_SRC:-$HOME/android-toolchain/bcsrc} # Bouncy Castle renamed to com.minamp.bc (Minamp's tools/setup_toolchain.sh)
|
||||
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 -A assets -I "$ANDROID_JAR" \
|
||||
-F "$OUT/Podlite.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" -sourcepath "src:$BC_SRC:$OUT/gen" \
|
||||
-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 Podlite.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=Podlite" > /dev/null 2>&1
|
||||
T=${TOOLS:-$HOME/android-toolchain}
|
||||
BC_SRC=$T/bcsrc # Bouncy Castle renamed to com.minamp.bc
|
||||
EXO=$T/exoplayer-2.18.7
|
||||
R8=${R8:-$T/calendar-tools/d8.jar} # build-tools' d8.jar holds R8 too
|
||||
PY=${PY:-$T/venv/bin/python} # signapk.py needs the cryptography package
|
||||
[ -x "$PY" ] || PY=python3
|
||||
KS=${KEYSTORE:-miniabs.keystore} # JKS, password and alias "miniabs": keep it, updates only install over the same key
|
||||
LEVELS=${*:-8 16}
|
||||
# a JDK 11+ (R8 needs it): the macOS java stub answers "javac" but can't run it
|
||||
if ! javac -version >/dev/null 2>&1 || javac -version 2>&1 | grep -q '1\.8'; then
|
||||
for j in "${JAVA_HOME:-}" /opt/homebrew/opt/openjdk@17 /opt/homebrew/opt/openjdk $(ls -d "$HOME"/tools/jdk-17* 2>/dev/null); do
|
||||
if [ -n "$j" ] && [ -x "$j/bin/javac" ]; then export JAVA_HOME=$j PATH=$j/bin:$PATH; break; fi
|
||||
done
|
||||
fi
|
||||
zipalign -f 4 "$OUT/Podlite.unsigned.apk" "$OUT/Podlite.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/Podlite.apk" "$OUT/Podlite.aligned.apk"
|
||||
apksigner verify --min-sdk-version 8 "$OUT/Podlite.apk"
|
||||
ls -l "$OUT/Podlite.apk"
|
||||
|
||||
mkdir -p build
|
||||
# signapk.py reads PKCS#12: convert the JKS key into build/ for this run (the original stays as it is)
|
||||
P12=build/signing.p12
|
||||
rm -f $P12
|
||||
keytool -importkeystore -srckeystore "$KS" -srcstoretype JKS -srcstorepass ${KEYPASS:-miniabs} -srcalias miniabs \
|
||||
-destkeystore $P12 -deststoretype PKCS12 -deststorepass ${KEYPASS:-miniabs} -destkeypass ${KEYPASS:-miniabs} \
|
||||
-noprompt >/dev/null 2>&1
|
||||
trap 'rm -f build/signing.p12' EXIT
|
||||
|
||||
CODE=$(sed -n 's/.*android:versionCode="\([0-9]*\)".*/\1/p' AndroidManifest.xml)
|
||||
|
||||
for API in $LEVELS; do
|
||||
case $API in
|
||||
8) TARGET=10; APK=build/Podlite.apk ;;
|
||||
16) TARGET=16; APK=build/Podlite-api16.apk
|
||||
[ -f "$EXO/exoplayer-core.jar" ] || { echo "no ExoPlayer in $EXO: run tools/fetch_exoplayer.sh"; exit 1; }
|
||||
[ -f "$R8" ] || { echo "no R8 at $R8 (build-tools 34's lib/d8.jar): set R8="; exit 1; } ;;
|
||||
*) echo "no such level: $API (8 or 16)"; exit 1 ;;
|
||||
esac
|
||||
ANDROID_JAR=$T/platforms/android-$API/android.jar
|
||||
OUT=build/api$API
|
||||
echo "== Android level $API -> $APK (versionCode $((CODE * 100 + API)))"
|
||||
rm -rf $OUT && mkdir -p $OUT/res $OUT/gen $OUT/classes
|
||||
|
||||
# this level's manifest; Android 3.2+ also counts a rotation as a screen size change; Holo settings on 4.1
|
||||
sed -e "s/android:minSdkVersion=\"[0-9]*\"/android:minSdkVersion=\"$API\"/" \
|
||||
-e "s/android:targetSdkVersion=\"[0-9]*\"/android:targetSdkVersion=\"$TARGET\"/" \
|
||||
$( [ $API -ge 13 ] && echo '-e s/keyboardHidden|orientation"/keyboardHidden|orientation|screenSize"/' ) \
|
||||
$( [ $API -ge 14 ] && echo '-e s#android:theme="@android:style/Theme"#android:theme="@android:style/Theme.Holo"#' ) \
|
||||
AndroidManifest.xml > $OUT/AndroidManifest.xml
|
||||
|
||||
$T/aapt2 compile --dir res -o $OUT/res/compiled.zip
|
||||
OVERLAY=
|
||||
if [ -d platform/api$API/res ]; then
|
||||
$T/aapt2 compile --dir platform/api$API/res -o $OUT/res/overlay.zip
|
||||
OVERLAY="-R $OUT/res/overlay.zip"
|
||||
fi
|
||||
$T/aapt2 link -o $OUT/base.apk -I $ANDROID_JAR --manifest $OUT/AndroidManifest.xml \
|
||||
--java $OUT/gen -A assets --version-code $((CODE * 100 + API)) --replace-version \
|
||||
--no-version-vectors $OUT/res/compiled.zip $OVERLAY
|
||||
|
||||
LIBS=
|
||||
[ $API -ge 16 ] && LIBS=$(ls $EXO/*.jar | tr '\n' ':')
|
||||
find src platform/api$API $OUT/gen -name '*.java' > $OUT/sources.txt
|
||||
javac -nowarn -Xlint:-options -source 8 -target 8 -encoding UTF-8 -g:source,lines \
|
||||
-bootclasspath $ANDROID_JAR -classpath "$LIBS" -sourcepath "src:platform/api$API:$BC_SRC:$OUT/gen" \
|
||||
-d $OUT/classes @$OUT/sources.txt 2>&1 | grep -v -E '^Picked up|^Note:|^warning: Cannot find annotation|^ reason: class file for|^[0-9]+ warnings?$' || true
|
||||
test -f $OUT/classes/com/miniabs/HomeActivity.class
|
||||
|
||||
if [ $API -lt 11 ]; then
|
||||
echo "-- Dalvik 2.x link check"
|
||||
python3 tools/scan_dalvik.py $OUT/classes $ANDROID_JAR > $OUT/dalvik-scan.txt
|
||||
if ! grep -q "^total patterns: 0$" $OUT/dalvik-scan.txt; then
|
||||
cat $OUT/dalvik-scan.txt; echo "Calls Android 2.x can't link (see above): fix before shipping"; exit 1
|
||||
fi
|
||||
java -Xmx2g -jar $T/dx.jar --dex --output=$OUT/classes.dex $OUT/classes 2>&1 | grep -v '^Picked up' || true
|
||||
else
|
||||
echo "-- R8"
|
||||
(cd $OUT/classes && zip -q -r ../app.jar .)
|
||||
PGCONF=$(for f in tools/r8-rules.pro $EXO/*.pro; do printf -- '--pg-conf %s ' "$f"; done)
|
||||
java -Xmx2g -cp "$R8" com.android.tools.r8.R8 --release --min-api $API --lib $ANDROID_JAR \
|
||||
--map-diagnostics info none --output $OUT $PGCONF $OUT/app.jar $EXO/*.jar 2>&1 \
|
||||
| grep -v -E '^Picked up|META-INF' || true
|
||||
test -f $OUT/classes.dex
|
||||
# one dex only: Android 4.1 can't load a second without a multidex library
|
||||
if [ -f $OUT/classes2.dex ]; then echo "R8 made more than one dex file (over 64K methods)"; exit 1; fi
|
||||
fi
|
||||
|
||||
cp $OUT/base.apk $OUT/unsigned.apk
|
||||
(cd $OUT && zip -q unsigned.apk classes.dex)
|
||||
$PY tools/signapk.py $P12 ${KEYPASS:-miniabs} $OUT/unsigned.apk $APK
|
||||
python3 tools/check_dex.py $APK
|
||||
ls -l $APK
|
||||
done
|
||||
@@ -0,0 +1,134 @@
|
||||
package com.miniabs;
|
||||
|
||||
import java.io.File;
|
||||
|
||||
import android.content.Context;
|
||||
import android.net.Uri;
|
||||
import android.os.Handler;
|
||||
import android.os.Looper;
|
||||
|
||||
import com.google.android.exoplayer2.C;
|
||||
import com.google.android.exoplayer2.DefaultLoadControl;
|
||||
import com.google.android.exoplayer2.ExoPlayer;
|
||||
import com.google.android.exoplayer2.MediaItem;
|
||||
import com.google.android.exoplayer2.PlaybackException;
|
||||
import com.google.android.exoplayer2.PlaybackParameters;
|
||||
import com.google.android.exoplayer2.Player;
|
||||
import com.google.android.exoplayer2.Renderer;
|
||||
import com.google.android.exoplayer2.RenderersFactory;
|
||||
import com.google.android.exoplayer2.Tracks;
|
||||
import com.google.android.exoplayer2.audio.AudioAttributes;
|
||||
import com.google.android.exoplayer2.audio.AudioRendererEventListener;
|
||||
import com.google.android.exoplayer2.audio.MediaCodecAudioRenderer;
|
||||
import com.google.android.exoplayer2.extractor.DefaultExtractorsFactory;
|
||||
import com.google.android.exoplayer2.mediacodec.MediaCodecSelector;
|
||||
import com.google.android.exoplayer2.metadata.MetadataOutput;
|
||||
import com.google.android.exoplayer2.source.ProgressiveMediaSource;
|
||||
import com.google.android.exoplayer2.text.TextOutput;
|
||||
import com.google.android.exoplayer2.upstream.DefaultDataSource;
|
||||
import com.google.android.exoplayer2.upstream.DefaultHttpDataSource;
|
||||
import com.google.android.exoplayer2.video.VideoRendererEventListener;
|
||||
|
||||
/**
|
||||
* ExoPlayer 2.18 (Android 4.1+ build): audio only, decoded with Android's MediaCodec. Over MediaPlayer it brings
|
||||
* playback speed and skipping silences (its own audio processing, so it works on Android 4.1), a buffer of minutes,
|
||||
* and errors that say what failed. Audio focus, unplugging and the wake/Wi-Fi locks stay with PlayerService.
|
||||
*/
|
||||
final class ExoEngine implements Engine {
|
||||
|
||||
private final ExoPlayer player;
|
||||
private Listener listener;
|
||||
private boolean preparedSent;
|
||||
private boolean failed;
|
||||
|
||||
ExoEngine(final Context c) {
|
||||
RenderersFactory audioOnly = new RenderersFactory() {
|
||||
public Renderer[] createRenderers(Handler h, VideoRendererEventListener v, AudioRendererEventListener a,
|
||||
TextOutput t, MetadataOutput m) {
|
||||
return new Renderer[] {new MediaCodecAudioRenderer(c, MediaCodecSelector.DEFAULT, h, a)};
|
||||
}
|
||||
};
|
||||
DefaultHttpDataSource.Factory http = new DefaultHttpDataSource.Factory()
|
||||
.setUserAgent("Podlite").setConnectTimeoutMs(15000).setReadTimeoutMs(30000)
|
||||
.setAllowCrossProtocolRedirects(true);
|
||||
ProgressiveMediaSource.Factory sources = new ProgressiveMediaSource.Factory(new DefaultDataSource.Factory(c, http),
|
||||
new DefaultExtractorsFactory().setConstantBitrateSeekingEnabled(true));
|
||||
player = new ExoPlayer.Builder(c, audioOnly, sources)
|
||||
// up to 3 minutes ahead in memory: rides out Wi-Fi gaps that MediaPlayer's seconds of buffer couldn't
|
||||
.setLoadControl(new DefaultLoadControl.Builder()
|
||||
.setBufferDurationsMs(60000, 180000, 1500, 3000)
|
||||
.setPrioritizeTimeOverSizeThresholds(true)
|
||||
.build())
|
||||
.setAudioAttributes(new AudioAttributes.Builder()
|
||||
.setUsage(C.USAGE_MEDIA).setContentType(C.AUDIO_CONTENT_TYPE_SPEECH).build(), false)
|
||||
.setHandleAudioBecomingNoisy(false)
|
||||
.setWakeMode(C.WAKE_MODE_LOCAL)
|
||||
.setLooper(Looper.getMainLooper())
|
||||
.build();
|
||||
player.addListener(new Player.Listener() {
|
||||
public void onPlaybackStateChanged(int state) {
|
||||
if (listener == null || failed) return;
|
||||
if (state == Player.STATE_READY && !preparedSent) {
|
||||
preparedSent = true;
|
||||
listener.onPrepared(ExoEngine.this);
|
||||
} else if (state == Player.STATE_ENDED) {
|
||||
listener.onCompletion(ExoEngine.this);
|
||||
}
|
||||
}
|
||||
|
||||
public void onPlayerError(PlaybackException e) {
|
||||
if (listener == null || failed) return;
|
||||
failed = true;
|
||||
int code = e.errorCode;
|
||||
// 4xxx: decoding; 3003: a container this player can't read. Android's MediaPlayer may manage.
|
||||
boolean decoder = (code >= 4000 && code < 5000) || code == PlaybackException.ERROR_CODE_PARSING_CONTAINER_UNSUPPORTED;
|
||||
Throwable cause = e.getCause();
|
||||
String why = cause != null && cause.getMessage() != null ? cause.getMessage() : e.getMessage();
|
||||
listener.onError(ExoEngine.this, "Erro de reprodución: " + e.getErrorCodeName() + (why != null ? " (" + why + ")" : ""), decoder);
|
||||
}
|
||||
|
||||
public void onTracksChanged(Tracks tracks) {
|
||||
// ExoPlayer plays silence through audio it can't decode instead of failing: fail, so MediaPlayer can try
|
||||
if (listener == null || failed || tracks.isEmpty() || tracks.isTypeSupported(C.TRACK_TYPE_AUDIO, true)) return;
|
||||
failed = true;
|
||||
listener.onError(ExoEngine.this, "Este teléfono non pode descodificar este audio.", true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public void setListener(Listener l) { listener = l; }
|
||||
|
||||
public void setSource(File file, String url) {
|
||||
player.setMediaItem(MediaItem.fromUri(file != null ? Uri.fromFile(file) : Uri.parse(url)));
|
||||
}
|
||||
|
||||
public void prepare() { player.prepare(); }
|
||||
|
||||
public void start() { player.setPlayWhenReady(true); }
|
||||
|
||||
public void pause() { player.setPlayWhenReady(false); }
|
||||
|
||||
public void seekTo(int ms) { player.seekTo(ms); }
|
||||
|
||||
public int position() { return (int) player.getCurrentPosition(); }
|
||||
|
||||
public int duration() {
|
||||
long d = player.getDuration();
|
||||
return d == C.TIME_UNSET || d < 0 ? 0 : (int) d;
|
||||
}
|
||||
|
||||
public int bufferedPercent() { return player.getBufferedPercentage(); }
|
||||
|
||||
public void setVolume(float v) { player.setVolume(v); }
|
||||
|
||||
/** Before prepare for skipping silences to work: ExoPlayer 2.18 switches it on only when it sets up the audio. */
|
||||
public void setSpeed(float speed, boolean skipSilence) {
|
||||
player.setPlaybackParameters(new PlaybackParameters(speed));
|
||||
player.setSkipSilenceEnabled(skipSilence);
|
||||
}
|
||||
|
||||
public void release() {
|
||||
listener = null;
|
||||
player.release();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
package com.miniabs;
|
||||
|
||||
import android.app.Notification;
|
||||
import android.app.PendingIntent;
|
||||
import android.content.ComponentName;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.graphics.Bitmap;
|
||||
import android.media.AudioManager;
|
||||
import android.media.MediaMetadataRetriever;
|
||||
import android.media.RemoteControlClient;
|
||||
import android.net.ConnectivityManager;
|
||||
import android.net.NetworkInfo;
|
||||
import android.os.StrictMode;
|
||||
import android.view.View;
|
||||
import android.view.ViewConfiguration;
|
||||
import android.widget.RemoteViews;
|
||||
|
||||
/**
|
||||
* What differs between Podlite's builds, for Android 4.1 and later (Xperia J, Galaxy S II...): ExoPlayer (speed,
|
||||
* skipping silences), a notification with the cover and buttons, lock-screen controls, metered networks, and the
|
||||
* ⋮ button on phones without a Menu key. platform/api8 has the same methods for Android 2.2; keep the two in step.
|
||||
*/
|
||||
final class Platform {
|
||||
private Platform() {}
|
||||
|
||||
static final int API = 16;
|
||||
|
||||
static final boolean SPEED = true;
|
||||
|
||||
static final int COVER_PX = 320; // the lock screen shows it large
|
||||
|
||||
private static boolean initDone;
|
||||
|
||||
static void init(Context c) {
|
||||
if (initDone) return;
|
||||
initDone = true;
|
||||
// Shared code sometimes closes a connection on the main thread (always brief). Android 2.x allows that;
|
||||
// Android 3+ kills apps that target it. Allow it here too.
|
||||
StrictMode.setThreadPolicy(new StrictMode.ThreadPolicy.Builder(StrictMode.getThreadPolicy()).permitNetwork().build());
|
||||
}
|
||||
|
||||
static Engine newEngine(Context c, boolean fallback) {
|
||||
return fallback ? new MediaPlayerEngine(c) : new ExoEngine(c);
|
||||
}
|
||||
|
||||
/** Android 4.1 knows which networks cost: mobile data, and a Wi-Fi that is a phone's hotspot. */
|
||||
static boolean unmetered(Context c) {
|
||||
try {
|
||||
ConnectivityManager cm = (ConnectivityManager) c.getSystemService(Context.CONNECTIVITY_SERVICE);
|
||||
NetworkInfo ni = cm == null ? null : cm.getActiveNetworkInfo();
|
||||
return ni != null && ni.isConnected() && !cm.isActiveNetworkMetered();
|
||||
} catch (Exception e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
static boolean needsMenuButton(Context c) {
|
||||
return !ViewConfiguration.get(c).hasPermanentMenuKey();
|
||||
}
|
||||
|
||||
/** Cover, title and show with −15 ⏯ +30 and ✕ (pulled down, a larger one). Swiping it away while paused stops. */
|
||||
static Notification playerNotification(Context c, Episode ep, String what, Bitmap cover, int state, boolean ongoing) {
|
||||
Notification n = new Notification.Builder(c)
|
||||
.setSmallIcon(R.drawable.ic_stat)
|
||||
.setContent(views(c, R.layout.notif_player, ep, what, cover, state))
|
||||
.setContentIntent(openPlayer(c))
|
||||
.setDeleteIntent(service(c, PlayerService.A_STOP, 14))
|
||||
.setOngoing(ongoing)
|
||||
.setWhen(0)
|
||||
.setPriority(state == PlayerService.PLAYING ? Notification.PRIORITY_HIGH : Notification.PRIORITY_DEFAULT)
|
||||
.build();
|
||||
n.bigContentView = views(c, R.layout.notif_player_big, ep, what, cover, state);
|
||||
return n;
|
||||
}
|
||||
|
||||
private static Bitmap small, smallOf;
|
||||
|
||||
private static RemoteViews views(Context c, int layout, Episode ep, String what, Bitmap cover, int state) {
|
||||
RemoteViews v = new RemoteViews(c.getPackageName(), layout);
|
||||
v.setTextViewText(R.id.n_title, ep.title);
|
||||
v.setTextViewText(R.id.n_sub, Util.join(what, ep.show));
|
||||
if (cover != null) {
|
||||
// the size the notification shows it at: a full-size bitmap would be copied to the status bar each time
|
||||
if (cover != smallOf) {
|
||||
int px = (int) (112 * c.getResources().getDisplayMetrics().density);
|
||||
smallOf = cover;
|
||||
small = cover.getWidth() <= px ? cover : Bitmap.createScaledBitmap(cover, px, px * cover.getHeight() / cover.getWidth(), true);
|
||||
}
|
||||
v.setImageViewBitmap(R.id.n_cover, small);
|
||||
}
|
||||
Prefs p = Prefs.get(c);
|
||||
v.setTextViewText(R.id.n_back, "−" + p.skipBack());
|
||||
v.setTextViewText(R.id.n_fwd, "+" + p.skipFwd());
|
||||
boolean playing = state == PlayerService.PLAYING || state == PlayerService.LOADING;
|
||||
v.setImageViewResource(R.id.n_play, playing ? R.drawable.ic_pause_small : R.drawable.ic_play_small);
|
||||
v.setOnClickPendingIntent(R.id.n_back, service(c, PlayerService.A_BACK, 11));
|
||||
v.setOnClickPendingIntent(R.id.n_play, service(c, PlayerService.A_TOGGLE, 12));
|
||||
v.setOnClickPendingIntent(R.id.n_fwd, service(c, PlayerService.A_FWD, 13));
|
||||
v.setOnClickPendingIntent(R.id.n_close, service(c, PlayerService.A_STOP, 14));
|
||||
v.setOnClickPendingIntent(R.id.n_cover, openPlayer(c));
|
||||
v.setOnClickPendingIntent(R.id.n_text, openPlayer(c));
|
||||
v.setViewVisibility(R.id.n_close, View.VISIBLE);
|
||||
return v;
|
||||
}
|
||||
|
||||
static PendingIntent openPlayer(Context c) {
|
||||
Intent i = new Intent(c, PlayerActivity.class).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
|
||||
return PendingIntent.getActivity(c, 0, i, PendingIntent.FLAG_UPDATE_CURRENT);
|
||||
}
|
||||
|
||||
private static PendingIntent service(Context c, String action, int request) {
|
||||
return PendingIntent.getService(c, request, new Intent(c, PlayerService.class).setAction(action), 0);
|
||||
}
|
||||
|
||||
private static RemoteControlClient remote;
|
||||
private static String remoteFor;
|
||||
private static Bitmap remoteCover;
|
||||
|
||||
/** The lock screen's player: cover, title, show and buttons (⏮ ⏭ skip back and forward: media keys). */
|
||||
static void lockScreen(Context c, Episode ep, Bitmap cover, int state) {
|
||||
AudioManager am = (AudioManager) c.getSystemService(Context.AUDIO_SERVICE);
|
||||
if (ep == null || state == PlayerService.IDLE) {
|
||||
if (remote != null) {
|
||||
remote.setPlaybackState(RemoteControlClient.PLAYSTATE_STOPPED);
|
||||
am.unregisterRemoteControlClient(remote);
|
||||
remote = null;
|
||||
remoteFor = null;
|
||||
remoteCover = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (remote == null) {
|
||||
ComponentName buttons = new ComponentName(c, MediaButtonReceiver.class);
|
||||
am.registerMediaButtonEventReceiver(buttons);
|
||||
Intent i = new Intent(Intent.ACTION_MEDIA_BUTTON).setComponent(buttons);
|
||||
remote = new RemoteControlClient(PendingIntent.getBroadcast(c.getApplicationContext(), 0, i, 0));
|
||||
remote.setTransportControlFlags(RemoteControlClient.FLAG_KEY_MEDIA_PLAY | RemoteControlClient.FLAG_KEY_MEDIA_PAUSE
|
||||
| RemoteControlClient.FLAG_KEY_MEDIA_PLAY_PAUSE | RemoteControlClient.FLAG_KEY_MEDIA_NEXT
|
||||
| RemoteControlClient.FLAG_KEY_MEDIA_PREVIOUS);
|
||||
am.registerRemoteControlClient(remote);
|
||||
}
|
||||
remote.setPlaybackState(state == PlayerService.PLAYING ? RemoteControlClient.PLAYSTATE_PLAYING
|
||||
: state == PlayerService.LOADING ? RemoteControlClient.PLAYSTATE_BUFFERING
|
||||
: state == PlayerService.ERROR ? RemoteControlClient.PLAYSTATE_ERROR : RemoteControlClient.PLAYSTATE_PAUSED);
|
||||
if (ep.id.equals(remoteFor) && cover == remoteCover) return;
|
||||
remoteFor = ep.id;
|
||||
remoteCover = cover;
|
||||
RemoteControlClient.MetadataEditor ed = remote.editMetadata(true);
|
||||
ed.putString(MediaMetadataRetriever.METADATA_KEY_TITLE, ep.title);
|
||||
ed.putString(MediaMetadataRetriever.METADATA_KEY_ARTIST, ep.show);
|
||||
ed.putLong(MediaMetadataRetriever.METADATA_KEY_DURATION, (long) (ep.duration * 1000));
|
||||
// a copy: Android recycles the artwork it's given when the next one comes, and this one is in the cover cache
|
||||
if (cover != null) ed.putBitmap(RemoteControlClient.MetadataEditor.BITMAP_KEY_ARTWORK, cover.copy(Bitmap.Config.ARGB_8888, false));
|
||||
ed.apply();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Android 4.1 build: Holo (dark, like the app), without the action bar: the screens draw their own header. -->
|
||||
<resources>
|
||||
<style name="BaseTheme" parent="@android:style/Theme.Holo.NoActionBar" />
|
||||
</resources>
|
||||
@@ -0,0 +1,63 @@
|
||||
package com.miniabs;
|
||||
|
||||
import android.app.Notification;
|
||||
import android.app.PendingIntent;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.graphics.Bitmap;
|
||||
import android.net.ConnectivityManager;
|
||||
import android.net.NetworkInfo;
|
||||
|
||||
/**
|
||||
* What differs between Podlite's builds, for Android 2.2 and later (the Galaxy Mini). platform/api16 has the same
|
||||
* methods for Android 4.1 and later; the rest of the app is shared and calls these. Keep the two in step.
|
||||
*/
|
||||
final class Platform {
|
||||
private Platform() {}
|
||||
|
||||
/** The Android level this build is for: its minSdkVersion. */
|
||||
static final int API = 8;
|
||||
|
||||
/** The player can change speed and skip silences (Android 2.x's can't). */
|
||||
static final boolean SPEED = false;
|
||||
|
||||
/** Size the cover is loaded at for the notification and lock screen. */
|
||||
static final int COVER_PX = 64;
|
||||
|
||||
/** At the start of each screen and service (main thread). */
|
||||
static void init(Context c) {}
|
||||
|
||||
/** The player for an episode. {@code fallback}: the episode failed to decode in the usual one. */
|
||||
static Engine newEngine(Context c, boolean fallback) {
|
||||
return new MediaPlayerEngine(c);
|
||||
}
|
||||
|
||||
/** On a network that doesn't cost by the megabyte (for "download only on Wi-Fi"): here, Wi-Fi. */
|
||||
static boolean unmetered(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;
|
||||
}
|
||||
|
||||
/** The phone has no Menu key, so screens need a ⋮ button. Android 2.x phones all have one. */
|
||||
static boolean needsMenuButton(Context c) {
|
||||
return false;
|
||||
}
|
||||
|
||||
/** The playback notification. Android 2.x: title and state; its notifications can't have buttons. */
|
||||
@SuppressWarnings("deprecation")
|
||||
static Notification playerNotification(Context c, Episode ep, String what, Bitmap cover, int state, boolean ongoing) {
|
||||
Notification n = new Notification(R.drawable.ic_stat, null, 0);
|
||||
n.flags |= ongoing ? Notification.FLAG_ONGOING_EVENT : 0;
|
||||
n.setLatestEventInfo(c, ep.title, Util.join(what, ep.show), openPlayer(c));
|
||||
return n;
|
||||
}
|
||||
|
||||
static PendingIntent openPlayer(Context c) {
|
||||
Intent i = new Intent(c, PlayerActivity.class).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
|
||||
return PendingIntent.getActivity(c, 0, i, PendingIntent.FLAG_UPDATE_CURRENT);
|
||||
}
|
||||
|
||||
/** The lock screen's player (null episode or IDLE: take it away). Android 2.x has none. */
|
||||
static void lockScreen(Context c, Episode ep, Bitmap cover, int state) {}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 533 B After Width: | Height: | Size: 564 B |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 434 B After Width: | Height: | Size: 453 B |
|
Before Width: | Height: | Size: 647 B After Width: | Height: | Size: 679 B |
|
Before Width: | Height: | Size: 494 B After Width: | Height: | Size: 531 B |
|
Before Width: | Height: | Size: 806 B After Width: | Height: | Size: 863 B |
@@ -1,7 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<selector xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:state_pressed="true">
|
||||
<shape><gradient android:startColor="#AA5CFF" android:endColor="#7022E6" android:angle="270" /><corners android:radius="6dp" /></shape>
|
||||
<shape><gradient android:startColor="#E39A38" android:endColor="#A9621A" android:angle="270" /><corners android:radius="6dp" /></shape>
|
||||
</item>
|
||||
<item android:state_enabled="false">
|
||||
<shape><solid android:color="@color/dim" /><corners android:radius="6dp" /></shape>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<selector xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:state_pressed="true">
|
||||
<shape><gradient android:startColor="#AA5CFF" android:endColor="#7022E6" android:angle="270" /><corners android:radius="23dp" /></shape>
|
||||
<shape><gradient android:startColor="#E39A38" android:endColor="#A9621A" android:angle="270" /><corners android:radius="23dp" /></shape>
|
||||
</item>
|
||||
<item>
|
||||
<shape><gradient android:startColor="@color/accent_top" android:endColor="@color/accent_bottom" android:angle="270" /><corners android:radius="23dp" /></shape>
|
||||
|
||||
@@ -33,6 +33,13 @@
|
||||
android:layout_height="fill_parent"
|
||||
android:background="@drawable/row_sel"
|
||||
android:src="@drawable/ic_refresh" />
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/menu"
|
||||
android:layout_width="@dimen/header_h"
|
||||
android:layout_height="fill_parent"
|
||||
android:background="@drawable/row_sel"
|
||||
android:visibility="gone" />
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
|
||||
@@ -18,6 +18,27 @@
|
||||
android:singleLine="true"
|
||||
android:ellipsize="end"
|
||||
android:background="@drawable/row_sel" />
|
||||
|
||||
<!-- Android 4.1 build: playback speed -->
|
||||
<Button
|
||||
android:id="@+id/speed"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="fill_parent"
|
||||
android:minWidth="@dimen/header_h"
|
||||
android:paddingLeft="8dp"
|
||||
android:paddingRight="8dp"
|
||||
android:background="@drawable/row_sel"
|
||||
android:textColor="@color/accent_light"
|
||||
android:textSize="@dimen/header_sub"
|
||||
android:textStyle="bold"
|
||||
android:visibility="gone" />
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/menu"
|
||||
android:layout_width="@dimen/header_h"
|
||||
android:layout_height="fill_parent"
|
||||
android:background="@drawable/row_sel"
|
||||
android:visibility="gone" />
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- The Android 4.1 build's playback notification (Platform in platform/api16 fills it). RemoteViews-only views. -->
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="fill_parent"
|
||||
android:layout_height="fill_parent"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical"
|
||||
android:background="@color/surface"
|
||||
android:padding="4dp">
|
||||
|
||||
<ImageView android:id="@+id/n_cover"
|
||||
android:layout_width="56dp" android:layout_height="56dp"
|
||||
android:scaleType="centerCrop" android:src="@drawable/ic_launcher" />
|
||||
|
||||
<LinearLayout android:id="@+id/n_text"
|
||||
android:layout_width="0dp" android:layout_height="fill_parent" android:layout_weight="1"
|
||||
android:orientation="vertical" android:gravity="center_vertical"
|
||||
android:paddingLeft="8dp" android:paddingRight="2dp">
|
||||
<TextView android:id="@+id/n_title"
|
||||
android:layout_width="fill_parent" android:layout_height="wrap_content"
|
||||
android:textColor="@color/text" android:textSize="15sp" android:textStyle="bold"
|
||||
android:singleLine="true" android:ellipsize="end" />
|
||||
<TextView android:id="@+id/n_sub"
|
||||
android:layout_width="fill_parent" android:layout_height="wrap_content"
|
||||
android:textColor="@color/sub" android:textSize="13sp"
|
||||
android:singleLine="true" android:ellipsize="end" />
|
||||
</LinearLayout>
|
||||
|
||||
<Button android:id="@+id/n_back" android:layout_width="42dp" android:layout_height="48dp"
|
||||
android:background="@drawable/btn_flat" android:textColor="@color/text" android:textSize="13sp" android:textStyle="bold" />
|
||||
<ImageButton android:id="@+id/n_play" android:layout_width="44dp" android:layout_height="48dp"
|
||||
android:background="@drawable/btn_flat" android:scaleType="center" android:src="@drawable/ic_play_small" />
|
||||
<Button android:id="@+id/n_fwd" android:layout_width="42dp" android:layout_height="48dp"
|
||||
android:background="@drawable/btn_flat" android:textColor="@color/text" android:textSize="13sp" android:textStyle="bold" />
|
||||
<ImageButton android:id="@+id/n_close" android:layout_width="34dp" android:layout_height="48dp"
|
||||
android:background="@drawable/btn_flat" android:scaleType="center"
|
||||
android:src="@android:drawable/ic_menu_close_clear_cancel" />
|
||||
</LinearLayout>
|
||||
@@ -0,0 +1,46 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- The Android 4.1 build's notification, pulled down. RemoteViews-only views. -->
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="fill_parent"
|
||||
android:layout_height="fill_parent"
|
||||
android:orientation="horizontal"
|
||||
android:background="@color/surface"
|
||||
android:padding="6dp">
|
||||
|
||||
<ImageView android:id="@+id/n_cover"
|
||||
android:layout_width="112dp" android:layout_height="112dp"
|
||||
android:layout_gravity="center_vertical"
|
||||
android:scaleType="centerCrop" android:src="@drawable/ic_launcher" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="0dp" android:layout_height="fill_parent" android:layout_weight="1"
|
||||
android:orientation="vertical" android:paddingLeft="10dp">
|
||||
<LinearLayout android:layout_width="fill_parent" android:layout_height="0dp" android:layout_weight="1"
|
||||
android:orientation="horizontal">
|
||||
<LinearLayout android:id="@+id/n_text"
|
||||
android:layout_width="0dp" android:layout_height="fill_parent" android:layout_weight="1"
|
||||
android:orientation="vertical" android:gravity="center_vertical">
|
||||
<TextView android:id="@+id/n_title"
|
||||
android:layout_width="fill_parent" android:layout_height="wrap_content"
|
||||
android:textColor="@color/text" android:textSize="15sp" android:textStyle="bold"
|
||||
android:maxLines="2" android:ellipsize="end" />
|
||||
<TextView android:id="@+id/n_sub"
|
||||
android:layout_width="fill_parent" android:layout_height="wrap_content"
|
||||
android:textColor="@color/sub" android:textSize="13sp"
|
||||
android:singleLine="true" android:ellipsize="end" />
|
||||
</LinearLayout>
|
||||
<ImageButton android:id="@+id/n_close" android:layout_width="34dp" android:layout_height="34dp"
|
||||
android:background="@drawable/btn_flat" android:scaleType="center"
|
||||
android:src="@android:drawable/ic_menu_close_clear_cancel" />
|
||||
</LinearLayout>
|
||||
<LinearLayout android:layout_width="fill_parent" android:layout_height="48dp"
|
||||
android:orientation="horizontal" android:gravity="center">
|
||||
<Button android:id="@+id/n_back" android:layout_width="0dp" android:layout_height="48dp" android:layout_weight="1"
|
||||
android:background="@drawable/btn_flat" android:textColor="@color/text" android:textSize="15sp" android:textStyle="bold" />
|
||||
<ImageButton android:id="@+id/n_play" android:layout_width="0dp" android:layout_height="48dp" android:layout_weight="1"
|
||||
android:background="@drawable/btn_flat" android:scaleType="center" android:src="@drawable/ic_play_small" />
|
||||
<Button android:id="@+id/n_fwd" android:layout_width="0dp" android:layout_height="48dp" android:layout_weight="1"
|
||||
android:background="@drawable/btn_flat" android:textColor="@color/text" android:textSize="15sp" android:textStyle="bold" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- The Android look Podlite's theme (values.xml) builds on. The Android 4.1 build replaces this file
|
||||
(platform/api16/res/values) with Holo, for Android 4's dialogs, menus and text fields. -->
|
||||
<resources>
|
||||
<style name="BaseTheme" parent="@android:style/Theme.NoTitleBar" />
|
||||
</resources>
|
||||
@@ -2,22 +2,22 @@
|
||||
<resources>
|
||||
<string name="app_name">Podlite</string>
|
||||
|
||||
<!-- Colours taken from the Audiobookshelf icon: gradient #9437FC to #570DCF, waves #BC8AFC -->
|
||||
<color name="bg">#13111A</color>
|
||||
<color name="surface">#1D1928</color>
|
||||
<color name="header">#0C0A12</color>
|
||||
<color name="line">#2C2638</color>
|
||||
<color name="text">#EEEBF3</color>
|
||||
<color name="sub">#ADA6BC</color>
|
||||
<color name="dim">#736C82</color>
|
||||
<color name="accent">#7A25EC</color> <!-- fills: tabs, progress, seek bar -->
|
||||
<color name="accent_light">#BC8AFC</color> <!-- text and small marks on dark backgrounds -->
|
||||
<color name="accent_top">#9437FC</color> <!-- button gradient, as in the icon -->
|
||||
<color name="accent_bottom">#5A11D3</color>
|
||||
<!-- Colours taken from the radio icon: brass #C8964F, dial glow #CC6000, on warm near-black -->
|
||||
<color name="bg">#15120F</color>
|
||||
<color name="surface">#211C17</color>
|
||||
<color name="header">#0E0B09</color>
|
||||
<color name="line">#342A21</color>
|
||||
<color name="text">#F2EDE6</color>
|
||||
<color name="sub">#BFB4A6</color>
|
||||
<color name="dim">#827567</color>
|
||||
<color name="accent">#C27A1E</color> <!-- fills: tabs, progress, seek bar -->
|
||||
<color name="accent_light">#E8A64A</color> <!-- text and small marks on dark backgrounds -->
|
||||
<color name="accent_top">#D98C2B</color> <!-- button gradient, as in the icon -->
|
||||
<color name="accent_bottom">#A35F14</color>
|
||||
<color name="fresh">#6EC1E4</color>
|
||||
<color name="pressed">#2E2642</color>
|
||||
<color name="pressed">#3B2D20</color>
|
||||
|
||||
<style name="MiniTheme" parent="@android:style/Theme.NoTitleBar">
|
||||
<style name="MiniTheme" parent="@style/BaseTheme">
|
||||
<item name="android:windowBackground">@color/bg</item>
|
||||
<item name="android:textColor">@color/text</item>
|
||||
<item name="android:textColorPrimary">@color/text</item>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<PreferenceCategory android:title="Reprodución">
|
||||
<PreferenceCategory android:key="playback" android:title="Reprodución">
|
||||
<ListPreference
|
||||
android:key="skip_back"
|
||||
android:title="Retroceder"
|
||||
@@ -50,7 +50,7 @@
|
||||
<PreferenceCategory android:title="Acerca de">
|
||||
<Preference
|
||||
android:key="about"
|
||||
android:title="Podlite 1.9"
|
||||
android:title="Podlite 1.10"
|
||||
android:summary="Un pequeno cliente de podcasts de Audiobookshelf para Android 2.2+" />
|
||||
</PreferenceCategory>
|
||||
</PreferenceScreen>
|
||||
@@ -0,0 +1,63 @@
|
||||
# Podlite (Android) — spec
|
||||
|
||||
The short contract for working on this app. `README.md` is the user guide; this file says what the app is
|
||||
for, what limits it lives within, and what must not break.
|
||||
|
||||
## Goal
|
||||
|
||||
A small Audiobookshelf podcast client for the Samsung Galaxy Mini whose progress stays in sync with the
|
||||
server and other apps.
|
||||
|
||||
## Target and hard limits
|
||||
|
||||
- Samsung Galaxy Mini GT-S5570 and other Android 2.2+ phones (API 8), 240×320 ldpi.
|
||||
- Native Java, no libraries beyond the bundled TLS stack. All sizes in `res/values/dimens.xml`.
|
||||
- Galician interface; warm dark colours with an amber accent (from the radio icon).
|
||||
|
||||
## Current scope (1.12)
|
||||
|
||||
- Login with user/password (refreshes 2.26+ short-lived tokens; older servers' long-lived token) or an API key.
|
||||
- HTTP on the LAN; bundled TLS 1.2 for `https://` (Mozilla roots); other certificates shown by fingerprint,
|
||||
trusted once (trusting the issuing CA so renewals work), forgettable in Settings.
|
||||
- Loopback stream proxy for HTTPS, forwarding seeks with a fresh token per request.
|
||||
- Tabs: Latest (continue listening + newest), Shows, Saved (SD-card downloads, Wi-Fi only by default).
|
||||
- Player: skip back/forward (amounts in Settings), seek, show notes, sleep timer, mark played, close.
|
||||
- Progress sync every 15 s and on pause; offline positions sent later.
|
||||
- Headset buttons; pauses for calls and unplugging.
|
||||
|
||||
## Android 4.1 build
|
||||
|
||||
- `build/Podlite-api16.apk` (minSdk/targetSdk 16) next to `build/Podlite.apk` (API 8): shared `src/`, per-level
|
||||
`platform/api8|api16/…/Platform.java` with the same methods, `platform/api16/res` overlaid. ExoPlayer with speed and
|
||||
skip silences, notification and lock screen with cover and buttons, Holo, ⋮ without a Menu key, metered networks.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Audiobooks, HLS/transcoded streams (so no Opus); playback speed on Android 2.x (it can't).
|
||||
|
||||
## Must not break
|
||||
|
||||
- An episode only counts as played within a minute of its real end; drop-outs and cut files are retried or
|
||||
reported, never marked played. A stall of 12 s is reported.
|
||||
- Package `com.miniabs` and `miniabs.keystore` (password `miniabs`): updates install over MiniABS.
|
||||
- `src/com/miniabs/net/` is a copy of `tlskit`: change it in `../tlskit` and reinstall.
|
||||
- `platform/api8` and `platform/api16` keep the same `Platform` methods; shared code calls no API the API 8 jar lacks
|
||||
(the api8 compile and Dalvik check catch it). Each build's `versionCode` is the manifest's × 100 + level, same key;
|
||||
both APKs are listed in `../tenda-lite/repo/apps.json`.
|
||||
- The Android 4.1 build targets API 16: network on the main thread is allowed in `Platform.init`; keep the desktop
|
||||
tests' files (`Util`, `Api`…) free of `Platform`.
|
||||
- Installs to internal storage.
|
||||
|
||||
## Code map
|
||||
|
||||
`Api` · `PlayerService` · `StreamProxy` · `DownloadService` / `Downloads` · `Trust` / `TrustPrompt` · `Prefs` ·
|
||||
activities Home, Podcast, Player, Login, Settings · `net/` tlskit · `tools/make_icons.py`, `translate_gl.py`.
|
||||
|
||||
## Build, test, release
|
||||
|
||||
- `ANDROID_JAR=/path/to/android-8/android.jar ./build.sh` (JDK, aapt, dx, zipalign, apksigner, `BC_SRC`).
|
||||
- `JSON_JAR=… test/run_tests.sh`: API, JSON, progress, HTTPS trust and proxy against `test/mock_abs.py`,
|
||||
with good, expired, wrong-host, forged and self-signed certificates (`test/make_pki.sh`).
|
||||
- Release: raise `versionCode`/`versionName`, build, add `changes` in `../tenda-lite/repo/apps.json`, commit
|
||||
and push, `../tenda-lite/repo/publish.sh`.
|
||||
- Siblings: `podlite-ios`, `podlite-mac`; behaviour fixes are backported between them.
|
||||
@@ -26,7 +26,7 @@ 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.9";
|
||||
static final String VERSION = "1.11";
|
||||
|
||||
static class ApiException extends IOException {
|
||||
final int code;
|
||||
|
||||
@@ -18,6 +18,27 @@ abstract class BaseActivity extends Activity {
|
||||
protected final Handler handler = new Handler();
|
||||
private boolean ticking;
|
||||
|
||||
@Override
|
||||
protected void onCreate(android.os.Bundle b) {
|
||||
Platform.init(this);
|
||||
super.onCreate(b);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onPostCreate(android.os.Bundle b) {
|
||||
super.onPostCreate(b);
|
||||
// phones without a Menu key (some Android 4 ones): a ⋮ in the header opens the options menu
|
||||
View menu = findViewById(R.id.menu);
|
||||
if (menu instanceof ImageButton && Platform.needsMenuButton(this)) {
|
||||
float d = getResources().getDisplayMetrics().density;
|
||||
((ImageButton) menu).setImageDrawable(new Dots(getResources().getColor(R.color.sub), (int) (24 * d)));
|
||||
menu.setVisibility(View.VISIBLE);
|
||||
menu.setOnClickListener(new View.OnClickListener() {
|
||||
public void onClick(View v) { openOptionsMenu(); }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private View mini;
|
||||
private TextView miniTitle, miniSub;
|
||||
private ImageButton miniPlay;
|
||||
@@ -166,7 +187,7 @@ abstract class BaseActivity extends Activity {
|
||||
playEpisode(e);
|
||||
return true;
|
||||
case M_DOWNLOAD:
|
||||
if (Prefs.get(this).wifiOnly() && !Util.onWifi(this)) {
|
||||
if (Prefs.get(this).wifiOnly() && !Platform.unmetered(this)) {
|
||||
new AlertDialog.Builder(this)
|
||||
.setMessage("Non estás nunha wifi. Descargar con datos móbiles esta vez?")
|
||||
.setPositiveButton("Descargar", new DialogInterface.OnClickListener() {
|
||||
|
||||
@@ -69,6 +69,26 @@ final class Covers {
|
||||
});
|
||||
}
|
||||
|
||||
interface Callback { void onCover(Bitmap b); }
|
||||
|
||||
/** The cover as a bitmap, on the main thread (null if covers are off or it can't be had). Sizes: 64, 128, 320. */
|
||||
static void load(Context c, final String itemId, int px, final Callback cb) {
|
||||
if (!Prefs.get(c).covers() || itemId == null || itemId.length() == 0) { cb.onCover(null); return; }
|
||||
final int size = px <= 64 ? 64 : px <= 128 ? 128 : 320;
|
||||
final String key = itemId + "_" + size;
|
||||
Bitmap b;
|
||||
synchronized (cache) { b = cache.get(key); }
|
||||
if (b != null) { cb.onCover(b); return; }
|
||||
final Context app = c.getApplicationContext();
|
||||
pool.execute(new Runnable() {
|
||||
public void run() {
|
||||
final Bitmap bmp = fetch(app, itemId, size, key);
|
||||
if (bmp != null) synchronized (cache) { cache.put(key, bmp); }
|
||||
ui.post(new Runnable() { public void run() { cb.onCover(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");
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.miniabs;
|
||||
|
||||
import android.graphics.Canvas;
|
||||
import android.graphics.ColorFilter;
|
||||
import android.graphics.Paint;
|
||||
import android.graphics.PixelFormat;
|
||||
import android.graphics.Rect;
|
||||
import android.graphics.drawable.Drawable;
|
||||
|
||||
/** ⋮: the options-menu button on phones without a Menu key (Android 4 build). */
|
||||
final class Dots extends Drawable {
|
||||
private final Paint p = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||
private final int size;
|
||||
|
||||
Dots(int color, int sizePx) {
|
||||
p.setColor(color);
|
||||
size = sizePx;
|
||||
}
|
||||
|
||||
@Override public void draw(Canvas c) {
|
||||
Rect b = getBounds();
|
||||
float cx = b.exactCenterX(), cy = b.exactCenterY(), r = size * 0.09f, gap = size * 0.3f;
|
||||
for (int i = -1; i <= 1; i++) c.drawCircle(cx, cy + i * gap, r, p);
|
||||
}
|
||||
|
||||
@Override public int getIntrinsicWidth() { return size; }
|
||||
@Override public int getIntrinsicHeight() { return size; }
|
||||
@Override public void setAlpha(int a) { p.setAlpha(a); }
|
||||
@Override public void setColorFilter(ColorFilter f) { p.setColorFilter(f); }
|
||||
@Override public int getOpacity() { return PixelFormat.TRANSLUCENT; }
|
||||
}
|
||||
@@ -22,6 +22,12 @@ import android.os.PowerManager;
|
||||
|
||||
/** Downloads episodes one at a time to the SD card, resuming partial files. */
|
||||
public class DownloadService extends IntentService {
|
||||
|
||||
@Override
|
||||
public void onCreate() {
|
||||
super.onCreate();
|
||||
Platform.init(this);
|
||||
}
|
||||
private static final int NOTIF_ID = 2;
|
||||
|
||||
private static final List<Episode> queue = new ArrayList<Episode>();
|
||||
@@ -130,7 +136,7 @@ public class DownloadService extends IntentService {
|
||||
}
|
||||
|
||||
private void download(Episode e) throws IOException {
|
||||
if (Prefs.get(this).wifiOnly() && !Util.onWifi(this)) {
|
||||
if (Prefs.get(this).wifiOnly() && !Platform.unmetered(this)) {
|
||||
throw new IOException("non hai wifi (Axustes: Descargar só con wifi)");
|
||||
}
|
||||
File dir = Downloads.dir(this);
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
package com.miniabs;
|
||||
|
||||
import java.io.File;
|
||||
|
||||
/**
|
||||
* The audio player behind {@link PlayerService}, one per opened episode. Android 2.x builds use
|
||||
* {@link MediaPlayerEngine}; the Android 4.1 build uses ExoPlayer (platform/api16, ExoEngine), see
|
||||
* {@link Platform#newEngine}. Called on the main thread only, and calls its listener there.
|
||||
*/
|
||||
interface Engine {
|
||||
|
||||
interface Listener {
|
||||
/** Ready to play (once per {@link #prepare}). */
|
||||
void onPrepared(Engine e);
|
||||
/** Played to the end (or what the player took for the end: PlayerService checks). */
|
||||
void onCompletion(Engine e);
|
||||
/** Playback failed. {@code decoder}: this engine can't decode the audio; another engine might. */
|
||||
void onError(Engine e, String message, boolean decoder);
|
||||
}
|
||||
|
||||
void setListener(Listener l);
|
||||
|
||||
/** The audio: a file, or an http:// address. One of the two. */
|
||||
void setSource(File file, String url) throws Exception;
|
||||
|
||||
void prepare();
|
||||
|
||||
void start();
|
||||
|
||||
void pause();
|
||||
|
||||
void seekTo(int ms);
|
||||
|
||||
int position();
|
||||
|
||||
/** The audio's length in ms, 0 if not known. */
|
||||
int duration();
|
||||
|
||||
/** How much of a stream is loaded, in percent. */
|
||||
int bufferedPercent();
|
||||
|
||||
void setVolume(float v);
|
||||
|
||||
/** Playback speed (1 = normal) and skipping silences; Android 2.x's player can't, and ignores them. */
|
||||
void setSpeed(float speed, boolean skipSilence);
|
||||
|
||||
void release();
|
||||
}
|
||||
@@ -27,8 +27,12 @@ public class MediaButtonReceiver extends BroadcastReceiver {
|
||||
action = PlayerService.A_BACK;
|
||||
break;
|
||||
case KeyEvent.KEYCODE_MEDIA_STOP:
|
||||
case 127: // KEYCODE_MEDIA_PAUSE (Android 3+: the lock screen, newer headsets)
|
||||
action = PlayerService.A_PAUSE;
|
||||
break;
|
||||
case 126: // KEYCODE_MEDIA_PLAY
|
||||
action = PlayerService.A_TOGGLE;
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
package com.miniabs;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
|
||||
import android.content.Context;
|
||||
import android.media.AudioManager;
|
||||
import android.media.MediaPlayer;
|
||||
import android.os.PowerManager;
|
||||
|
||||
/** Android's own MediaPlayer: every Android version. No speed control (Android 2.x can't). */
|
||||
final class MediaPlayerEngine implements Engine, MediaPlayer.OnPreparedListener, MediaPlayer.OnCompletionListener,
|
||||
MediaPlayer.OnErrorListener, MediaPlayer.OnBufferingUpdateListener {
|
||||
|
||||
private final MediaPlayer mp = new MediaPlayer();
|
||||
private Listener listener;
|
||||
private int buffered;
|
||||
|
||||
MediaPlayerEngine(Context c) {
|
||||
mp.setAudioStreamType(AudioManager.STREAM_MUSIC);
|
||||
mp.setWakeMode(c.getApplicationContext(), PowerManager.PARTIAL_WAKE_LOCK);
|
||||
mp.setOnPreparedListener(this);
|
||||
mp.setOnCompletionListener(this);
|
||||
mp.setOnErrorListener(this);
|
||||
mp.setOnBufferingUpdateListener(this);
|
||||
}
|
||||
|
||||
public void setListener(Listener l) { listener = l; }
|
||||
|
||||
public void setSource(File file, String url) throws Exception {
|
||||
if (file != null) {
|
||||
FileInputStream in = new FileInputStream(file);
|
||||
try { mp.setDataSource(in.getFD()); } finally { in.close(); }
|
||||
} else {
|
||||
mp.setDataSource(url);
|
||||
}
|
||||
}
|
||||
|
||||
public void prepare() { mp.prepareAsync(); }
|
||||
|
||||
public void start() { mp.start(); }
|
||||
|
||||
public void pause() { mp.pause(); }
|
||||
|
||||
public void seekTo(int ms) { mp.seekTo(ms); }
|
||||
|
||||
public int position() { return mp.getCurrentPosition(); }
|
||||
|
||||
public int duration() { return Math.max(0, mp.getDuration()); }
|
||||
|
||||
public int bufferedPercent() { return buffered; }
|
||||
|
||||
public void setVolume(float v) { mp.setVolume(v, v); }
|
||||
|
||||
public void setSpeed(float speed, boolean skipSilence) {}
|
||||
|
||||
public void release() {
|
||||
listener = null;
|
||||
try { mp.reset(); } catch (Exception ignored) {}
|
||||
try { mp.release(); } catch (Exception ignored) {}
|
||||
}
|
||||
|
||||
public void onPrepared(MediaPlayer p) { if (listener != null) listener.onPrepared(this); }
|
||||
|
||||
public void onCompletion(MediaPlayer p) { if (listener != null) listener.onCompletion(this); }
|
||||
|
||||
public boolean onError(MediaPlayer p, int what, int extra) {
|
||||
if (listener != null) listener.onError(this, "Erro de reprodución (" + what + "/" + extra + ").", false);
|
||||
return true;
|
||||
}
|
||||
|
||||
public void onBufferingUpdate(MediaPlayer p, int percent) { buffered = percent; }
|
||||
}
|
||||
@@ -22,7 +22,7 @@ public class PlayerActivity extends BaseActivity {
|
||||
private ImageView cover;
|
||||
private SeekBar seek;
|
||||
private ImageButton play;
|
||||
private Button back, fwd;
|
||||
private Button back, fwd, speed;
|
||||
|
||||
private Episode shown;
|
||||
private Episode pending; // episode just requested, until the service picks it up
|
||||
@@ -77,6 +77,14 @@ public class PlayerActivity extends BaseActivity {
|
||||
refresh();
|
||||
}
|
||||
});
|
||||
speed = (Button) findViewById(R.id.speed);
|
||||
if (Platform.SPEED) { // Android 4.1 build: ExoPlayer changes speed
|
||||
speed.setVisibility(View.VISIBLE);
|
||||
speed.setText(speedLabel(Prefs.get(this).speed()));
|
||||
speed.setOnClickListener(new View.OnClickListener() {
|
||||
public void onClick(View v) { speedDialog(); }
|
||||
});
|
||||
}
|
||||
show.setOnClickListener(new View.OnClickListener() {
|
||||
public void onClick(View v) {
|
||||
Episode e = current();
|
||||
@@ -289,6 +297,34 @@ public class PlayerActivity extends BaseActivity {
|
||||
}
|
||||
}
|
||||
|
||||
private static final float[] SPEEDS = {0.8f, 1f, 1.2f, 1.4f, 1.6f, 1.8f, 2f};
|
||||
|
||||
/** "1,2×" (Galician decimal comma); "1×" for normal speed. */
|
||||
private static String speedLabel(float s) {
|
||||
String n = s == Math.round(s) ? Integer.toString(Math.round(s)) : Float.toString(Math.round(s * 100) / 100f);
|
||||
return n.replace('.', ',') + "×";
|
||||
}
|
||||
|
||||
private void speedDialog() {
|
||||
float now = Prefs.get(this).speed();
|
||||
String[] labels = new String[SPEEDS.length];
|
||||
int checked = 1;
|
||||
for (int i = 0; i < SPEEDS.length; i++) {
|
||||
labels[i] = speedLabel(SPEEDS[i]) + (SPEEDS[i] == 1f ? " (normal)" : "");
|
||||
if (Math.abs(SPEEDS[i] - now) < 0.01f) checked = i;
|
||||
}
|
||||
new AlertDialog.Builder(this).setTitle("Velocidade")
|
||||
.setSingleChoiceItems(labels, checked, new DialogInterface.OnClickListener() {
|
||||
public void onClick(DialogInterface d, int which) {
|
||||
Prefs.get(PlayerActivity.this).setSpeed(SPEEDS[which]);
|
||||
PlayerService ps = PlayerService.get();
|
||||
if (ps != null) ps.applySpeed();
|
||||
speed.setText(speedLabel(SPEEDS[which]));
|
||||
d.dismiss();
|
||||
}
|
||||
}).show();
|
||||
}
|
||||
|
||||
private void sleepDialog() {
|
||||
final int[] minutes = {0, 5, 10, 15, 30, 45, 60, -1};
|
||||
String[] labels = {"Desactivado", "5 minutos", "10 minutos", "15 minutos", "30 minutos", "45 minutos", "1 hora",
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package com.miniabs;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
|
||||
@@ -10,28 +9,25 @@ 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.graphics.Bitmap;
|
||||
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.
|
||||
* sync, and falls back to saved files and local progress when offline. The player is an {@link Engine}: Android's
|
||||
* MediaPlayer, or ExoPlayer (with speed and skipping silences) on the Android 4.1 build.
|
||||
*/
|
||||
public class PlayerService extends Service implements MediaPlayer.OnPreparedListener,
|
||||
MediaPlayer.OnCompletionListener, MediaPlayer.OnErrorListener, MediaPlayer.OnBufferingUpdateListener,
|
||||
AudioManager.OnAudioFocusChangeListener {
|
||||
public class PlayerService extends Service implements Engine.Listener, AudioManager.OnAudioFocusChangeListener {
|
||||
|
||||
static final String A_PLAY = "com.miniabs.PLAY";
|
||||
static final String A_TOGGLE = "com.miniabs.TOGGLE";
|
||||
@@ -68,8 +64,13 @@ public class PlayerService extends Service implements MediaPlayer.OnPreparedList
|
||||
private WifiManager.WifiLock wifi;
|
||||
private ComponentName buttons;
|
||||
|
||||
private MediaPlayer mp;
|
||||
private Engine mp;
|
||||
private boolean prepared;
|
||||
private String sourceUrl; // what the engine was opened with, to reopen it in the other engine
|
||||
private boolean fallback; // this episode didn't decode in ExoPlayer: Android's MediaPlayer
|
||||
private boolean engineSkip; // whether the engine was opened skipping silences
|
||||
private String coverFor; // the notification and lock screen's cover
|
||||
private Bitmap cover;
|
||||
private Episode ep;
|
||||
private volatile String sessionId;
|
||||
private String contentUrl; // server-relative stream URL, re-tokenised on retry
|
||||
@@ -79,7 +80,6 @@ public class PlayerService extends Service implements MediaPlayer.OnPreparedList
|
||||
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;
|
||||
@@ -118,6 +118,7 @@ public class PlayerService extends Service implements MediaPlayer.OnPreparedList
|
||||
public void onCreate() {
|
||||
super.onCreate();
|
||||
instance = this;
|
||||
Platform.init(this);
|
||||
am = (AudioManager) getSystemService(AUDIO_SERVICE);
|
||||
wifi = ((WifiManager) getSystemService(WIFI_SERVICE)).createWifiLock(WifiManager.WIFI_MODE_FULL, "Podlite");
|
||||
wifi.setReferenceCounted(false);
|
||||
@@ -137,6 +138,7 @@ public class PlayerService extends Service implements MediaPlayer.OnPreparedList
|
||||
unregisterReceiver(noisy);
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
Platform.lockScreen(this, null, null, IDLE);
|
||||
am.unregisterMediaButtonEventReceiver(buttons);
|
||||
dropFocus();
|
||||
wifi.release();
|
||||
@@ -179,7 +181,7 @@ public class PlayerService extends Service implements MediaPlayer.OnPreparedList
|
||||
int state() { return state; }
|
||||
String status() { return status; }
|
||||
boolean isLocal() { return local; }
|
||||
int bufferedPercent() { return local ? 100 : buffered; }
|
||||
int bufferedPercent() { return local ? 100 : mp != null ? mp.bufferedPercent() : 0; }
|
||||
|
||||
boolean isPlaying() {
|
||||
return state == PLAYING;
|
||||
@@ -188,7 +190,7 @@ public class PlayerService extends Service implements MediaPlayer.OnPreparedList
|
||||
int positionMs() {
|
||||
if (mp != null && prepared) {
|
||||
try {
|
||||
return mp.getCurrentPosition();
|
||||
return mp.position();
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
@@ -199,7 +201,7 @@ public class PlayerService extends Service implements MediaPlayer.OnPreparedList
|
||||
if (ep != null && ep.duration > 0) return (int) (ep.duration * 1000);
|
||||
if (mp != null && prepared) {
|
||||
try {
|
||||
return mp.getDuration();
|
||||
return mp.duration();
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
@@ -231,6 +233,7 @@ public class PlayerService extends Service implements MediaPlayer.OnPreparedList
|
||||
sessionId = null;
|
||||
contentUrl = null;
|
||||
retries = 0;
|
||||
fallback = false;
|
||||
lastPosMs = 0;
|
||||
resumeMs = 0;
|
||||
Prefs.get(this).setLastEpisode(e);
|
||||
@@ -313,6 +316,7 @@ public class PlayerService extends Service implements MediaPlayer.OnPreparedList
|
||||
wifi.release();
|
||||
stopForeground(true);
|
||||
((NotificationManager) getSystemService(NOTIFICATION_SERVICE)).cancel(NOTIF_ID);
|
||||
Platform.lockScreen(this, null, null, IDLE);
|
||||
ep = null;
|
||||
stopSelf();
|
||||
}
|
||||
@@ -450,35 +454,29 @@ public class PlayerService extends Service implements MediaPlayer.OnPreparedList
|
||||
|
||||
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);
|
||||
sourceUrl = url;
|
||||
mp = Platform.newEngine(getApplicationContext(), fallback);
|
||||
mp.setListener(this);
|
||||
// before the audio is opened: ExoPlayer sets up skipping silences only then
|
||||
Prefs prefs = Prefs.get(this);
|
||||
engineSkip = prefs.skipSilence();
|
||||
mp.setSpeed(prefs.speed(), engineSkip);
|
||||
try {
|
||||
if (local) {
|
||||
FileInputStream in = new FileInputStream(url);
|
||||
try {
|
||||
mp.setDataSource(in.getFD());
|
||||
} finally {
|
||||
in.close();
|
||||
}
|
||||
mp.setSource(new File(url), null);
|
||||
} else {
|
||||
mp.setDataSource(url);
|
||||
mp.setSource(null, url);
|
||||
wifi.acquire();
|
||||
}
|
||||
state = LOADING;
|
||||
status = note != null ? note : local ? "Abrindo o ficheiro gardado…" : "Cargando…";
|
||||
mp.prepareAsync();
|
||||
mp.prepare();
|
||||
} catch (Exception ex) {
|
||||
fail("Non se pode abrir o audio: " + Util.describe(ex));
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPrepared(MediaPlayer p) {
|
||||
public void onPrepared(Engine p) {
|
||||
if (p != mp) return;
|
||||
prepared = true;
|
||||
int dur = durationMs();
|
||||
@@ -498,6 +496,22 @@ public class PlayerService extends Service implements MediaPlayer.OnPreparedList
|
||||
}
|
||||
}
|
||||
|
||||
/** Speed or skipping silences changed in the player screen or Settings. Speed changes as it plays; skipping
|
||||
* silences only takes effect when the audio is opened, so the episode is reopened here at the same spot. */
|
||||
void applySpeed() {
|
||||
Prefs p = Prefs.get(this);
|
||||
if (mp == null) return;
|
||||
if (Platform.SPEED && prepared && sourceUrl != null && p.skipSilence() != engineSkip) {
|
||||
boolean wasPlaying = state == PLAYING;
|
||||
resumeMs = positionMs();
|
||||
lastPosMs = resumeMs;
|
||||
playOnPrepared = wasPlaying;
|
||||
prepare(sourceUrl, null);
|
||||
return;
|
||||
}
|
||||
mp.setSpeed(p.speed(), engineSkip);
|
||||
}
|
||||
|
||||
private void start() {
|
||||
if (mp == null) return;
|
||||
if (!focusHeld) {
|
||||
@@ -506,7 +520,8 @@ public class PlayerService extends Service implements MediaPlayer.OnPreparedList
|
||||
}
|
||||
am.registerMediaButtonEventReceiver(buttons);
|
||||
try {
|
||||
mp.setVolume(1f, 1f);
|
||||
mp.setVolume(1f);
|
||||
applySpeed();
|
||||
mp.start();
|
||||
} catch (Exception ex) {
|
||||
fail("Fallou a reprodución: " + Util.describe(ex));
|
||||
@@ -533,26 +548,15 @@ public class PlayerService extends Service implements MediaPlayer.OnPreparedList
|
||||
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) {
|
||||
public void onCompletion(Engine p) {
|
||||
if (p != mp) return;
|
||||
int pos = positionMs(), dur = durationMs();
|
||||
// Only playback that got to (nearly) the end counts as listened; anything else is never
|
||||
@@ -577,16 +581,24 @@ public class PlayerService extends Service implements MediaPlayer.OnPreparedList
|
||||
showNotification(false);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onError(MediaPlayer p, int what, int extra) {
|
||||
if (p != mp) return true;
|
||||
public void onError(Engine p, String message, boolean decoder) {
|
||||
if (p != mp) return;
|
||||
if (prepared) lastPosMs = positionMs();
|
||||
if (decoder && !fallback && !(p instanceof MediaPlayerEngine) && sourceUrl != null) {
|
||||
// ExoPlayer can't decode this episode on this phone: Android's own player may (other decoders)
|
||||
com.miniabs.net.DiagLog.log("ExoPlayer can't decode \"" + ep.title + "\" (" + message + "): MediaPlayer instead");
|
||||
fallback = true;
|
||||
boolean wasPlaying = state == PLAYING || playOnPrepared;
|
||||
resumeMs = lastPosMs;
|
||||
playOnPrepared = wasPlaying;
|
||||
prepare(sourceUrl, null);
|
||||
return;
|
||||
}
|
||||
if (!local && contentUrl != null) {
|
||||
retryStream("Perdeuse a conexión, reconectando…");
|
||||
} else {
|
||||
fail("Erro de reprodución (" + what + "/" + extra + ").");
|
||||
fail(message);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Re-opens the stream at the last position with a fresh token. Gives up after three tries. */
|
||||
@@ -647,11 +659,11 @@ public class PlayerService extends Service implements MediaPlayer.OnPreparedList
|
||||
}
|
||||
break;
|
||||
case AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK:
|
||||
if (mp != null && state == PLAYING) mp.setVolume(0.2f, 0.2f);
|
||||
if (mp != null && state == PLAYING) mp.setVolume(0.2f);
|
||||
break;
|
||||
case AudioManager.AUDIOFOCUS_GAIN:
|
||||
focusHeld = true;
|
||||
if (mp != null) mp.setVolume(1f, 1f);
|
||||
if (mp != null) mp.setVolume(1f);
|
||||
if (pausedByFocus) {
|
||||
pausedByFocus = false;
|
||||
if (state == PAUSED) start();
|
||||
@@ -767,12 +779,21 @@ public class PlayerService extends Service implements MediaPlayer.OnPreparedList
|
||||
|
||||
// --------------------------------------------------------------- notification
|
||||
|
||||
/** The notification and the lock screen show the episode and the state. */
|
||||
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);
|
||||
if (!ep.itemId.equals(coverFor)) {
|
||||
coverFor = ep.itemId;
|
||||
cover = null;
|
||||
final String want = ep.itemId;
|
||||
Covers.load(this, want, Platform.COVER_PX, new Covers.Callback() {
|
||||
public void onCover(Bitmap b) {
|
||||
if (b == null || !want.equals(coverFor) || instance != PlayerService.this || ep == null) return;
|
||||
cover = b;
|
||||
showNotification(state == PLAYING || state == LOADING);
|
||||
}
|
||||
});
|
||||
}
|
||||
String what;
|
||||
switch (state) {
|
||||
case PLAYING: what = "Reproducindo"; break;
|
||||
@@ -781,12 +802,13 @@ public class PlayerService extends Service implements MediaPlayer.OnPreparedList
|
||||
case ERROR: what = "Detido: toca para tentalo de novo"; break;
|
||||
default: what = "En pausa"; break;
|
||||
}
|
||||
n.setLatestEventInfo(this, ep.title, Util.join(what, ep.show), pi);
|
||||
Notification n = Platform.playerNotification(this, ep, what, cover, state, foreground);
|
||||
if (foreground) {
|
||||
startForeground(NOTIF_ID, n);
|
||||
} else {
|
||||
stopForeground(false);
|
||||
((NotificationManager) getSystemService(NOTIFICATION_SERVICE)).notify(NOTIF_ID, n);
|
||||
}
|
||||
Platform.lockScreen(this, ep, cover, state);
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,14 @@ final class Prefs {
|
||||
String libraryId() { return s("library_id"); }
|
||||
String libraryName() { return s("library_name"); }
|
||||
|
||||
/** Playback speed (Android 4.1 build): 1 = normal. */
|
||||
float speed() {
|
||||
try { return Float.parseFloat(sp.getString("speed", "1")); } catch (Exception e) { return 1f; }
|
||||
}
|
||||
void setSpeed(float s) { sp.edit().putString("speed", Float.toString(s)).commit(); }
|
||||
/** Skip the silences in episodes (Android 4.1 build). */
|
||||
boolean skipSilence() { return sp.getBoolean("skip_silence", false); }
|
||||
|
||||
int skipBack() { return num("skip_back", 15); }
|
||||
int skipFwd() { return num("skip_fwd", 30); }
|
||||
|
||||
|
||||
@@ -28,6 +28,26 @@ public class SettingsActivity extends PreferenceActivity {
|
||||
back.setOnPreferenceChangeListener(skipSummary);
|
||||
fwd.setOnPreferenceChangeListener(skipSummary);
|
||||
|
||||
if (Platform.SPEED) { // Android 4.1 build: ExoPlayer can leave out the silences
|
||||
android.preference.CheckBoxPreference silence = new android.preference.CheckBoxPreference(this);
|
||||
silence.setKey("skip_silence");
|
||||
silence.setTitle("Saltar os silencios");
|
||||
silence.setSummary("Os episodios acaban antes sen perder nada");
|
||||
silence.setDefaultValue(false);
|
||||
((android.preference.PreferenceCategory) findPreference("playback")).addPreference(silence);
|
||||
silence.setOnPreferenceChangeListener(new Preference.OnPreferenceChangeListener() {
|
||||
public boolean onPreferenceChange(Preference pref, final Object value) {
|
||||
new android.os.Handler().post(new Runnable() { // after the new value is saved
|
||||
public void run() {
|
||||
PlayerService ps = PlayerService.get();
|
||||
if (ps != null) ps.applySpeed();
|
||||
}
|
||||
});
|
||||
return true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
findPreference("covers").setOnPreferenceChangeListener(new Preference.OnPreferenceChangeListener() {
|
||||
public boolean onPreferenceChange(Preference pref, Object value) {
|
||||
Covers.clearMemory();
|
||||
|
||||
@@ -4,35 +4,47 @@ import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
import android.content.Context;
|
||||
|
||||
/** Mozilla roots shipped as assets/roots/<sha1(subject)>-<n>.der */
|
||||
/**
|
||||
* Mozilla roots shipped as assets/roots/<sha1(subject)>-<n>.der
|
||||
*
|
||||
* Roots are read from the APK once and kept: an app installed on the SD card loses its APK while the
|
||||
* card is shared over USB, and a failed read must not leave every server "untrusted" until a restart.
|
||||
*/
|
||||
public final class AssetRoots implements CertVerifier.RootStore {
|
||||
private final Context ctx;
|
||||
private Set<String> names;
|
||||
private Set<String> names; // asset file names, once listed
|
||||
private final Map<String, List<byte[]>> found = new HashMap<String, List<byte[]>>(); // by subject hash; null = none
|
||||
|
||||
public AssetRoots(Context c) { ctx = c.getApplicationContext(); }
|
||||
|
||||
public synchronized List<byte[]> find(byte[] subjectDer) {
|
||||
String h = CertVerifier.sha1Hex(subjectDer);
|
||||
if (found.containsKey(h)) return found.get(h);
|
||||
try {
|
||||
if (names == null) {
|
||||
names = new HashSet<String>();
|
||||
String[] l = ctx.getAssets().list("roots");
|
||||
if (l != null) for (int i = 0; i < l.length; i++) names.add(l[i]);
|
||||
if (l == null || l.length == 0) return null; // APK unreadable right now: try again next time
|
||||
Set<String> n = new HashSet<String>();
|
||||
for (int i = 0; i < l.length; i++) n.add(l[i]);
|
||||
names = n;
|
||||
}
|
||||
String h = CertVerifier.sha1Hex(subjectDer);
|
||||
List<byte[]> out = null;
|
||||
for (int i = 0; names.contains(h + "-" + i + ".der"); i++) {
|
||||
if (out == null) out = new ArrayList<byte[]>();
|
||||
out.add(readAll(ctx.getAssets().open("roots/" + h + "-" + i + ".der")));
|
||||
}
|
||||
found.put(h, out);
|
||||
return out;
|
||||
} catch (IOException e) {
|
||||
return null;
|
||||
return null; // not cached: the next handshake reads again
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -451,19 +451,25 @@ public final class Http {
|
||||
// ------------------------------------------------------------------ body streams
|
||||
|
||||
private static final class FixedStream extends InputStream {
|
||||
private final Conn c; private long left; private final boolean keepAlive; private boolean closed;
|
||||
private final Conn c; private long left; private final boolean keepAlive; private volatile boolean closed;
|
||||
|
||||
FixedStream(Conn c, long len, boolean keepAlive) {
|
||||
this.c = c; this.left = len; this.keepAlive = keepAlive;
|
||||
if (len == 0) finish(true);
|
||||
}
|
||||
|
||||
private void finish(boolean complete) {
|
||||
private synchronized void finish(boolean complete) {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
if (complete && keepAlive) release(c); else c.close();
|
||||
}
|
||||
|
||||
synchronized void abort() {
|
||||
if (closed) return; // done: the connection may already be back in the pool, serving someone else
|
||||
closed = true;
|
||||
try { c.socket.close(); } catch (Exception ignored) {}
|
||||
}
|
||||
|
||||
@Override public int read() throws IOException {
|
||||
byte[] one = new byte[1];
|
||||
int n = read(one, 0, 1);
|
||||
@@ -490,16 +496,22 @@ public final class Http {
|
||||
|
||||
private static final class ChunkedStream extends InputStream {
|
||||
private final Conn c; private final boolean keepAlive;
|
||||
private long chunkLeft = 0; private boolean eof; private boolean closed;
|
||||
private long chunkLeft = 0; private boolean eof; private volatile boolean closed;
|
||||
|
||||
ChunkedStream(Conn c, boolean keepAlive) { this.c = c; this.keepAlive = keepAlive; }
|
||||
|
||||
private void finish(boolean complete) {
|
||||
private synchronized void finish(boolean complete) {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
if (complete && keepAlive) release(c); else c.close();
|
||||
}
|
||||
|
||||
synchronized void abort() {
|
||||
if (closed) return; // done: the connection may already be back in the pool, serving someone else
|
||||
closed = true;
|
||||
try { c.socket.close(); } catch (Exception ignored) {}
|
||||
}
|
||||
|
||||
private boolean nextChunk() throws IOException {
|
||||
if (eof) return false;
|
||||
String line = readLine(c.in);
|
||||
@@ -551,7 +563,7 @@ public final class Http {
|
||||
}
|
||||
|
||||
private static final class EofStream extends InputStream {
|
||||
private final Conn c; private boolean closed;
|
||||
private final Conn c; private volatile boolean closed;
|
||||
EofStream(Conn c) { this.c = c; }
|
||||
|
||||
@Override public int read() throws IOException {
|
||||
@@ -568,21 +580,25 @@ public final class Http {
|
||||
return n;
|
||||
}
|
||||
|
||||
@Override public void close() {
|
||||
@Override public synchronized void close() {
|
||||
if (!closed) { closed = true; c.close(); }
|
||||
}
|
||||
}
|
||||
|
||||
/** Close one in-flight response's socket from another thread (used to abort streams). */
|
||||
public static void abort(Response r) {
|
||||
if (r == null) return;
|
||||
InputStream b = r.body;
|
||||
Conn c = null;
|
||||
if (b instanceof FixedStream) c = ((FixedStream) b).c;
|
||||
else if (b instanceof ChunkedStream) c = ((ChunkedStream) b).c;
|
||||
else if (b instanceof EofStream) c = ((EofStream) b).c;
|
||||
if (c != null) {
|
||||
synchronized void abort() {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
try { c.socket.close(); } catch (Exception ignored) {}
|
||||
}
|
||||
}
|
||||
|
||||
/** Close one in-flight response's socket from another thread (used to abort streams). A response that has
|
||||
* already been read to the end is left alone: its connection went back to the pool and may be carrying
|
||||
* another request by now (closing it cut read-ahead downloads short: "Unexpected end of stream"). */
|
||||
public static void abort(Response r) {
|
||||
if (r == null) return;
|
||||
InputStream b = r.body;
|
||||
if (b instanceof FixedStream) ((FixedStream) b).abort();
|
||||
else if (b instanceof ChunkedStream) ((ChunkedStream) b).abort();
|
||||
else if (b instanceof EofStream) ((EofStream) b).abort();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Checks the dex inside an APK the way Dalvik's dexopt does at install time: format version,
|
||||
size, Adler-32, SHA-1, no post-035 sections. usage: check_dex.py app.apk"""
|
||||
import hashlib
|
||||
import struct
|
||||
import sys
|
||||
import zipfile
|
||||
import zlib
|
||||
|
||||
d = zipfile.ZipFile(sys.argv[1]).read("classes.dex")
|
||||
problems = []
|
||||
if d[:8] != b"dex\n035\x00":
|
||||
problems.append("dex version %r (Android 2.3 needs 035)" % d[:8])
|
||||
if struct.unpack("<I", d[32:36])[0] != len(d):
|
||||
problems.append("file_size field wrong")
|
||||
if zlib.adler32(d[12:]) & 0xFFFFFFFF != struct.unpack("<I", d[8:12])[0]:
|
||||
problems.append("Adler-32 checksum wrong")
|
||||
if hashlib.sha1(d[32:]).digest() != d[12:32]:
|
||||
problems.append("SHA-1 signature wrong")
|
||||
map_off = struct.unpack("<I", d[52:56])[0]
|
||||
for i in range(struct.unpack("<I", d[map_off:map_off + 4])[0]):
|
||||
t = struct.unpack("<H", d[map_off + 4 + i * 12:map_off + 6 + i * 12])[0]
|
||||
if t in (7, 8):
|
||||
problems.append("contains call_site/method_handle sections (needs Android 8+)")
|
||||
for p in problems:
|
||||
print("DEX PROBLEM:", p)
|
||||
print("classes.dex: %d bytes, %s" % (len(d), "OK" if not problems else "REJECTED"))
|
||||
sys.exit(1 if problems else 0)
|
||||
@@ -0,0 +1,35 @@
|
||||
#!/bin/bash
|
||||
# Fetches what the Android 4.1 build needs beyond tools/setup_toolchain.sh: ExoPlayer 2.18.7 (Google's Maven) and
|
||||
# Guava (Maven Central), checked against these SHA-256s, into $TOOLS/exoplayer-2.18.7 (TOOLS default ~/android-toolchain).
|
||||
# 2.18.7 because it's the newest that needs only Guava (2.19 adds AndroidX Core); every 2.x runs on Android 4.1.
|
||||
set -euo pipefail
|
||||
T=${TOOLS:-$HOME/android-toolchain}
|
||||
D=$T/exoplayer-2.18.7
|
||||
G=https://dl.google.com/dl/android/maven2/com/google/android/exoplayer
|
||||
M=https://repo1.maven.org/maven2/com/google/guava
|
||||
mkdir -p "$D"
|
||||
tmp=$(mktemp -d)
|
||||
trap 'rm -rf "$tmp"' EXIT
|
||||
get() { # url sha256 file
|
||||
if [ -f "$D/$3" ] && [ "$(shasum -a 256 "$D/$3" | cut -d' ' -f1)" = "$2" ]; then return; fi
|
||||
curl -fsSL -o "$tmp/$3" "$1"
|
||||
[ "$(shasum -a 256 "$tmp/$3" | cut -d' ' -f1)" = "$2" ] || { echo "$3: checksum mismatch"; exit 1; }
|
||||
mv "$tmp/$3" "$D/$3"
|
||||
}
|
||||
for m in core:2b51c5f07aa25f89e4adb7a833073e1a4adc5b87a388bd0045ebc3429b1bf42f \
|
||||
common:adfef7d3cbd69dd8fcc4ce05642a886c9fbc2764826bc39a6075ba6a0c62d003 \
|
||||
datasource:cbe67ad9eaf94be7b35d1a625252a79ed5c18601d19f7b4d787acfbcea009208 \
|
||||
decoder:598c0b444db71770f9b89b4e444e34cb03d5ed2eb5c12fb062afb2f9b05b9a1e \
|
||||
extractor:c3e34c7b07ecd3c78a75bb7185671030bea4371ac88d854000a856fc05f7eeff \
|
||||
database:31f0929246fb81296345be8414e7fa080a82deb8d382eb5d875974649030619d; do
|
||||
a=${m%%:*}; sum=${m#*:}
|
||||
get "$G/exoplayer-$a/2.18.7/exoplayer-$a-2.18.7.aar" "$sum" "exoplayer-$a.aar"
|
||||
# an AAR is a zip: the code is classes.jar, the keep rules for apps that shrink it are proguard.txt
|
||||
unzip -o -q -d "$tmp/$a" "$D/exoplayer-$a.aar"
|
||||
cp "$tmp/$a/classes.jar" "$D/exoplayer-$a.jar"
|
||||
if [ -f "$tmp/$a/proguard.txt" ]; then cp "$tmp/$a/proguard.txt" "$D/exoplayer-$a.pro"; fi
|
||||
rm "$D/exoplayer-$a.aar"
|
||||
done
|
||||
get "$M/guava/31.0.1-android/guava-31.0.1-android.jar" 9425a423a4cb9d9db0356300722d9bd8e634cf539f29d97bb84f457cccd16eb8 guava.jar
|
||||
get "$M/failureaccess/1.0.1/failureaccess-1.0.1.jar" a171ee4c734dd2da837e4b16be9df4661afab72a41adaf31eb84dfdaf936ca26 failureaccess.jar
|
||||
ls "$D"
|
||||
@@ -15,7 +15,7 @@ from PIL import Image, ImageDraw
|
||||
RES = sys.argv[1]
|
||||
ROOT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..")
|
||||
DENS = {"ldpi": 0.75, "mdpi": 1.0, "hdpi": 1.5}
|
||||
LILAC = (188, 138, 252, 255) # the icon's sound-wave colour (#BC8AFC)
|
||||
AMBER = (232, 166, 74, 255) # accent_light (#E8A64A), from the radio icon's brass
|
||||
WHITE = (255, 255, 255, 255)
|
||||
SS = 4 # supersampling
|
||||
|
||||
@@ -91,9 +91,9 @@ def refresh(px, folder):
|
||||
sil = status_silhouette()
|
||||
for folder, k in DENS.items():
|
||||
status(sil, round(25 * k), folder)
|
||||
play(round(32 * k), folder, "ic_play", WHITE) # on the purple play button
|
||||
play(round(32 * k), folder, "ic_play", WHITE) # on the amber play button
|
||||
pause(round(32 * k), folder, "ic_pause", WHITE)
|
||||
play(round(38 * k), folder, "ic_play_small", LILAC) # mini player bar, on dark
|
||||
pause(round(38 * k), folder, "ic_pause_small", LILAC)
|
||||
play(round(38 * k), folder, "ic_play_small", AMBER) # mini player bar, on dark
|
||||
pause(round(38 * k), folder, "ic_pause_small", AMBER)
|
||||
refresh(round(30 * k), folder)
|
||||
print("icons ok")
|
||||
@@ -0,0 +1,18 @@
|
||||
# R8 for the Android 4.1 build (build.sh): shrinks ExoPlayer and Guava to what the app uses and turns their Java 8
|
||||
# code into what Android 4.1 runs. The app (and its Bouncy Castle copy) stays whole and readable in crash reports.
|
||||
-keep class com.miniabs.** { *; }
|
||||
-keep class com.minamp.** { *; }
|
||||
-dontobfuscate
|
||||
# referenced only by annotations, or by code for newer Androids that ExoPlayer guards by version
|
||||
# (build.sh gives R8 the Android 4.1 API, so anything newer is "missing")
|
||||
-dontwarn android.**
|
||||
-dontwarn androidx.annotation.**
|
||||
-dontwarn org.checkerframework.**
|
||||
-dontwarn com.google.errorprone.annotations.**
|
||||
-dontwarn com.google.j2objc.annotations.**
|
||||
-dontwarn javax.annotation.**
|
||||
-dontwarn kotlin.**
|
||||
-dontwarn org.codehaus.mojo.animal_sniffer.**
|
||||
-dontwarn sun.misc.Unsafe
|
||||
-dontwarn java.lang.ClassValue
|
||||
-dontwarn javax.lang.model.element.Modifier
|
||||
@@ -0,0 +1,46 @@
|
||||
# Find invokeinterface calls to java.lang.Object methods (toString/equals/hashCode/getClass) through an
|
||||
# interface that doesn't declare them itself: Dalvik before 4.0 can't resolve those (NoSuchMethodError).
|
||||
import os, re, subprocess, sys, zipfile
|
||||
classes = sys.argv[1]; android_jar = sys.argv[2]
|
||||
OBJ = {"toString:()Ljava/lang/String;", "equals:(Ljava/lang/Object;)Z", "hashCode:()I", "getClass:()Ljava/lang/Class;"}
|
||||
z = zipfile.ZipFile(android_jar)
|
||||
declared = {}
|
||||
def declares(iface, m):
|
||||
key = (iface, m)
|
||||
if key in declared: return declared[key]
|
||||
path = os.path.join(classes, iface + ".class")
|
||||
src = path if os.path.exists(path) else None
|
||||
args = ["javap", "-p", "-s", "-cp", classes if src else android_jar, iface.replace("/", ".")]
|
||||
out = subprocess.run(args, capture_output=True, text=True).stdout
|
||||
name, desc = m.split(":")
|
||||
found = False
|
||||
lines = out.splitlines()
|
||||
for i, l in enumerate(lines):
|
||||
if re.search(r"\b" + name + r"\(", l) and i + 1 < len(lines) and desc in lines[i + 1]:
|
||||
found = True
|
||||
# superinterfaces declaring it also count
|
||||
if not found:
|
||||
mm = re.search(r"interface \S+ extends (.+?) \{", out)
|
||||
if mm:
|
||||
for sup in mm.group(1).split(","):
|
||||
sup = sup.strip().split("<")[0].replace(".", "/")
|
||||
if sup and declares(sup, m): found = True
|
||||
declared[key] = found
|
||||
return found
|
||||
hits = {}
|
||||
files = [os.path.join(dp, f) for dp, _, fs in os.walk(classes) for f in fs if f.endswith(".class")]
|
||||
for i in range(0, len(files), 200):
|
||||
out = subprocess.run(["javap", "-c", "-p"] + files[i:i+200], capture_output=True, text=True).stdout
|
||||
cur = None
|
||||
for line in out.splitlines():
|
||||
m = re.match(r"^(?:public |final |abstract |class |interface )*.*?(?:class|interface) (\S+)", line)
|
||||
if m and not line.startswith(" "): cur = m.group(1)
|
||||
mi = re.search(r"invokeinterface\s+#\d+,\s*\d+\s*// InterfaceMethod (\S+)\.(\w+):(\S+)", line)
|
||||
if mi:
|
||||
iface, name, desc = mi.group(1), mi.group(2), mi.group(3)
|
||||
sig = name + ":" + desc
|
||||
if sig in OBJ and not declares(iface, sig):
|
||||
hits.setdefault((iface, sig), set()).add(cur)
|
||||
for (iface, sig), users in sorted(hits.items()):
|
||||
print(f"{iface}.{sig} <- {len(users)} class(es): " + ", ".join(sorted(users))[:300])
|
||||
print("total patterns:", len(hits))
|
||||
@@ -0,0 +1,146 @@
|
||||
"""v1 (JAR) APK signer in the format Android's own SignApk used in the 2.x era, plus zipalign.
|
||||
|
||||
Why not jarsigner: modern JDKs write "SHA-1-Digest" entries (Android <= 4.2 only looks for
|
||||
"SHA1-Digest"/"SHA-Digest") and put sha1WithRSAEncryption in the PKCS#7 SignerInfo, which old
|
||||
JarUtils can't map. Either makes Android 2.x treat the APK as unsigned and refuse to install it.
|
||||
|
||||
Usage: signapk.py <keystore.p12> <password> <in.apk> <out.apk>
|
||||
"""
|
||||
import base64
|
||||
import hashlib
|
||||
import struct
|
||||
import sys
|
||||
import zipfile
|
||||
|
||||
from cryptography.hazmat.primitives import hashes, serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import padding
|
||||
from cryptography.hazmat.primitives.serialization import pkcs12
|
||||
|
||||
ALIGN = 4
|
||||
FIXED_TIME = (2008, 1, 1, 0, 0, 0)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- minimal DER
|
||||
|
||||
def der_len(n):
|
||||
if n < 0x80:
|
||||
return bytes([n])
|
||||
b = n.to_bytes((n.bit_length() + 7) // 8, "big")
|
||||
return bytes([0x80 | len(b)]) + b
|
||||
|
||||
|
||||
def tlv(tag, body):
|
||||
return bytes([tag]) + der_len(len(body)) + body
|
||||
|
||||
|
||||
def seq(*parts):
|
||||
return tlv(0x30, b"".join(parts))
|
||||
|
||||
|
||||
def set_of(*parts):
|
||||
return tlv(0x31, b"".join(sorted(parts)))
|
||||
|
||||
|
||||
def oid(dotted):
|
||||
nums = [int(x) for x in dotted.split(".")]
|
||||
body = bytes([nums[0] * 40 + nums[1]])
|
||||
for n in nums[2:]:
|
||||
chunk = [n & 0x7F]
|
||||
n >>= 7
|
||||
while n:
|
||||
chunk.append(0x80 | (n & 0x7F))
|
||||
n >>= 7
|
||||
body += bytes(reversed(chunk))
|
||||
return tlv(0x06, body)
|
||||
|
||||
|
||||
def integer(n):
|
||||
b = n.to_bytes((n.bit_length() + 8) // 8 or 1, "big", signed=True)
|
||||
return tlv(0x02, b)
|
||||
|
||||
|
||||
NULL = b"\x05\x00"
|
||||
OID_SHA1 = "1.3.14.3.2.26"
|
||||
OID_RSA = "1.2.840.113549.1.1.1"
|
||||
OID_DATA = "1.2.840.113549.1.7.1"
|
||||
OID_SIGNED_DATA = "1.2.840.113549.1.7.2"
|
||||
|
||||
|
||||
def pkcs7_detached(sf_bytes, key, cert):
|
||||
signature = key.sign(sf_bytes, padding.PKCS1v15(), hashes.SHA1())
|
||||
cert_der = cert.public_bytes(serialization.Encoding.DER)
|
||||
sha1_alg = seq(oid(OID_SHA1), NULL)
|
||||
signer_info = seq(
|
||||
integer(1),
|
||||
seq(cert.issuer.public_bytes(), integer(cert.serial_number)),
|
||||
sha1_alg,
|
||||
seq(oid(OID_RSA), NULL), # rsaEncryption, as old JarUtils expects
|
||||
tlv(0x04, signature),
|
||||
)
|
||||
signed_data = seq(
|
||||
integer(1),
|
||||
set_of(sha1_alg),
|
||||
seq(oid(OID_DATA)), # detached: content is META-INF/CERT.SF
|
||||
tlv(0xA0, cert_der), # [0] IMPLICIT certificates
|
||||
set_of(signer_info),
|
||||
)
|
||||
return seq(oid(OID_SIGNED_DATA), tlv(0xA0, signed_data))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- manifest text
|
||||
|
||||
def b64sha1(data):
|
||||
return base64.b64encode(hashlib.sha1(data).digest()).decode()
|
||||
|
||||
|
||||
def wrap(line):
|
||||
"""JAR manifest lines are at most 72 bytes; continuation lines start with a space."""
|
||||
raw = line.encode("utf-8")
|
||||
out = [raw[:70]]
|
||||
raw = raw[70:]
|
||||
while raw:
|
||||
out.append(b" " + raw[:69])
|
||||
raw = raw[69:]
|
||||
return b"\r\n".join(out) + b"\r\n"
|
||||
|
||||
|
||||
def sign(p12_path, password, src, dst):
|
||||
key, cert, _ = pkcs12.load_key_and_certificates(open(p12_path, "rb").read(), password.encode())
|
||||
zin = zipfile.ZipFile(src)
|
||||
entries = [i for i in zin.infolist()
|
||||
if not i.filename.startswith("META-INF/") and not i.is_dir()]
|
||||
data = {i.filename: zin.read(i.filename) for i in entries}
|
||||
|
||||
manifest = b"Manifest-Version: 1.0\r\nCreated-By: 1.0 (Android SignApk)\r\n\r\n"
|
||||
sf_entries = b""
|
||||
for i in entries:
|
||||
section = wrap("Name: " + i.filename) + wrap("SHA1-Digest: " + b64sha1(data[i.filename])) + b"\r\n"
|
||||
manifest += section
|
||||
sf_entries += wrap("Name: " + i.filename) + wrap("SHA1-Digest: " + b64sha1(section)) + b"\r\n"
|
||||
sf = (b"Signature-Version: 1.0\r\nCreated-By: 1.0 (Android SignApk)\r\n"
|
||||
+ wrap("SHA1-Digest-Manifest: " + b64sha1(manifest)) + b"\r\n" + sf_entries)
|
||||
rsa = pkcs7_detached(sf, key, cert)
|
||||
|
||||
with zipfile.ZipFile(dst, "w") as zout:
|
||||
def put(name, payload, compress):
|
||||
zi = zipfile.ZipInfo(name, date_time=FIXED_TIME)
|
||||
zi.compress_type = compress
|
||||
zi.external_attr = 0o644 << 16
|
||||
zi.create_system = 0
|
||||
extra = b""
|
||||
if compress == zipfile.ZIP_STORED:
|
||||
base = zout.fp.tell() + 30 + len(name.encode("utf-8")) + 6
|
||||
pad = (ALIGN - base % ALIGN) % ALIGN
|
||||
extra = struct.pack("<HHH", 0xD935, 2 + pad, ALIGN) + b"\0" * pad
|
||||
zi.extra = extra
|
||||
zout.writestr(zi, payload)
|
||||
|
||||
put("META-INF/MANIFEST.MF", manifest, zipfile.ZIP_DEFLATED)
|
||||
put("META-INF/CERT.SF", sf, zipfile.ZIP_DEFLATED)
|
||||
put("META-INF/CERT.RSA", rsa, zipfile.ZIP_DEFLATED)
|
||||
for i in entries:
|
||||
put(i.filename, data[i.filename], i.compress_type)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sign(*sys.argv[1:5])
|
||||