3 Commits
Author SHA1 Message Date
xockoandClaude Opus 5.5 0fea07f1b2 MiniABS 1.4: smaller player controls
- Play/pause becomes a rounded rectangle (58x46dp) instead of a 78dp circle,
  with 32dp icons and wider spacing (play_w/play_h/play_gap in dimens.xml)
- Skip buttons shrink from 82x56dp to 62x42dp, with 16sp text
- Version 1.4 (versionCode 5); there was no public 1.3

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 08:02:53 +02:00
xockoandClaude Opus 5.5 275bee3c11 MiniABS 1.2: Galician interface, larger text and touch targets
- Translate the interface to Galician (tools/translate_gl.py), including
  Galician month abbreviations for dates, which Android 2.3 lacks
- Resize for the Galaxy Mini's 240x320 screen: larger text, rows, tabs and
  buttons; all sizes now live in res/values/dimens.xml
- Redraw play/pause/refresh icons for every density; slightly brighter
  secondary text colours

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 21:29:17 +02:00
xockoandClaude Opus 5.5 b7e43d7d61 MiniABS 1.1: HTTPS support
- Bundle current root certificates (Let's Encrypt, GTS, Sectigo, DigiCert G2,
  Amazon, ...) missing from Android 2.x's store (Roots.java, tools/gen_roots.py)
- Ask once for unknown certificates, showing the SHA-256 fingerprint, and
  remember the answer; replaces "accept any certificate" (Trust*, Settings)
- Local streaming proxy so Android 2.x's MediaPlayer can play https:// servers,
  with seeking and a fresh token per request (StreamProxy.java)
- Tests over real TLS with good, expired, wrong-host, forged and self-signed
  certificates (test/make_pki.sh, TrustTest.java)
- README: server setup for a legacy TLS 1.0 port

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 20:07:55 +02:00
60 changed files with 1999 additions and 427 deletions

No files matched your search

+3 -3
View File
@@ -1,8 +1,8 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" <manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.miniabs" package="com.miniabs"
android:versionCode="1" android:versionCode="5"
android:versionName="1.0" android:versionName="1.4"
android:installLocation="auto"> android:installLocation="auto">
<uses-sdk android:minSdkVersion="8" android:targetSdkVersion="10" /> <uses-sdk android:minSdkVersion="8" android:targetSdkVersion="10" />
@@ -54,7 +54,7 @@
<activity <activity
android:name=".SettingsActivity" android:name=".SettingsActivity"
android:label="Settings" android:label="Axustes"
android:theme="@android:style/Theme" android:theme="@android:style/Theme"
android:screenOrientation="portrait" android:screenOrientation="portrait"
android:configChanges="keyboardHidden|orientation" /> android:configChanges="keyboardHidden|orientation" />
+55 -5
View File
@@ -1,6 +1,6 @@
# MiniABS # MiniABS
A tiny Audiobookshelf **podcast** client for the Samsung Galaxy Mini (GT-S5570) and other Android 2.2+ phones. Native Java, no libraries, ~100 KB. A tiny Audiobookshelf **podcast** client for the Samsung Galaxy Mini (GT-S5570) and other Android 2.2+ phones. Native Java, no libraries, ~130 KB. The interface is in Galician, with large text and touch targets sized for the Mini's 240×320 screen.
## Install ## Install
@@ -10,12 +10,58 @@ A tiny Audiobookshelf **podcast** client for the Samsung Galaxy Mini (GT-S5570)
## Connecting ## Connecting
Use your server's **local http:// address**, e.g. `http://192.168.1.10:13378`. At home, the simplest option is your server's **local http:// address**, e.g. `http://192.168.1.10:13378`.
Android 2.x only speaks old TLS and has an outdated certificate list, so it usually can't connect to modern HTTPS servers (including Let's Encrypt behind a reverse proxy). The "Accept any HTTPS certificate" box only helps with self-signed certificates on servers that still allow older TLS.
Sign in with your username and password, or tick "Use an API key instead". It works with both current servers (2.26+, short-lived access tokens that MiniABS refreshes automatically) and older ones (single long-lived token). Sign in with your username and password, or tick "Use an API key instead". It works with both current servers (2.26+, short-lived access tokens that MiniABS refreshes automatically) and older ones (single long-lived token).
## HTTPS (from outside your home)
Android 2.3 only speaks **TLS 1.0**, which modern servers refuse. So HTTPS needs one change on the server and is handled by the app from there.
### Server: a legacy HTTPS port
Add a second `server` block to nginx, on its own port, used only by the phone. Your normal HTTPS site stays strict.
```nginx
server {
listen 8443 ssl;
server_name abs.example.com;
ssl_certificate /etc/letsencrypt/live/abs.example.com/fullchain.pem; # must be an RSA certificate
ssl_certificate_key /etc/letsencrypt/live/abs.example.com/privkey.pem;
ssl_protocols TLSv1 TLSv1.1 TLSv1.2;
# @SECLEVEL=0 is required: OpenSSL 3 refuses TLS 1.0 without it
ssl_ciphers "ECDHE-RSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-SHA:AES128-SHA:@SECLEVEL=0";
ssl_prefer_server_ciphers on;
client_max_body_size 0;
location / {
proxy_pass http://127.0.0.1:13378;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_http_version 1.1;
proxy_buffering off;
}
}
```
Then sign in with `https://abs.example.com:8443`.
- **RSA certificate:** Android 2.3 can't use ECDSA certificates. With certbot, request one with `--key-type rsa`.
- **Security:** TLS 1.0 with these ciphers is weak by today's standards, but much better than plain HTTP over the internet. Only open this port if you need it.
- **Caddy** can't serve TLS 1.0; put nginx (or HAProxy) in front for this port.
### App: certificates
- **Built-in roots:** MiniABS trusts what the phone trusts, plus modern root certificates the phone's 2011-era list lacks: Let's Encrypt, Google Trust Services, Sectigo/ZeroSSL, DigiCert G2, Amazon and others. Most certificates just work.
- **Anything else** (self-signed, a private CA, a new root): MiniABS shows the certificate's SHA-256 fingerprint and asks once. Compare it with the fingerprint your computer's browser shows for the site before tapping Trust. It then trusts the CA that issued it, so renewals keep working.
- **Settings → Certificates you trusted** lets you forget them.
### App: streaming
Android 2.x's media player can't stream https:// URLs, so for HTTPS servers MiniABS runs a small proxy on the phone (127.0.0.1 only, random unguessable URLs). It forwards seeks and gets a fresh sign-in token for each request, so long episodes don't break when the token expires.
## Using it ## Using it
- **Latest**: "Continue listening" plus the newest episodes across your podcast library. Tap the library name at the top to switch libraries. - **Latest**: "Continue listening" plus the newest episodes across your podcast library. Tap the library name at the top to switch libraries.
@@ -45,4 +91,8 @@ Needs a JDK, `aapt`, `dalvik-exchange` (dx), `zipalign`, `apksigner` (Ubuntu: `a
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. Compiling against the API 8 jar guarantees no newer Android APIs are used. The APK is signed with `miniabs.keystore` (password `miniabs`); keep it to install updates over the top.
`test/run_tests.sh` runs the API, JSON and progress code on the desktop against a mock Audiobookshelf server (`test/mock_abs.py`). All sizes (text, rows, buttons, covers) are in `res/values/dimens.xml`; change them there and rebuild.
`tools/gen_roots.py` regenerates the bundled root certificates from a system CA store.
`test/run_tests.sh` runs the API, JSON, progress, HTTPS-trust and streaming-proxy code on the desktop JVM against a mock Audiobookshelf server (`test/mock_abs.py`), over plain HTTP and over TLS with good, expired, wrong-host, forged and self-signed certificates (`test/make_pki.sh`).
Binary file not shown.

Before

Width:  |  Height:  |  Size: 499 B

After

Width:  |  Height:  |  Size: 312 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 346 B

After

Width:  |  Height:  |  Size: 510 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 650 B

After

Width:  |  Height:  |  Size: 760 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 880 B

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 816 B

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 381 B

After

Width:  |  Height:  |  Size: 354 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 349 B

After

Width:  |  Height:  |  Size: 413 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 470 B

After

Width:  |  Height:  |  Size: 467 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 527 B

After

Width:  |  Height:  |  Size: 656 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 377 B

After

Width:  |  Height:  |  Size: 446 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 408 B

After

Width:  |  Height:  |  Size: 355 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 413 B

After

Width:  |  Height:  |  Size: 486 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 513 B

After

Width:  |  Height:  |  Size: 561 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 656 B

After

Width:  |  Height:  |  Size: 834 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 463 B

After

Width:  |  Height:  |  Size: 699 B

+2 -2
View File
@@ -1,9 +1,9 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android"> <selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_pressed="true"> <item android:state_pressed="true">
<shape android:shape="oval"><solid android:color="#FFC766" /></shape> <shape><solid android:color="#FFC766" /><corners android:radius="23dp" /></shape>
</item> </item>
<item> <item>
<shape android:shape="oval"><solid android:color="@color/accent" /></shape> <shape><solid android:color="@color/accent" /><corners android:radius="23dp" /></shape>
</item> </item>
</selector> </selector>
+2 -1
View File
@@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="oval"> <shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="oval">
<solid android:color="@color/accent" /> <solid android:color="@color/accent" />
<size android:width="14dp" android:height="14dp" /> <stroke android:width="2dp" android:color="@color/bg" />
<size android:width="@dimen/seek_thumb" android:height="@dimen/seek_thumb" />
</shape> </shape>
+8 -8
View File
@@ -10,7 +10,7 @@
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="MiniABS" android:text="MiniABS"
android:textSize="15sp" android:textSize="@dimen/header_text"
android:textStyle="bold" android:textStyle="bold"
android:textColor="@color/accent" /> android:textColor="@color/accent" />
@@ -20,8 +20,8 @@
android:layout_height="fill_parent" android:layout_height="fill_parent"
android:layout_weight="1" android:layout_weight="1"
android:gravity="center_vertical" android:gravity="center_vertical"
android:paddingLeft="8dp" android:paddingLeft="10dp"
android:textSize="11sp" android:textSize="@dimen/header_sub"
android:textColor="@color/sub" android:textColor="@color/sub"
android:singleLine="true" android:singleLine="true"
android:ellipsize="end" android:ellipsize="end"
@@ -29,7 +29,7 @@
<ImageButton <ImageButton
android:id="@+id/refresh" android:id="@+id/refresh"
android:layout_width="36dp" android:layout_width="@dimen/header_h"
android:layout_height="fill_parent" android:layout_height="fill_parent"
android:background="@drawable/row_sel" android:background="@drawable/row_sel"
android:src="@drawable/ic_refresh" /> android:src="@drawable/ic_refresh" />
@@ -37,12 +37,12 @@
<LinearLayout <LinearLayout
android:layout_width="fill_parent" android:layout_width="fill_parent"
android:layout_height="32dp" android:layout_height="@dimen/tab_h"
android:orientation="horizontal"> android:orientation="horizontal">
<TextView android:id="@+id/tab_latest" style="@style/Tab" android:text="Latest" /> <TextView android:id="@+id/tab_latest" style="@style/Tab" android:text="Últimos" />
<TextView android:id="@+id/tab_shows" style="@style/Tab" android:text="Shows" /> <TextView android:id="@+id/tab_shows" style="@style/Tab" android:text="Programas" />
<TextView android:id="@+id/tab_saved" style="@style/Tab" android:text="Saved" /> <TextView android:id="@+id/tab_saved" style="@style/Tab" android:text="Gardados" />
</LinearLayout> </LinearLayout>
<include layout="@layout/list_area" /> <include layout="@layout/list_area" />
+24 -28
View File
@@ -15,50 +15,50 @@
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:orientation="horizontal" android:orientation="horizontal"
android:gravity="center_vertical" android:gravity="center_vertical"
android:layout_marginBottom="4dp"> android:layout_marginBottom="6dp">
<ImageView <ImageView
android:layout_width="36dp" android:layout_width="48dp"
android:layout_height="36dp" android:layout_height="48dp"
android:src="@drawable/ic_launcher" /> android:src="@drawable/ic_launcher" />
<LinearLayout <LinearLayout
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:orientation="vertical" android:orientation="vertical"
android:paddingLeft="8dp"> android:paddingLeft="10dp">
<TextView <TextView
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="MiniABS" android:text="MiniABS"
android:textSize="18sp" android:textSize="@dimen/login_title"
android:textStyle="bold" android:textStyle="bold"
android:textColor="@color/accent" /> android:textColor="@color/accent" />
<TextView <TextView
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="Audiobookshelf podcasts" android:text="Podcasts de Audiobookshelf"
android:textSize="11sp" android:textSize="@dimen/login_tip"
android:textColor="@color/sub" /> android:textColor="@color/sub" />
</LinearLayout> </LinearLayout>
</LinearLayout> </LinearLayout>
<TextView style="@style/Label" android:text="Server address" /> <TextView style="@style/Label" android:text="Enderezo do servidor" />
<EditText <EditText
android:id="@+id/server" android:id="@+id/server"
style="@style/Field" style="@style/Field"
android:hint="http://192.168.1.10:13378" android:hint="http://192.168.1.10:13378"
android:inputType="textUri" /> android:inputType="textUri" />
<TextView android:id="@+id/user_label" style="@style/Label" android:text="Username" /> <TextView android:id="@+id/user_label" style="@style/Label" android:text="Usuario" />
<EditText <EditText
android:id="@+id/user" android:id="@+id/user"
style="@style/Field" style="@style/Field"
android:inputType="text" /> android:inputType="text" />
<TextView android:id="@+id/pass_label" style="@style/Label" android:text="Password" /> <TextView android:id="@+id/pass_label" style="@style/Label" android:text="Contrasinal" />
<EditText <EditText
android:id="@+id/pass" android:id="@+id/pass"
style="@style/Field" style="@style/Field"
@@ -66,43 +66,39 @@
<CheckBox <CheckBox
android:id="@+id/use_key" android:id="@+id/use_key"
android:layout_width="wrap_content" android:layout_width="fill_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:textSize="12sp" android:layout_marginTop="4dp"
android:text="Use an API key instead" /> android:minHeight="48dp"
android:textSize="@dimen/login_check"
<CheckBox android:text="Usar unha chave da API" />
android:id="@+id/trust"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="12sp"
android:text="Accept any HTTPS certificate (insecure)" />
<Button <Button
android:id="@+id/connect" android:id="@+id/connect"
android:layout_width="fill_parent" android:layout_width="fill_parent"
android:layout_height="42dp" android:layout_height="@dimen/login_btn_h"
android:layout_marginTop="6dp" android:layout_marginTop="6dp"
android:background="@drawable/btn_accent" android:background="@drawable/btn_accent"
android:text="Connect" android:text="Conectar"
android:textStyle="bold" android:textStyle="bold"
android:textSize="14sp" android:textSize="@dimen/login_btn_text"
android:textColor="#121519" /> android:textColor="#121519" />
<TextView <TextView
android:id="@+id/status" android:id="@+id/status"
android:layout_width="fill_parent" android:layout_width="fill_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginTop="8dp" android:layout_marginTop="10dp"
android:textSize="12sp" android:textSize="@dimen/login_status"
android:textColor="@color/accent" /> android:textColor="@color/accent" />
<TextView <TextView
android:layout_width="fill_parent" android:layout_width="fill_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginTop="8dp" android:layout_marginTop="10dp"
android:textSize="10sp" android:textSize="@dimen/login_tip"
android:textColor="@color/dim" android:textColor="@color/dim"
android:text="Tip: Android 2.x can't talk to most modern HTTPS servers. Use your server's local http:// address (on Wi-Fi) for the most reliable connection." /> android:lineSpacingExtra="2dp"
android:text="Na casa, usa o enderezo local http:// do servidor. Para https://, Android 2.3 precisa que o servidor ofreza TLS 1.0 nun porto á parte (ve o README). Se o certificado non se recoñece, amosaráseche a súa pegada dixital para que a comprobes." />
</LinearLayout> </LinearLayout>
</ScrollView> </ScrollView>
+37 -33
View File
@@ -11,7 +11,9 @@
android:layout_height="fill_parent" android:layout_height="fill_parent"
android:layout_weight="1" android:layout_weight="1"
android:gravity="center_vertical" android:gravity="center_vertical"
android:textSize="12sp" android:paddingRight="10dp"
android:textSize="@dimen/header_sub"
android:textStyle="bold"
android:textColor="@color/accent" android:textColor="@color/accent"
android:singleLine="true" android:singleLine="true"
android:ellipsize="end" android:ellipsize="end"
@@ -27,9 +29,9 @@
<ImageView <ImageView
android:id="@+id/cover" android:id="@+id/cover"
android:layout_width="84dp" android:layout_width="@dimen/cover_player"
android:layout_height="84dp" android:layout_height="@dimen/cover_player"
android:layout_marginRight="8dp" android:layout_marginRight="10dp"
android:scaleType="centerCrop" android:scaleType="centerCrop"
android:background="@color/surface" /> android:background="@color/surface" />
@@ -43,7 +45,7 @@
android:id="@+id/title" android:id="@+id/title"
android:layout_width="fill_parent" android:layout_width="fill_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:textSize="14sp" android:textSize="@dimen/player_title"
android:textStyle="bold" android:textStyle="bold"
android:textColor="@color/text" android:textColor="@color/text"
android:maxLines="3" android:maxLines="3"
@@ -53,8 +55,8 @@
android:id="@+id/date" android:id="@+id/date"
android:layout_width="fill_parent" android:layout_width="fill_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginTop="2dp" android:layout_marginTop="3dp"
android:textSize="10sp" android:textSize="@dimen/player_meta"
android:textColor="@color/sub" android:textColor="@color/sub"
android:singleLine="true" /> android:singleLine="true" />
@@ -63,9 +65,10 @@
android:layout_width="fill_parent" android:layout_width="fill_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginTop="2dp" android:layout_marginTop="2dp"
android:textSize="10sp" android:textSize="@dimen/player_meta"
android:textColor="@color/fresh" android:textColor="@color/fresh"
android:maxLines="2" /> android:maxLines="2"
android:ellipsize="end" />
</LinearLayout> </LinearLayout>
</LinearLayout> </LinearLayout>
@@ -73,28 +76,29 @@
android:id="@+id/seek" android:id="@+id/seek"
android:layout_width="fill_parent" android:layout_width="fill_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:paddingLeft="14dp" android:paddingLeft="18dp"
android:paddingRight="14dp" android:paddingRight="18dp"
android:minHeight="4dp" android:paddingTop="6dp"
android:maxHeight="4dp" android:paddingBottom="6dp"
android:minHeight="@dimen/seek_track"
android:maxHeight="@dimen/seek_track"
android:progressDrawable="@drawable/seek_track" android:progressDrawable="@drawable/seek_track"
android:thumb="@drawable/seek_thumb" android:thumb="@drawable/seek_thumb"
android:thumbOffset="7dp" android:thumbOffset="13dp"
android:max="1000" /> android:max="1000" />
<RelativeLayout <RelativeLayout
android:layout_width="fill_parent" android:layout_width="fill_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:paddingLeft="10dp" android:paddingLeft="12dp"
android:paddingRight="10dp" android:paddingRight="12dp">
android:paddingTop="2dp">
<TextView <TextView
android:id="@+id/pos" android:id="@+id/pos"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_alignParentLeft="true" android:layout_alignParentLeft="true"
android:textSize="11sp" android:textSize="@dimen/player_time"
android:textColor="@color/sub" /> android:textColor="@color/sub" />
<TextView <TextView
@@ -102,7 +106,7 @@
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_alignParentRight="true" android:layout_alignParentRight="true"
android:textSize="11sp" android:textSize="@dimen/player_time"
android:textColor="@color/sub" /> android:textColor="@color/sub" />
</RelativeLayout> </RelativeLayout>
@@ -112,33 +116,33 @@
android:orientation="horizontal" android:orientation="horizontal"
android:gravity="center" android:gravity="center"
android:paddingTop="4dp" android:paddingTop="4dp"
android:paddingBottom="6dp"> android:paddingBottom="8dp">
<Button <Button
android:id="@+id/back" android:id="@+id/back"
android:layout_width="58dp" android:layout_width="@dimen/skip_w"
android:layout_height="38dp" android:layout_height="@dimen/skip_h"
android:background="@drawable/btn_flat" android:background="@drawable/btn_flat"
android:textColor="@color/text" android:textColor="@color/text"
android:textSize="13sp" android:textSize="@dimen/skip_text"
android:textStyle="bold" /> android:textStyle="bold" />
<ImageButton <ImageButton
android:id="@+id/play" android:id="@+id/play"
android:layout_width="56dp" android:layout_width="@dimen/play_w"
android:layout_height="56dp" android:layout_height="@dimen/play_h"
android:layout_marginLeft="16dp" android:layout_marginLeft="@dimen/play_gap"
android:layout_marginRight="16dp" android:layout_marginRight="@dimen/play_gap"
android:background="@drawable/btn_round" android:background="@drawable/btn_round"
android:src="@drawable/ic_play" /> android:src="@drawable/ic_play" />
<Button <Button
android:id="@+id/fwd" android:id="@+id/fwd"
android:layout_width="58dp" android:layout_width="@dimen/skip_w"
android:layout_height="38dp" android:layout_height="@dimen/skip_h"
android:background="@drawable/btn_flat" android:background="@drawable/btn_flat"
android:textColor="@color/text" android:textColor="@color/text"
android:textSize="13sp" android:textSize="@dimen/skip_text"
android:textStyle="bold" /> android:textStyle="bold" />
</LinearLayout> </LinearLayout>
@@ -154,9 +158,9 @@
android:id="@+id/desc" android:id="@+id/desc"
android:layout_width="fill_parent" android:layout_width="fill_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:padding="8dp" android:padding="10dp"
android:textSize="12sp" android:textSize="@dimen/player_desc"
android:textColor="@color/sub" android:textColor="@color/sub"
android:lineSpacingExtra="2dp" /> android:lineSpacingExtra="3dp" />
</ScrollView> </ScrollView>
</LinearLayout> </LinearLayout>
+7 -11
View File
@@ -9,14 +9,14 @@
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:orientation="horizontal" android:orientation="horizontal"
android:background="@color/header" android:background="@color/header"
android:padding="6dp" android:padding="8dp"
android:gravity="center_vertical"> android:gravity="center_vertical">
<ImageView <ImageView
android:id="@+id/cover" android:id="@+id/cover"
android:layout_width="44dp" android:layout_width="@dimen/cover_podcast"
android:layout_height="44dp" android:layout_height="@dimen/cover_podcast"
android:layout_marginRight="8dp" android:layout_marginRight="10dp"
android:scaleType="centerCrop" android:scaleType="centerCrop"
android:background="@color/surface" /> android:background="@color/surface" />
@@ -30,7 +30,7 @@
android:id="@+id/title" android:id="@+id/title"
android:layout_width="fill_parent" android:layout_width="fill_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:textSize="14sp" android:textSize="@dimen/podcast_title"
android:textStyle="bold" android:textStyle="bold"
android:textColor="@color/text" android:textColor="@color/text"
android:maxLines="2" android:maxLines="2"
@@ -38,12 +38,8 @@
<TextView <TextView
android:id="@+id/subtitle" android:id="@+id/subtitle"
android:layout_width="fill_parent" style="@style/RowMeta"
android:layout_height="wrap_content" android:layout_marginTop="2dp" />
android:textSize="10sp"
android:textColor="@color/sub"
android:singleLine="true"
android:ellipsize="end" />
</LinearLayout> </LinearLayout>
</LinearLayout> </LinearLayout>
+3 -2
View File
@@ -13,13 +13,14 @@
android:layout_gravity="center" android:layout_gravity="center"
android:gravity="center" android:gravity="center"
android:padding="20dp" android:padding="20dp"
android:textSize="13sp" android:textSize="@dimen/text_empty"
android:textColor="@color/sub" android:textColor="@color/sub"
android:lineSpacingExtra="2dp"
android:visibility="gone" /> android:visibility="gone" />
<ProgressBar <ProgressBar
android:id="@+id/loading" android:id="@+id/loading"
style="?android:attr/progressBarStyleSmall" style="?android:attr/progressBarStyle"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_gravity="center" android:layout_gravity="center"
+8 -7
View File
@@ -11,13 +11,13 @@
android:id="@+id/mini_progress" android:id="@+id/mini_progress"
style="?android:attr/progressBarStyleHorizontal" style="?android:attr/progressBarStyleHorizontal"
android:layout_width="fill_parent" android:layout_width="fill_parent"
android:layout_height="2dp" android:layout_height="@dimen/mini_progress"
android:progressDrawable="@drawable/thin_progress" android:progressDrawable="@drawable/thin_progress"
android:max="1000" /> android:max="1000" />
<LinearLayout <LinearLayout
android:layout_width="fill_parent" android:layout_width="fill_parent"
android:layout_height="44dp" android:layout_height="@dimen/mini_h"
android:orientation="horizontal" android:orientation="horizontal"
android:gravity="center_vertical"> android:gravity="center_vertical">
@@ -28,14 +28,14 @@
android:layout_weight="1" android:layout_weight="1"
android:orientation="vertical" android:orientation="vertical"
android:gravity="center_vertical" android:gravity="center_vertical"
android:paddingLeft="8dp" android:paddingLeft="10dp"
android:background="@drawable/row_sel"> android:background="@drawable/row_sel">
<TextView <TextView
android:id="@+id/mini_title" android:id="@+id/mini_title"
android:layout_width="fill_parent" android:layout_width="fill_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:textSize="13sp" android:textSize="@dimen/mini_title"
android:textColor="@color/text" android:textColor="@color/text"
android:singleLine="true" android:singleLine="true"
android:ellipsize="end" /> android:ellipsize="end" />
@@ -44,7 +44,8 @@
android:id="@+id/mini_sub" android:id="@+id/mini_sub"
android:layout_width="fill_parent" android:layout_width="fill_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:textSize="10sp" android:layout_marginTop="1dp"
android:textSize="@dimen/mini_sub"
android:textColor="@color/sub" android:textColor="@color/sub"
android:singleLine="true" android:singleLine="true"
android:ellipsize="end" /> android:ellipsize="end" />
@@ -52,8 +53,8 @@
<ImageButton <ImageButton
android:id="@+id/mini_play" android:id="@+id/mini_play"
android:layout_width="44dp" android:layout_width="@dimen/mini_btn"
android:layout_height="44dp" android:layout_height="@dimen/mini_btn"
android:background="@drawable/row_sel" android:background="@drawable/row_sel"
android:src="@drawable/ic_play_small" /> android:src="@drawable/ic_play_small" />
</LinearLayout> </LinearLayout>
+15 -29
View File
@@ -3,18 +3,18 @@
android:layout_width="fill_parent" android:layout_width="fill_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:orientation="horizontal" android:orientation="horizontal"
android:minHeight="48dp" android:minHeight="@dimen/row_min"
android:paddingLeft="6dp" android:paddingLeft="@dimen/row_pad"
android:paddingRight="6dp" android:paddingRight="@dimen/row_pad"
android:paddingTop="5dp" android:paddingTop="7dp"
android:paddingBottom="5dp"> android:paddingBottom="7dp">
<View <View
android:id="@+id/dot" android:id="@+id/dot"
android:layout_width="7dp" android:layout_width="@dimen/dot"
android:layout_height="7dp" android:layout_height="@dimen/dot"
android:layout_marginTop="5dp" android:layout_marginTop="6dp"
android:layout_marginRight="6dp" /> android:layout_marginRight="8dp" />
<LinearLayout <LinearLayout
android:layout_width="0dp" android:layout_width="0dp"
@@ -26,37 +26,23 @@
android:id="@+id/show" android:id="@+id/show"
android:layout_width="fill_parent" android:layout_width="fill_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:textSize="10sp" android:textSize="@dimen/text_show"
android:textColor="@color/accent" android:textColor="@color/accent"
android:singleLine="true" android:singleLine="true"
android:ellipsize="end" /> android:ellipsize="end" />
<TextView <TextView android:id="@+id/title" style="@style/RowTitle" />
android:id="@+id/title"
android:layout_width="fill_parent"
android:layout_height="wrap_content"
android:textSize="13sp"
android:textColor="@color/text"
android:maxLines="2"
android:ellipsize="end" />
<ProgressBar <ProgressBar
android:id="@+id/progress" android:id="@+id/progress"
style="?android:attr/progressBarStyleHorizontal" style="?android:attr/progressBarStyleHorizontal"
android:layout_width="fill_parent" android:layout_width="fill_parent"
android:layout_height="2dp" android:layout_height="@dimen/row_progress"
android:layout_marginTop="3dp" android:layout_marginTop="4dp"
android:layout_marginBottom="1dp" android:layout_marginBottom="2dp"
android:progressDrawable="@drawable/thin_progress" android:progressDrawable="@drawable/thin_progress"
android:max="1000" /> android:max="1000" />
<TextView <TextView android:id="@+id/meta" style="@style/RowMeta" android:layout_marginTop="1dp" />
android:id="@+id/meta"
android:layout_width="fill_parent"
android:layout_height="wrap_content"
android:textSize="10sp"
android:textColor="@color/sub"
android:singleLine="true"
android:ellipsize="end" />
</LinearLayout> </LinearLayout>
</LinearLayout> </LinearLayout>
+5 -4
View File
@@ -4,9 +4,10 @@
android:layout_width="fill_parent" android:layout_width="fill_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:background="@color/surface" android:background="@color/surface"
android:paddingLeft="8dp" android:paddingLeft="10dp"
android:paddingTop="4dp" android:paddingRight="10dp"
android:paddingBottom="4dp" android:paddingTop="6dp"
android:textSize="10sp" android:paddingBottom="6dp"
android:textSize="@dimen/text_section"
android:textStyle="bold" android:textStyle="bold"
android:textColor="@color/sub" /> android:textColor="@color/sub" />
+3 -2
View File
@@ -2,7 +2,8 @@
<TextView xmlns:android="http://schemas.android.com/apk/res/android" <TextView xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/title" android:id="@+id/title"
android:layout_width="fill_parent" android:layout_width="fill_parent"
android:layout_height="44dp" android:layout_height="@dimen/row_min"
android:gravity="center" android:gravity="center"
android:textSize="13sp" android:textSize="@dimen/text_more"
android:textStyle="bold"
android:textColor="@color/accent" /> android:textColor="@color/accent" />
+7 -21
View File
@@ -3,15 +3,15 @@
android:layout_width="fill_parent" android:layout_width="fill_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:orientation="horizontal" android:orientation="horizontal"
android:minHeight="48dp" android:minHeight="@dimen/row_min"
android:padding="5dp" android:padding="7dp"
android:gravity="center_vertical"> android:gravity="center_vertical">
<ImageView <ImageView
android:id="@+id/cover" android:id="@+id/cover"
android:layout_width="40dp" android:layout_width="@dimen/cover_row"
android:layout_height="40dp" android:layout_height="@dimen/cover_row"
android:layout_marginRight="8dp" android:layout_marginRight="10dp"
android:scaleType="centerCrop" android:scaleType="centerCrop"
android:background="@color/surface" /> android:background="@color/surface" />
@@ -21,22 +21,8 @@
android:layout_weight="1" android:layout_weight="1"
android:orientation="vertical"> android:orientation="vertical">
<TextView <TextView android:id="@+id/title" style="@style/RowTitle" />
android:id="@+id/title"
android:layout_width="fill_parent"
android:layout_height="wrap_content"
android:textSize="13sp"
android:textColor="@color/text"
android:maxLines="2"
android:ellipsize="end" />
<TextView <TextView android:id="@+id/meta" style="@style/RowMeta" android:layout_marginTop="2dp" />
android:id="@+id/meta"
android:layout_width="fill_parent"
android:layout_height="wrap_content"
android:textSize="10sp"
android:textColor="@color/sub"
android:singleLine="true"
android:ellipsize="end" />
</LinearLayout> </LinearLayout>
</LinearLayout> </LinearLayout>
+6 -6
View File
@@ -1,12 +1,12 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<string-array name="skip_labels"> <string-array name="skip_labels">
<item>5 seconds</item> <item>5 segundos</item>
<item>10 seconds</item> <item>10 segundos</item>
<item>15 seconds</item> <item>15 segundos</item>
<item>30 seconds</item> <item>30 segundos</item>
<item>45 seconds</item> <item>45 segundos</item>
<item>60 seconds</item> <item>60 segundos</item>
</string-array> </string-array>
<string-array name="skip_values"> <string-array name="skip_values">
<item>5</item> <item>5</item>
+63
View File
@@ -0,0 +1,63 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Every size in MiniABS lives here. Tuned for the Galaxy Mini (240x320, 120 dpi), where
1dp = 0.75 px: 16sp text is 12 px tall, and 48dp touch targets are 36 px (about 7 mm).
-->
<resources>
<!-- top bar and tabs -->
<dimen name="header_h">46dp</dimen>
<dimen name="header_text">19sp</dimen>
<dimen name="header_sub">14sp</dimen>
<dimen name="tab_h">46dp</dimen>
<dimen name="tab_text">16sp</dimen>
<!-- lists -->
<dimen name="row_min">66dp</dimen>
<dimen name="row_pad">9dp</dimen>
<dimen name="text_title">16sp</dimen>
<dimen name="text_meta">13sp</dimen>
<dimen name="text_show">13sp</dimen>
<dimen name="text_section">13sp</dimen>
<dimen name="text_more">17sp</dimen>
<dimen name="text_empty">16sp</dimen>
<dimen name="dot">11dp</dimen>
<dimen name="row_progress">4dp</dimen>
<dimen name="cover_row">54dp</dimen>
<!-- show header -->
<dimen name="cover_podcast">62dp</dimen>
<dimen name="podcast_title">17sp</dimen>
<!-- mini player bar -->
<dimen name="mini_h">60dp</dimen>
<dimen name="mini_title">15sp</dimen>
<dimen name="mini_sub">13sp</dimen>
<dimen name="mini_btn">60dp</dimen>
<dimen name="mini_progress">3dp</dimen>
<!-- player -->
<dimen name="cover_player">100dp</dimen>
<dimen name="player_title">17sp</dimen>
<dimen name="player_meta">13sp</dimen>
<dimen name="player_time">15sp</dimen>
<dimen name="player_desc">15sp</dimen>
<dimen name="skip_w">62dp</dimen>
<dimen name="skip_h">42dp</dimen>
<dimen name="skip_text">16sp</dimen>
<dimen name="play_w">58dp</dimen>
<dimen name="play_h">46dp</dimen>
<dimen name="play_gap">28dp</dimen>
<dimen name="seek_thumb">26dp</dimen>
<dimen name="seek_track">7dp</dimen>
<!-- login -->
<dimen name="login_title">24sp</dimen>
<dimen name="login_label">15sp</dimen>
<dimen name="login_field">18sp</dimen>
<dimen name="login_field_h">52dp</dimen>
<dimen name="login_check">16sp</dimen>
<dimen name="login_btn_h">56dp</dimen>
<dimen name="login_btn_text">20sp</dimen>
<dimen name="login_status">15sp</dimen>
<dimen name="login_tip">13sp</dimen>
</resources>
+30 -9
View File
@@ -7,8 +7,8 @@
<color name="header">#0B0D10</color> <color name="header">#0B0D10</color>
<color name="line">#2A3038</color> <color name="line">#2A3038</color>
<color name="text">#ECEFF2</color> <color name="text">#ECEFF2</color>
<color name="sub">#9AA4AE</color> <color name="sub">#A9B2BC</color>
<color name="dim">#5C6670</color> <color name="dim">#6E7882</color>
<color name="accent">#F5A623</color> <color name="accent">#F5A623</color>
<color name="fresh">#6EC1E4</color> <color name="fresh">#6EC1E4</color>
<color name="pressed">#2C333C</color> <color name="pressed">#2C333C</color>
@@ -22,11 +22,11 @@
<style name="Header"> <style name="Header">
<item name="android:layout_width">fill_parent</item> <item name="android:layout_width">fill_parent</item>
<item name="android:layout_height">32dp</item> <item name="android:layout_height">@dimen/header_h</item>
<item name="android:background">@color/header</item> <item name="android:background">@color/header</item>
<item name="android:gravity">center_vertical</item> <item name="android:gravity">center_vertical</item>
<item name="android:paddingLeft">8dp</item> <item name="android:paddingLeft">10dp</item>
<item name="android:paddingRight">2dp</item> <item name="android:paddingRight">0dp</item>
</style> </style>
<style name="Tab"> <style name="Tab">
@@ -34,9 +34,10 @@
<item name="android:layout_height">fill_parent</item> <item name="android:layout_height">fill_parent</item>
<item name="android:layout_weight">1</item> <item name="android:layout_weight">1</item>
<item name="android:gravity">center</item> <item name="android:gravity">center</item>
<item name="android:textSize">13sp</item> <item name="android:textSize">@dimen/tab_text</item>
<item name="android:textStyle">bold</item> <item name="android:textStyle">bold</item>
<item name="android:textColor">@color/sub</item> <item name="android:textColor">@color/sub</item>
<item name="android:singleLine">true</item>
<item name="android:background">@drawable/tab_bg</item> <item name="android:background">@drawable/tab_bg</item>
</style> </style>
@@ -53,7 +54,8 @@
<style name="Field"> <style name="Field">
<item name="android:layout_width">fill_parent</item> <item name="android:layout_width">fill_parent</item>
<item name="android:layout_height">wrap_content</item> <item name="android:layout_height">wrap_content</item>
<item name="android:textSize">14sp</item> <item name="android:minHeight">@dimen/login_field_h</item>
<item name="android:textSize">@dimen/login_field</item>
<item name="android:singleLine">true</item> <item name="android:singleLine">true</item>
<item name="android:textColor">#000000</item> <item name="android:textColor">#000000</item>
</style> </style>
@@ -61,8 +63,27 @@
<style name="Label"> <style name="Label">
<item name="android:layout_width">wrap_content</item> <item name="android:layout_width">wrap_content</item>
<item name="android:layout_height">wrap_content</item> <item name="android:layout_height">wrap_content</item>
<item name="android:textSize">11sp</item> <item name="android:textSize">@dimen/login_label</item>
<item name="android:textColor">@color/sub</item> <item name="android:textColor">@color/sub</item>
<item name="android:layout_marginTop">6dp</item> <item name="android:layout_marginTop">8dp</item>
<item name="android:layout_marginBottom">2dp</item>
</style>
<style name="RowTitle">
<item name="android:layout_width">fill_parent</item>
<item name="android:layout_height">wrap_content</item>
<item name="android:textSize">@dimen/text_title</item>
<item name="android:textColor">@color/text</item>
<item name="android:maxLines">2</item>
<item name="android:ellipsize">end</item>
</style>
<style name="RowMeta">
<item name="android:layout_width">fill_parent</item>
<item name="android:layout_height">wrap_content</item>
<item name="android:textSize">@dimen/text_meta</item>
<item name="android:textColor">@color/sub</item>
<item name="android:singleLine">true</item>
<item name="android:ellipsize">end</item>
</style> </style>
</resources> </resources>
+19 -16
View File
@@ -1,53 +1,56 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android"> <PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android">
<PreferenceCategory android:title="Playback"> <PreferenceCategory android:title="Reprodución">
<ListPreference <ListPreference
android:key="skip_back" android:key="skip_back"
android:title="Skip back" android:title="Retroceder"
android:entries="@array/skip_labels" android:entries="@array/skip_labels"
android:entryValues="@array/skip_values" android:entryValues="@array/skip_values"
android:defaultValue="15" /> android:defaultValue="15" />
<ListPreference <ListPreference
android:key="skip_fwd" android:key="skip_fwd"
android:title="Skip forward" android:title="Avanzar"
android:entries="@array/skip_labels" android:entries="@array/skip_labels"
android:entryValues="@array/skip_values" android:entryValues="@array/skip_values"
android:defaultValue="30" /> android:defaultValue="30" />
</PreferenceCategory> </PreferenceCategory>
<PreferenceCategory android:title="Downloads"> <PreferenceCategory android:title="Descargas">
<CheckBoxPreference <CheckBoxPreference
android:key="wifi_only" android:key="wifi_only"
android:title="Download on Wi-Fi only" android:title="Descargar só con wifi"
android:summary="Saves your mobile data" android:summary="Aforra datos móbiles"
android:defaultValue="true" /> android:defaultValue="true" />
<Preference <Preference
android:key="delete_all" android:key="delete_all"
android:title="Delete all downloads" /> android:title="Eliminar todas as descargas" />
</PreferenceCategory> </PreferenceCategory>
<PreferenceCategory android:title="Display"> <PreferenceCategory android:title="Aparencia">
<CheckBoxPreference <CheckBoxPreference
android:key="covers" android:key="covers"
android:title="Show cover art" android:title="Amosar portadas"
android:summary="Turn off to save data and memory" android:summary="Desactívao para aforrar datos e memoria"
android:defaultValue="true" /> android:defaultValue="true" />
</PreferenceCategory> </PreferenceCategory>
<PreferenceCategory android:title="Account"> <PreferenceCategory android:title="Conta">
<Preference <Preference
android:key="account" android:key="account"
android:title="Signed in" /> android:title="Sesión iniciada" />
<Preference
android:key="pins"
android:title="Certificados nos que confías" />
<Preference <Preference
android:key="logout" android:key="logout"
android:title="Log out" /> android:title="Pechar sesión" />
</PreferenceCategory> </PreferenceCategory>
<PreferenceCategory android:title="About"> <PreferenceCategory android:title="Acerca de">
<Preference <Preference
android:key="about" android:key="about"
android:title="MiniABS 1.0" android:title="MiniABS 1.4"
android:summary="A tiny Audiobookshelf podcast client for Android 2.2+" /> android:summary="Un pequeno cliente de podcasts de Audiobookshelf para Android 2.2+" />
</PreferenceCategory> </PreferenceCategory>
</PreferenceScreen> </PreferenceScreen>
+28 -69
View File
@@ -12,17 +12,10 @@ import java.net.ProtocolException;
import java.net.Socket; import java.net.Socket;
import java.net.URL; import java.net.URL;
import java.net.URLEncoder; import java.net.URLEncoder;
import java.security.cert.X509Certificate;
import javax.net.ssl.HostnameVerifier;
import javax.net.ssl.HttpsURLConnection; import javax.net.ssl.HttpsURLConnection;
import javax.net.ssl.SSLContext;
import javax.net.ssl.SSLException; import javax.net.ssl.SSLException;
import javax.net.ssl.SSLSession;
import javax.net.ssl.SSLSocket; import javax.net.ssl.SSLSocket;
import javax.net.ssl.SSLSocketFactory;
import javax.net.ssl.TrustManager;
import javax.net.ssl.X509TrustManager;
import org.json.JSONArray; import org.json.JSONArray;
import org.json.JSONException; import org.json.JSONException;
@@ -33,7 +26,7 @@ import android.os.Build;
/** Audiobookshelf REST client (API as of server 2.x, including the 2.26+ token system). */ /** Audiobookshelf REST client (API as of server 2.x, including the 2.26+ token system). */
final class Api { final class Api {
static final String VERSION = "1.0"; static final String VERSION = "1.4";
static class ApiException extends IOException { static class ApiException extends IOException {
final int code; final int code;
@@ -52,7 +45,6 @@ final class Api {
} }
private static final Object REFRESH_LOCK = new Object(); private static final Object REFRESH_LOCK = new Object();
private static SSLSocketFactory trustAllFactory;
static { static {
// Froyo's HttpURLConnection reuses dead pooled sockets; turn pooling off. // Froyo's HttpURLConnection reuses dead pooled sockets; turn pooling off.
@@ -70,7 +62,7 @@ final class Api {
conn.setReadTimeout(30000); conn.setReadTimeout(30000);
conn.setUseCaches(false); conn.setUseCaches(false);
conn.setInstanceFollowRedirects(true); conn.setInstanceFollowRedirects(true);
if (conn instanceof HttpsURLConnection && Prefs.get(c).trustAll()) trustAll((HttpsURLConnection) conn); if (conn instanceof HttpsURLConnection) Trust.apply(c, (HttpsURLConnection) conn);
setMethod(conn, method); setMethod(conn, method);
conn.setRequestProperty("User-Agent", "MiniABS/" + VERSION + " (Android " + Build.VERSION.RELEASE + ")"); conn.setRequestProperty("User-Agent", "MiniABS/" + VERSION + " (Android " + Build.VERSION.RELEASE + ")");
return conn; return conn;
@@ -80,38 +72,6 @@ final class Api {
conn.setRequestMethod(method); conn.setRequestMethod(method);
} }
private static synchronized SSLSocketFactory trustAllFactory() throws Exception {
if (trustAllFactory == null) {
TrustManager tm = new X509TrustManager() {
public void checkClientTrusted(X509Certificate[] chain, String type) {
}
public void checkServerTrusted(X509Certificate[] chain, String type) {
}
public X509Certificate[] getAcceptedIssuers() {
return new X509Certificate[0];
}
};
SSLContext ctx = SSLContext.getInstance("TLS");
ctx.init(null, new TrustManager[]{tm}, null);
trustAllFactory = ctx.getSocketFactory();
}
return trustAllFactory;
}
private static void trustAll(HttpsURLConnection conn) {
try {
conn.setSSLSocketFactory(trustAllFactory());
conn.setHostnameVerifier(new HostnameVerifier() {
public boolean verify(String host, SSLSession session) {
return true;
}
});
} catch (Exception ignored) {
}
}
/** /**
* PATCH over a plain socket. Android 2.x's HttpURLConnection only sends a body with POST or PUT, * PATCH over a plain socket. Android 2.x's HttpURLConnection only sends a body with POST or PUT,
* and Audiobookshelf uses PATCH for "mark as played" and for saving progress without a session. * and Audiobookshelf uses PATCH for "mark as played" and for saving progress without a session.
@@ -130,18 +90,17 @@ final class Api {
sock.setSoTimeout(30000); sock.setSoTimeout(30000);
Socket io = sock; Socket io = sock;
if (https) { if (https) {
boolean trust = p.trustAll(); SSLSocket ss = (SSLSocket) Trust.factory(c).createSocket(sock, u.getHost(), port, true);
SSLSocketFactory f; try { // send the host name (SNI) where the phone supports it (Android 2.3+)
try { ss.getClass().getMethod("setHostname", String.class).invoke(ss, u.getHost());
f = trust ? trustAllFactory() : (SSLSocketFactory) SSLSocketFactory.getDefault(); } catch (Exception ignored) {
} catch (Exception e) {
throw new SSLException("TLS unavailable");
} }
SSLSocket ss = (SSLSocket) f.createSocket(sock, u.getHost(), port, true);
ss.startHandshake(); ss.startHandshake();
if (!trust && !HttpsURLConnection.getDefaultHostnameVerifier().verify(u.getHost(), ss.getSession())) { java.security.cert.Certificate[] peer = ss.getSession().getPeerCertificates();
if (peer.length == 0 || !(peer[0] instanceof java.security.cert.X509Certificate)
|| !Trust.hostMatches((java.security.cert.X509Certificate) peer[0], u.getHost())) {
ss.close(); ss.close();
throw new SSLException("Certificate doesn't match " + u.getHost()); throw new SSLException("O certificado non corresponde a " + u.getHost());
} }
io = ss; io = ss;
} }
@@ -164,11 +123,11 @@ final class Api {
int ch; int ch;
while ((ch = in.read()) != -1 && ch != '\n' && line.length() < 200) line.append((char) ch); while ((ch = in.read()) != -1 && ch != '\n' && line.length() < 200) line.append((char) ch);
String[] parts = line.toString().trim().split(" "); String[] parts = line.toString().trim().split(" ");
if (parts.length < 2 || !parts[0].startsWith("HTTP/")) throw new IOException("Unexpected reply from server"); if (parts.length < 2 || !parts[0].startsWith("HTTP/")) throw new IOException("Resposta inesperada do servidor");
try { try {
code = Integer.parseInt(parts[1]); code = Integer.parseInt(parts[1]);
} catch (NumberFormatException e) { } catch (NumberFormatException e) {
throw new IOException("Unexpected reply from server"); throw new IOException("Resposta inesperada do servidor");
} }
byte[] rest = new byte[1024]; byte[] rest = new byte[1024];
int total = 0, n; int total = 0, n;
@@ -185,10 +144,10 @@ final class Api {
refreshIf(c, used, 0); refreshIf(c, used, 0);
return rawPatch(c, path, body, false); return rawPatch(c, path, body, false);
} }
throw new AuthException("Your sign-in has expired. Please log in again."); throw new AuthException("A túa sesión caducou. Inicia sesión de novo.");
} }
if (code >= 400) throw new ApiException(code, ""); if (code >= 400) throw new ApiException(code, "");
if (code >= 300) throw new ApiException(code, "The server redirected; check the address in Settings."); if (code >= 300) throw new ApiException(code, "O servidor redirixiu a petición; comproba o enderezo do servidor.");
return "OK"; return "OK";
} }
@@ -257,7 +216,7 @@ final class Api {
static Object call(Context c, String method, String path, JSONObject body, int maxString, String[] deepSkip, static Object call(Context c, String method, String path, JSONObject body, int maxString, String[] deepSkip,
boolean mayRefresh) throws IOException { boolean mayRefresh) throws IOException {
Prefs p = Prefs.get(c); Prefs p = Prefs.get(c);
if (!p.loggedIn()) throw new AuthException("Please log in."); if (!p.loggedIn()) throw new AuthException("Inicia sesión.");
if ("PATCH".equals(method)) return rawPatch(c, path, body, mayRefresh); if ("PATCH".equals(method)) return rawPatch(c, path, body, mayRefresh);
if (mayRefresh) ensureFresh(c, 120); if (mayRefresh) ensureFresh(c, 120);
String used = p.token(); String used = p.token();
@@ -273,7 +232,7 @@ final class Api {
refreshIf(c, used, 0); refreshIf(c, used, 0);
return call(c, method, path, body, maxString, deepSkip, false); return call(c, method, path, body, maxString, deepSkip, false);
} }
throw new AuthException("Your sign-in has expired. Please log in again."); throw new AuthException("A túa sesión caducou. Inicia sesión de novo.");
} }
if (code >= 400) throw new ApiException(code, errorText(conn)); if (code >= 400) throw new ApiException(code, errorText(conn));
return readBody(conn, maxString, deepSkip); return readBody(conn, maxString, deepSkip);
@@ -284,7 +243,7 @@ final class Api {
static JSONObject obj(Object o) throws IOException { static JSONObject obj(Object o) throws IOException {
if (o instanceof JSONObject) return (JSONObject) o; if (o instanceof JSONObject) return (JSONObject) o;
throw new IOException("Unexpected reply from server"); throw new IOException("Resposta inesperada do servidor");
} }
// ------------------------------------------------------------------ sign-in // ------------------------------------------------------------------ sign-in
@@ -294,13 +253,13 @@ final class Api {
HttpURLConnection conn = open(c, server + "/status", "GET"); HttpURLConnection conn = open(c, server + "/status", "GET");
try { try {
int code = responseCode(conn); int code = responseCode(conn);
if (code == 404) throw new IOException("No Audiobookshelf server at this address."); if (code == 404) throw new IOException("Non hai ningún servidor de Audiobookshelf neste enderezo.");
if (code >= 400) throw new ApiException(code, errorText(conn)); if (code >= 400) throw new ApiException(code, errorText(conn));
Object o = readBody(conn, 200, null); Object o = readBody(conn, 200, null);
if (!(o instanceof JSONObject)) throw new IOException("No Audiobookshelf server at this address."); if (!(o instanceof JSONObject)) throw new IOException("Non hai ningún servidor de Audiobookshelf neste enderezo.");
JSONObject s = (JSONObject) o; JSONObject s = (JSONObject) o;
if (s.has("isInit") && !s.optBoolean("isInit", true)) { if (s.has("isInit") && !s.optBoolean("isInit", true)) {
throw new IOException("This server hasn't been set up yet. Finish setup in a browser first."); throw new IOException("Este servidor aínda non está configurado. Remata a configuración nun navegador.");
} }
return Util.str(s, "serverVersion"); return Util.str(s, "serverVersion");
} finally { } finally {
@@ -321,19 +280,19 @@ final class Api {
} }
writeBody(conn, body); writeBody(conn, body);
int code = responseCode(conn); int code = responseCode(conn);
if (code == 401) throw new ApiException(401, "Wrong username or password."); if (code == 401) throw new ApiException(401, "Usuario ou contrasinal incorrectos.");
if (code == 429) throw new ApiException(429, "Too many attempts. Wait a minute and try again."); if (code == 429) throw new ApiException(429, "Demasiados intentos. Agarda un minuto e téntao de novo.");
if (code >= 400) throw new ApiException(code, errorText(conn)); if (code >= 400) throw new ApiException(code, errorText(conn));
JSONObject o = obj(readBody(conn, 500, null)); JSONObject o = obj(readBody(conn, 500, null));
JSONObject u = o.optJSONObject("user"); JSONObject u = o.optJSONObject("user");
if (u == null) throw new IOException("Unexpected reply from server"); if (u == null) throw new IOException("Resposta inesperada do servidor");
String access = Util.str(u, "accessToken"); String access = Util.str(u, "accessToken");
String refresh = Util.str(u, "refreshToken"); String refresh = Util.str(u, "refreshToken");
String legacy = Util.str(u, "token"); String legacy = Util.str(u, "token");
Prefs p = Prefs.get(c); Prefs p = Prefs.get(c);
if (access.length() > 0) p.setAuth(user, Prefs.MODE_JWT, access, refresh); if (access.length() > 0) p.setAuth(user, Prefs.MODE_JWT, access, refresh);
else if (legacy.length() > 0) p.setAuth(user, Prefs.MODE_LEGACY, legacy, ""); else if (legacy.length() > 0) p.setAuth(user, Prefs.MODE_LEGACY, legacy, "");
else throw new IOException("The server didn't return a sign-in token."); else throw new IOException("O servidor non devolveu ningún token de sesión.");
return Util.str(o, "userDefaultLibraryId"); return Util.str(o, "userDefaultLibraryId");
} finally { } finally {
conn.disconnect(); conn.disconnect();
@@ -351,7 +310,7 @@ final class Api {
return name; return name;
} catch (IOException e) { } catch (IOException e) {
p.logout(); p.logout();
if (e instanceof AuthException) throw new ApiException(401, "That API key wasn't accepted."); if (e instanceof AuthException) throw new ApiException(401, "Non se aceptou esa chave da API.");
throw e; throw e;
} }
} }
@@ -376,7 +335,7 @@ final class Api {
private static void refreshIf(Context c, String stale, long minSeconds) throws IOException { private static void refreshIf(Context c, String stale, long minSeconds) throws IOException {
synchronized (REFRESH_LOCK) { synchronized (REFRESH_LOCK) {
Prefs p = Prefs.get(c); Prefs p = Prefs.get(c);
if (!p.canRefresh()) throw new AuthException("Your sign-in has expired. Please log in again."); if (!p.canRefresh()) throw new AuthException("A túa sesión caducou. Inicia sesión de novo.");
if (stale != null) { if (stale != null) {
if (!stale.equals(p.token())) return; if (!stale.equals(p.token())) return;
} else if (p.tokenSecondsLeft() >= minSeconds) { } else if (p.tokenSecondsLeft() >= minSeconds) {
@@ -389,12 +348,12 @@ final class Api {
writeBody(conn, null); writeBody(conn, null);
int code = responseCode(conn); int code = responseCode(conn);
if (code == 401 || code == 403) { if (code == 401 || code == 403) {
throw new AuthException("Your sign-in has expired. Please log in again."); throw new AuthException("A túa sesión caducou. Inicia sesión de novo.");
} }
if (code >= 400) throw new ApiException(code, errorText(conn)); if (code >= 400) throw new ApiException(code, errorText(conn));
JSONObject u = obj(readBody(conn, 500, null)).optJSONObject("user"); JSONObject u = obj(readBody(conn, 500, null)).optJSONObject("user");
String access = u == null ? "" : Util.str(u, "accessToken"); String access = u == null ? "" : Util.str(u, "accessToken");
if (access.length() == 0) throw new AuthException("Your sign-in has expired. Please log in again."); if (access.length() == 0) throw new AuthException("A túa sesión caducou. Inicia sesión de novo.");
p.setTokens(access, Util.str(u, "refreshToken")); p.setTokens(access, Util.str(u, "refreshToken"));
} finally { } finally {
conn.disconnect(); conn.disconnect();
+28 -17
View File
@@ -64,9 +64,20 @@ abstract class BaseActivity extends Activity {
finish(); finish();
return; return;
} }
if (offerTrust(null)) return;
toast(Util.describe(e)); toast(Util.describe(e));
} }
/** If a request just failed on an unrecognised certificate, ask about it. retry runs after trusting. */
protected boolean offerTrust(final Runnable retry) {
return TrustPrompt.offer(this, new Runnable() {
public void run() {
if (retry != null) retry.run();
else toast("Certificado aceptado. Téntao de novo.");
}
});
}
// ------------------------------------------------------------ mini player // ------------------------------------------------------------ mini player
protected void setupMiniBar() { protected void setupMiniBar() {
@@ -119,13 +130,13 @@ abstract class BaseActivity extends Activity {
dur = ps.durationMs(); dur = ps.durationMs();
int st = ps.state(); int st = ps.state();
if (st == PlayerService.LOADING || st == PlayerService.ERROR) sub = ps.status(); if (st == PlayerService.LOADING || st == PlayerService.ERROR) sub = ps.status();
else sub = Util.join(e.show, dur > 0 ? Util.clock(dur - pos) + " left" : ""); else sub = Util.join(e.show, dur > 0 ? "quedan " + Util.clock(dur - pos) : "");
} else { } else {
double[] p = App.progress(e.id); double[] p = App.progress(e.id);
double cur = p != null ? p[0] : Math.max(0, App.localPosition(this, e.id)); double cur = p != null ? p[0] : Math.max(0, App.localPosition(this, e.id));
pos = (int) (cur * 1000); pos = (int) (cur * 1000);
dur = (int) (e.duration * 1000); dur = (int) (e.duration * 1000);
sub = Util.join(e.show, "Paused"); sub = Util.join(e.show, "En pausa");
} }
miniSub.setText(sub); miniSub.setText(sub);
miniProgress.setProgress(dur > 0 ? (int) (1000L * pos / dur) : 0); miniProgress.setProgress(dur > 0 ? (int) (1000L * pos / dur) : 0);
@@ -138,14 +149,14 @@ abstract class BaseActivity extends Activity {
protected void episodeMenu(ContextMenu menu, Episode e, boolean offerShow) { protected void episodeMenu(ContextMenu menu, Episode e, boolean offerShow) {
menu.setHeaderTitle(e.title); menu.setHeaderTitle(e.title);
menu.add(0, M_PLAY, 0, "Play"); menu.add(0, M_PLAY, 0, "Reproducir");
if (DownloadService.busyWith(e.id)) menu.add(0, M_CANCEL, 0, "Cancel download"); if (DownloadService.busyWith(e.id)) menu.add(0, M_CANCEL, 0, "Cancelar descarga");
else if (Downloads.has(this, e.id)) menu.add(0, M_DELETE, 0, "Delete download"); else if (Downloads.has(this, e.id)) menu.add(0, M_DELETE, 0, "Eliminar descarga");
else menu.add(0, M_DOWNLOAD, 0, "Download"); else menu.add(0, M_DOWNLOAD, 0, "Descargar");
double[] p = App.progress(e.id); double[] p = App.progress(e.id);
if (p != null && p[2] > 0) menu.add(0, M_UNPLAYED, 0, "Mark as unplayed"); if (p != null && p[2] > 0) menu.add(0, M_UNPLAYED, 0, "Marcar como non escoitado");
else menu.add(0, M_PLAYED, 0, "Mark as played"); else menu.add(0, M_PLAYED, 0, "Marcar como escoitado");
if (offerShow) menu.add(0, M_SHOW, 0, "Go to show"); if (offerShow) menu.add(0, M_SHOW, 0, "Ir ao programa");
} }
/** Returns true if handled. onChanged runs on the UI thread after the list should refresh. */ /** Returns true if handled. onChanged runs on the UI thread after the list should refresh. */
@@ -157,26 +168,26 @@ abstract class BaseActivity extends Activity {
case M_DOWNLOAD: case M_DOWNLOAD:
if (Prefs.get(this).wifiOnly() && !Util.onWifi(this)) { if (Prefs.get(this).wifiOnly() && !Util.onWifi(this)) {
new AlertDialog.Builder(this) new AlertDialog.Builder(this)
.setMessage("You're not on Wi-Fi. Download over mobile data this time?") .setMessage("Non estás nunha wifi. Descargar con datos móbiles esta vez?")
.setPositiveButton("Download", new DialogInterface.OnClickListener() { .setPositiveButton("Descargar", new DialogInterface.OnClickListener() {
public void onClick(DialogInterface d, int w) { public void onClick(DialogInterface d, int w) {
Prefs.get(BaseActivity.this).sp.edit().putBoolean("wifi_only", false).commit(); Prefs.get(BaseActivity.this).sp.edit().putBoolean("wifi_only", false).commit();
DownloadService.enqueue(BaseActivity.this, e); DownloadService.enqueue(BaseActivity.this, e);
toast("Downloading. Wi-Fi-only is now off (change it in Settings)."); toast("Descargando. Desactivouse «só con wifi» (podes cambialo en Axustes).");
if (onChanged != null) onChanged.run(); if (onChanged != null) onChanged.run();
} }
}) })
.setNegativeButton("Cancel", null) .setNegativeButton("Cancelar", null)
.show(); .show();
} else { } else {
DownloadService.enqueue(this, e); DownloadService.enqueue(this, e);
toast("Downloading…"); toast("Descargando…");
} }
if (onChanged != null) onChanged.run(); if (onChanged != null) onChanged.run();
return true; return true;
case M_CANCEL: case M_CANCEL:
DownloadService.cancel(e.id); DownloadService.cancel(e.id);
toast("Download cancelled"); toast("Descarga cancelada");
if (onChanged != null) onChanged.run(); if (onChanged != null) onChanged.run();
return true; return true;
case M_DELETE: case M_DELETE:
@@ -185,7 +196,7 @@ abstract class BaseActivity extends Activity {
ps.stop(); ps.stop();
} }
Downloads.delete(this, e); Downloads.delete(this, e);
toast("Download deleted"); toast("Descarga eliminada");
if (onChanged != null) onChanged.run(); if (onChanged != null) onChanged.run();
return true; return true;
case M_PLAYED: case M_PLAYED:
@@ -219,7 +230,7 @@ abstract class BaseActivity extends Activity {
} }
void done(Void r) { void done(Void r) {
toast(played ? "Marked as played" : "Marked as unplayed"); toast(played ? "Marcado como escoitado" : "Marcado como non escoitado");
} }
void failed(Exception ex) { void failed(Exception ex) {
+7 -7
View File
@@ -102,7 +102,7 @@ public class DownloadService extends IntentService {
} }
} catch (Exception ex) { } catch (Exception ex) {
if (!e.id.equals(cancelId)) { if (!e.id.equals(cancelId)) {
lastError = "Download failed: " + Util.describe(ex); lastError = "Fallou a descarga: " + Util.describe(ex);
notifyDone(e.title, lastError); notifyDone(e.title, lastError);
} }
} finally { } finally {
@@ -116,14 +116,14 @@ public class DownloadService extends IntentService {
private void download(Episode e) throws IOException { private void download(Episode e) throws IOException {
if (Prefs.get(this).wifiOnly() && !Util.onWifi(this)) { if (Prefs.get(this).wifiOnly() && !Util.onWifi(this)) {
throw new IOException("not on Wi-Fi (Settings: Download on Wi-Fi only)"); throw new IOException("non hai wifi (Axustes: Descargar só con wifi)");
} }
File dir = Downloads.dir(this); File dir = Downloads.dir(this);
if (Downloads.audio(this, e) != null) { if (Downloads.audio(this, e) != null) {
Downloads.saved(this, e); Downloads.saved(this, e);
return; return;
} }
if (e.ino.length() == 0) throw new IOException("this episode has no audio file"); if (e.ino.length() == 0) throw new IOException("este episodio non ten ficheiro de audio");
File part = new File(dir, e.id + ".part"); File part = new File(dir, e.id + ".part");
Notification n = progressNotification(e, 0); Notification n = progressNotification(e, 0);
startForeground(NOTIF_ID, n); startForeground(NOTIF_ID, n);
@@ -158,8 +158,8 @@ public class DownloadService extends IntentService {
if (code != 404 && code != 403) break; if (code != 404 && code != 403) break;
} }
if (code != 200 && code != 206) { if (code != 200 && code != 206) {
if (code == 401) throw new Api.AuthException("sign-in expired; log in again"); if (code == 401) throw new Api.AuthException("a sesión caducou; inicia sesión de novo");
if (code == 403) throw new IOException("your account isn't allowed to download"); if (code == 403) throw new IOException("a túa conta non ten permiso para descargar");
throw new Api.ApiException(code, ""); throw new Api.ApiException(code, "");
} }
try { try {
@@ -194,7 +194,7 @@ public class DownloadService extends IntentService {
} }
if (e.id.equals(cancelId)) return; if (e.id.equals(cancelId)) return;
File dest = new File(dir, e.id + e.ext); File dest = new File(dir, e.id + e.ext);
if (!part.renameTo(dest)) throw new IOException("couldn't save the file"); if (!part.renameTo(dest)) throw new IOException("non se puido gardar o ficheiro");
Downloads.saved(this, e); Downloads.saved(this, e);
percent = 100; percent = 100;
} }
@@ -216,7 +216,7 @@ public class DownloadService extends IntentService {
synchronized (queue) { synchronized (queue) {
left = queue.size(); left = queue.size();
} }
String text = "Downloading " + pct + "%" + (left > 0 ? " · " + left + " more queued" : ""); String text = "Descargando " + pct + " %" + (left > 0 ? " · " + left + " máis na cola" : "");
n.setLatestEventInfo(this, e.title, text, openApp()); n.setLatestEventInfo(this, e.title, text, openApp());
return n; return n;
} }
+3 -3
View File
@@ -28,11 +28,11 @@ final class Downloads {
static File dir(Context c) throws IOException { static File dir(Context c) throws IOException {
if (!Environment.MEDIA_MOUNTED.equals(Environment.getExternalStorageState())) { if (!Environment.MEDIA_MOUNTED.equals(Environment.getExternalStorageState())) {
throw new IOException("No SD card available. Insert or unmount it from USB to download."); throw new IOException("Non hai tarxeta SD dispoñible. Insírea ou desconéctaa do USB para descargar.");
} }
File d = c.getExternalFilesDir(Environment.DIRECTORY_PODCASTS); File d = c.getExternalFilesDir(Environment.DIRECTORY_PODCASTS);
if (d == null) throw new IOException("Can't use the SD card."); if (d == null) throw new IOException("Non se pode usar a tarxeta SD.");
if (!d.exists() && !d.mkdirs()) throw new IOException("Can't create a folder on the SD card."); if (!d.exists() && !d.mkdirs()) throw new IOException("Non se pode crear un cartafol na tarxeta SD.");
File nomedia = new File(d, ".nomedia"); File nomedia = new File(d, ".nomedia");
if (!nomedia.exists()) { if (!nomedia.exists()) {
try { try {
+35 -21
View File
@@ -178,6 +178,12 @@ public class HomeActivity extends BaseActivity {
empty.setVisibility(msg == null ? View.GONE : View.VISIBLE); empty.setVisibility(msg == null ? View.GONE : View.VISIBLE);
} }
private final Runnable retryRefresh = new Runnable() {
public void run() {
refresh();
}
};
private void refresh() { private void refresh() {
App.invalidate(); App.invalidate();
Covers.clearMemory(); Covers.clearMemory();
@@ -196,7 +202,7 @@ public class HomeActivity extends BaseActivity {
} }
void done(Integer n) { void done(Integer n) {
if (n > 0) toast("Synced offline progress"); if (n > 0) toast("Sincronizouse o progreso gardado sen conexión");
} }
}.start(); }.start();
} }
@@ -216,7 +222,7 @@ public class HomeActivity extends BaseActivity {
found.add(new String[]{Util.str(l, "id"), Util.str(l, "name")}); found.add(new String[]{Util.str(l, "id"), Util.str(l, "name")});
} }
} }
if (found.isEmpty()) throw new IOException("This server has no podcast libraries."); if (found.isEmpty()) throw new IOException("Este servidor non ten bibliotecas de podcasts.");
podcastLibs = found; podcastLibs = found;
} }
String id = p.libraryId(); String id = p.libraryId();
@@ -267,10 +273,10 @@ public class HomeActivity extends BaseActivity {
// older servers: just skip this section // older servers: just skip this section
} }
if (!cont.isEmpty()) { if (!cont.isEmpty()) {
rows.add(Rows.Row.header("CONTINUE LISTENING")); rows.add(Rows.Row.header("CONTINUAR ESCOITANDO"));
rows.addAll(cont); rows.addAll(cont);
} }
rows.add(Rows.Row.header("LATEST EPISODES")); rows.add(Rows.Row.header("ÚLTIMOS EPISODIOS"));
} }
JSONArray eps = Api.recentEpisodes(HomeActivity.this, lib, page, PAGE).optJSONArray("episodes"); JSONArray eps = Api.recentEpisodes(HomeActivity.this, lib, page, PAGE).optJSONArray("episodes");
int n = eps == null ? 0 : eps.length(); int n = eps == null ? 0 : eps.length();
@@ -295,8 +301,8 @@ public class HomeActivity extends BaseActivity {
} }
latestPage = page; latestPage = page;
latestMore = hasMore; latestMore = hasMore;
if (hasMore) latestCache.add(Rows.Row.more("Load more")); if (hasMore) latestCache.add(Rows.Row.more("Cargar máis"));
if (tab == TAB_LATEST) show(latestCache, "No episodes yet."); if (tab == TAB_LATEST) show(latestCache, "Aínda non hai episodios.");
} }
void failed(Exception e) { void failed(Exception e) {
@@ -309,12 +315,16 @@ public class HomeActivity extends BaseActivity {
if (more) { if (more) {
toast(Util.describe(e)); toast(Util.describe(e));
if (latestCache != null && !latestCache.isEmpty()) { if (latestCache != null && !latestCache.isEmpty()) {
latestCache.get(latestCache.size() - 1).text = "Load more"; latestCache.get(latestCache.size() - 1).text = "Cargar máis";
adapter.notifyDataSetChanged(); adapter.notifyDataSetChanged();
} }
return; return;
} }
latestError = Util.describe(e) + "\n\nTap refresh to try again."; latestError = Util.describe(e) + "\n\nToca o botón de actualizar (arriba á dereita) para tentalo de novo.";
if (offerTrust(retryRefresh)) {
if (tab == TAB_LATEST) show(null, null);
return;
}
if (tab == TAB_LATEST) offlineFallback(e); if (tab == TAB_LATEST) offlineFallback(e);
} }
}.start(); }.start();
@@ -358,7 +368,7 @@ public class HomeActivity extends BaseActivity {
if (isFinishing()) return; if (isFinishing()) return;
updateLibraryLabel(); updateLibraryLabel();
showsCache = rows; showsCache = rows;
if (tab == TAB_SHOWS) show(rows, "No podcasts in this library."); if (tab == TAB_SHOWS) show(rows, "Non hai podcasts nesta biblioteca.");
} }
void failed(Exception e) { void failed(Exception e) {
@@ -368,7 +378,11 @@ public class HomeActivity extends BaseActivity {
showError(e); showError(e);
return; return;
} }
showsError = Util.describe(e) + "\n\nTap refresh to try again."; showsError = Util.describe(e) + "\n\nToca o botón de actualizar (arriba á dereita) para tentalo de novo.";
if (offerTrust(retryRefresh)) {
if (tab == TAB_SHOWS) show(null, null);
return;
}
if (tab == TAB_SHOWS) offlineFallback(e); if (tab == TAB_SHOWS) offlineFallback(e);
} }
}.start(); }.start();
@@ -377,7 +391,7 @@ public class HomeActivity extends BaseActivity {
/** When the server can't be reached but episodes are saved, show those instead. */ /** When the server can't be reached but episodes are saved, show those instead. */
private void offlineFallback(Exception e) { private void offlineFallback(Exception e) {
if (!(e instanceof Api.ApiException) && !Downloads.list(this).isEmpty()) { if (!(e instanceof Api.ApiException) && !Downloads.list(this).isEmpty()) {
toast("Can't reach the server. Showing saved episodes."); toast("Non se pode acceder ao servidor. Amósanse os episodios gardados.");
selectTab(TAB_SAVED); selectTab(TAB_SAVED);
} else { } else {
show(null, null); show(null, null);
@@ -391,17 +405,17 @@ public class HomeActivity extends BaseActivity {
savedSignature = (cur == null ? "" : cur.id) + "/" + queued.size() + "/" + DownloadService.lastError; savedSignature = (cur == null ? "" : cur.id) + "/" + queued.size() + "/" + DownloadService.lastError;
if (DownloadService.lastError != null) rows.add(Rows.Row.header(DownloadService.lastError)); if (DownloadService.lastError != null) rows.add(Rows.Row.header(DownloadService.lastError));
if (cur != null || !queued.isEmpty()) { if (cur != null || !queued.isEmpty()) {
rows.add(Rows.Row.header("DOWNLOADING")); rows.add(Rows.Row.header("DESCARGANDO"));
if (cur != null) rows.add(Rows.Row.episode(cur)); if (cur != null) rows.add(Rows.Row.episode(cur));
for (Episode q : queued) rows.add(Rows.Row.episode(q)); for (Episode q : queued) rows.add(Rows.Row.episode(q));
} }
List<Episode> saved = Downloads.list(this); List<Episode> saved = Downloads.list(this);
if (!saved.isEmpty()) { if (!saved.isEmpty()) {
rows.add(Rows.Row.header("SAVED ON SD CARD (" + saved.size() + ")")); rows.add(Rows.Row.header("GARDADOS NA TARXETA SD (" + saved.size() + ")"));
for (Episode e : saved) rows.add(Rows.Row.episode(e)); for (Episode e : saved) rows.add(Rows.Row.episode(e));
} }
if (rows.isEmpty()) { if (rows.isEmpty()) {
show(rows, "Nothing saved yet.\n\nLong-press an episode and choose Download to listen offline."); show(rows, "Aínda non gardaches nada.\n\nMantén premido un episodio e escolle Descargar para escoitalo sen conexión.");
} else { } else {
show(rows, null); show(rows, null);
} }
@@ -422,7 +436,7 @@ public class HomeActivity extends BaseActivity {
break; break;
case Rows.MORE: case Rows.MORE:
if (!loadingLatest && latestMore) { if (!loadingLatest && latestMore) {
r.text = "Loading…"; r.text = "Cargando…";
adapter.notifyDataSetChanged(); adapter.notifyDataSetChanged();
loadLatest(true); loadLatest(true);
} }
@@ -455,12 +469,12 @@ public class HomeActivity extends BaseActivity {
private void chooseLibrary() { private void chooseLibrary() {
if (podcastLibs == null || podcastLibs.size() < 2) { if (podcastLibs == null || podcastLibs.size() < 2) {
toast(podcastLibs == null ? "Still loading libraries…" : "This is your only podcast library."); toast(podcastLibs == null ? "Aínda se están cargando as bibliotecas…" : "Esta é a túa única biblioteca de podcasts.");
return; return;
} }
String[] names = new String[podcastLibs.size()]; String[] names = new String[podcastLibs.size()];
for (int i = 0; i < names.length; i++) names[i] = podcastLibs.get(i)[1]; for (int i = 0; i < names.length; i++) names[i] = podcastLibs.get(i)[1];
new AlertDialog.Builder(this).setTitle("Podcast library") new AlertDialog.Builder(this).setTitle("Biblioteca de podcasts")
.setItems(names, new DialogInterface.OnClickListener() { .setItems(names, new DialogInterface.OnClickListener() {
public void onClick(DialogInterface d, int which) { public void onClick(DialogInterface d, int which) {
String[] l = podcastLibs.get(which); String[] l = podcastLibs.get(which);
@@ -477,10 +491,10 @@ public class HomeActivity extends BaseActivity {
@Override @Override
public boolean onCreateOptionsMenu(Menu menu) { public boolean onCreateOptionsMenu(Menu menu) {
menu.add(0, 1, 0, "Refresh").setIcon(android.R.drawable.ic_menu_rotate); menu.add(0, 1, 0, "Actualizar").setIcon(android.R.drawable.ic_menu_rotate);
menu.add(0, 2, 0, "Library").setIcon(android.R.drawable.ic_menu_agenda); menu.add(0, 2, 0, "Biblioteca").setIcon(android.R.drawable.ic_menu_agenda);
menu.add(0, 3, 0, "Now playing").setIcon(android.R.drawable.ic_media_play); menu.add(0, 3, 0, "En reprodución").setIcon(android.R.drawable.ic_media_play);
menu.add(0, 4, 0, "Settings").setIcon(android.R.drawable.ic_menu_preferences); menu.add(0, 4, 0, "Axustes").setIcon(android.R.drawable.ic_menu_preferences);
return true; return true;
} }
+10 -10
View File
@@ -88,13 +88,13 @@ final class Json {
return JSONObject.NULL; return JSONObject.NULL;
default: default:
if (c == '-' || (c >= '0' && c <= '9')) return number(c); if (c == '-' || (c >= '0' && c <= '9')) return number(c);
throw new IOException("Unexpected reply from server"); throw new IOException("Resposta inesperada do servidor");
} }
} }
private void expect(String rest) throws IOException { private void expect(String rest) throws IOException {
for (int i = 0; i < rest.length(); i++) { for (int i = 0; i < rest.length(); i++) {
if (read() != rest.charAt(i)) throw new IOException("Unexpected reply from server"); if (read() != rest.charAt(i)) throw new IOException("Resposta inesperada do servidor");
} }
} }
@@ -149,10 +149,10 @@ final class Json {
return o; return o;
} }
while (true) { while (true) {
if (c != '"') throw new IOException("Unexpected reply from server"); if (c != '"') throw new IOException("Resposta inesperada do servidor");
String key = string(512); String key = string(512);
c = skipWs(); c = skipWs();
if (c != ':') throw new IOException("Unexpected reply from server"); if (c != ':') throw new IOException("Resposta inesperada do servidor");
c = skipWs(); c = skipWs();
if (skipKey(key)) { if (skipKey(key)) {
skipValue(c); skipValue(c);
@@ -173,7 +173,7 @@ final class Json {
depth--; depth--;
return o; return o;
} }
throw new IOException("Unexpected reply from server"); throw new IOException("Resposta inesperada do servidor");
} }
} }
@@ -189,7 +189,7 @@ final class Json {
continue; continue;
} }
if (c == ']') return a; if (c == ']') return a;
throw new IOException("Unexpected reply from server"); throw new IOException("Resposta inesperada do servidor");
} }
} }
@@ -202,7 +202,7 @@ final class Json {
int d = 1; int d = 1;
while (d > 0) { while (d > 0) {
int ch = read(); int ch = read();
if (ch == -1) throw new IOException("Reply was cut off"); if (ch == -1) throw new IOException("A resposta chegou incompleta");
if (ch == '"') string(0); if (ch == '"') string(0);
else if (ch == '{' || ch == '[') d++; else if (ch == '{' || ch == '[') d++;
else if (ch == '}' || ch == ']') d--; else if (ch == '}' || ch == ']') d--;
@@ -217,7 +217,7 @@ final class Json {
boolean cut = false; boolean cut = false;
while (true) { while (true) {
int c = read(); int c = read();
if (c == -1) throw new IOException("Reply was cut off"); if (c == -1) throw new IOException("A resposta chegou incompleta");
if (c == '"') break; if (c == '"') break;
if (c == '\\') { if (c == '\\') {
c = read(); c = read();
@@ -231,14 +231,14 @@ final class Json {
int v = 0; int v = 0;
for (int i = 0; i < 4; i++) { for (int i = 0; i < 4; i++) {
int d = Character.digit(read(), 16); int d = Character.digit(read(), 16);
if (d < 0) throw new IOException("Unexpected reply from server"); if (d < 0) throw new IOException("Resposta inesperada do servidor");
v = v * 16 + d; v = v * 16 + d;
} }
c = v; c = v;
break; break;
} }
case -1: case -1:
throw new IOException("Reply was cut off"); throw new IOException("A resposta chegou incompleta");
default: default:
break; // \" \\ \/ break; // \" \\ \/
} }
+12 -8
View File
@@ -14,7 +14,7 @@ import android.widget.TextView;
public class LoginActivity extends Activity { public class LoginActivity extends Activity {
private EditText server, user, pass; private EditText server, user, pass;
private CheckBox useKey, trust; private CheckBox useKey;
private TextView status, userLabel, passLabel; private TextView status, userLabel, passLabel;
private Button connect; private Button connect;
@@ -26,7 +26,6 @@ public class LoginActivity extends Activity {
user = (EditText) findViewById(R.id.user); user = (EditText) findViewById(R.id.user);
pass = (EditText) findViewById(R.id.pass); pass = (EditText) findViewById(R.id.pass);
useKey = (CheckBox) findViewById(R.id.use_key); useKey = (CheckBox) findViewById(R.id.use_key);
trust = (CheckBox) findViewById(R.id.trust);
status = (TextView) findViewById(R.id.status); status = (TextView) findViewById(R.id.status);
userLabel = (TextView) findViewById(R.id.user_label); userLabel = (TextView) findViewById(R.id.user_label);
passLabel = (TextView) findViewById(R.id.pass_label); passLabel = (TextView) findViewById(R.id.pass_label);
@@ -35,7 +34,6 @@ public class LoginActivity extends Activity {
Prefs p = Prefs.get(this); Prefs p = Prefs.get(this);
server.setText(p.server()); server.setText(p.server());
user.setText(p.username()); user.setText(p.username());
trust.setChecked(p.trustAll());
String msg = getIntent().getStringExtra("message"); String msg = getIntent().getStringExtra("message");
if (msg != null) status.setText(msg); if (msg != null) status.setText(msg);
@@ -44,7 +42,7 @@ public class LoginActivity extends Activity {
int vis = on ? View.GONE : View.VISIBLE; int vis = on ? View.GONE : View.VISIBLE;
user.setVisibility(vis); user.setVisibility(vis);
userLabel.setVisibility(vis); userLabel.setVisibility(vis);
passLabel.setText(on ? "API key" : "Password"); passLabel.setText(on ? "Chave da API" : "Contrasinal");
pass.setInputType(on ? InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS pass.setInputType(on ? InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS
: InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PASSWORD); : InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PASSWORD);
pass.setText(""); pass.setText("");
@@ -71,23 +69,24 @@ public class LoginActivity extends Activity {
final String pw = pass.getText().toString(); final String pw = pass.getText().toString();
final boolean key = useKey.isChecked(); final boolean key = useKey.isChecked();
if (srv.length() == 0) { if (srv.length() == 0) {
status.setText("Enter your server address."); status.setText("Escribe o enderezo do servidor.");
return; return;
} }
if (key ? pw.trim().length() == 0 : u.length() == 0) { if (key ? pw.trim().length() == 0 : u.length() == 0) {
status.setText(key ? "Paste your API key." : "Enter your username."); status.setText(key ? "Pega a túa chave da API." : "Escribe o teu usuario.");
return; return;
} }
server.setText(srv); server.setText(srv);
((InputMethodManager) getSystemService(INPUT_METHOD_SERVICE)).hideSoftInputFromWindow(pass.getWindowToken(), 0); ((InputMethodManager) getSystemService(INPUT_METHOD_SERVICE)).hideSoftInputFromWindow(pass.getWindowToken(), 0);
connect.setEnabled(false); connect.setEnabled(false);
status.setText("Connecting…"); status.setText("Conectando…");
final Prefs p = Prefs.get(this); final Prefs p = Prefs.get(this);
p.logout(); p.logout();
App.clear(); App.clear();
HomeActivity.clearCache(); HomeActivity.clearCache();
p.setServer(srv, trust.isChecked()); p.setServer(srv);
Trust.lastRejected = null;
new Job<String>() { new Job<String>() {
String work() throws Exception { String work() throws Exception {
String version = Api.status(LoginActivity.this, srv); String version = Api.status(LoginActivity.this, srv);
@@ -111,6 +110,11 @@ public class LoginActivity extends Activity {
void failed(Exception e) { void failed(Exception e) {
connect.setEnabled(true); connect.setEnabled(true);
status.setText(Util.describe(e)); status.setText(Util.describe(e));
TrustPrompt.offer(LoginActivity.this, new Runnable() {
public void run() {
go();
}
});
} }
}.start(); }.start();
} }
+18 -18
View File
@@ -167,9 +167,9 @@ public class PlayerActivity extends BaseActivity {
Episode e = current(); Episode e = current();
if (e == null) { if (e == null) {
show.setText(""); show.setText("");
title.setText("Nothing playing"); title.setText("Non se está a reproducir nada");
date.setText(""); date.setText("");
status.setText("Pick an episode from the list."); status.setText("Escolle un episodio da lista.");
desc.setText(""); desc.setText("");
cover.setVisibility(View.GONE); cover.setVisibility(View.GONE);
setControls(false); setControls(false);
@@ -181,7 +181,7 @@ public class PlayerActivity extends BaseActivity {
title.setText(e.title); title.setText(e.title);
date.setText(Util.join(Util.date(e.published), e.duration > 0 ? Util.duration(e.duration) : "")); date.setText(Util.join(Util.date(e.published), e.duration > 0 ? Util.duration(e.duration) : ""));
desc.setText(Util.plainText(e.desc)); desc.setText(Util.plainText(e.desc));
Covers.load(this, cover, e.itemId, (int) (84 * getResources().getDisplayMetrics().density)); Covers.load(this, cover, e.itemId, getResources().getDimensionPixelSize(R.dimen.cover_player));
} }
PlayerService ps = PlayerService.get(); PlayerService ps = PlayerService.get();
@@ -195,8 +195,8 @@ public class PlayerActivity extends BaseActivity {
playing = ps.isPlaying() || (ps.state() == PlayerService.LOADING); playing = ps.isPlaying() || (ps.state() == PlayerService.LOADING);
st = ps.status(); st = ps.status();
long sleep = ps.sleepLeftMs(); long sleep = ps.sleepLeftMs();
if (sleep == -1) st = Util.join(st, "Sleep at end"); if (sleep == -1) st = Util.join(st, "Pausa ao final do episodio");
else if (sleep > 0) st = Util.join(st, "Sleep in " + Util.clock(sleep)); else if (sleep > 0) st = Util.join(st, "Pausa en " + Util.clock(sleep));
seek.setSecondaryProgress(ps.bufferedPercent() * 10); seek.setSecondaryProgress(ps.bufferedPercent() * 10);
setControls(true); setControls(true);
} else { } else {
@@ -204,7 +204,7 @@ public class PlayerActivity extends BaseActivity {
double cur = pr != null ? pr[0] : Math.max(0, App.localPosition(this, e.id)); double cur = pr != null ? pr[0] : Math.max(0, App.localPosition(this, e.id));
p = (int) (cur * 1000); p = (int) (cur * 1000);
dur = (int) (e.duration * 1000); dur = (int) (e.duration * 1000);
st = pending != null ? "Connecting…" : (Downloads.has(this, e.id) ? "Saved on SD card" : ""); st = pending != null ? "Conectando…" : (Downloads.has(this, e.id) ? "Gardado na tarxeta SD" : "");
playing = pending != null; playing = pending != null;
seek.setSecondaryProgress(0); seek.setSecondaryProgress(0);
setControls(true); setControls(true);
@@ -234,17 +234,17 @@ public class PlayerActivity extends BaseActivity {
if (e == null) return false; if (e == null) return false;
PlayerService ps = PlayerService.get(); PlayerService ps = PlayerService.get();
boolean active = ps != null && ps.episode() != null && ps.episode().id.equals(e.id); boolean active = ps != null && ps.episode() != null && ps.episode().id.equals(e.id);
if (active) menu.add(0, 1, 0, "Sleep timer").setIcon(android.R.drawable.ic_lock_idle_alarm); if (active) menu.add(0, 1, 0, "Temporizador de pausa").setIcon(android.R.drawable.ic_lock_idle_alarm);
menu.add(0, 2, 0, "Mark as played").setIcon(android.R.drawable.ic_menu_agenda); menu.add(0, 2, 0, "Marcar como escoitado").setIcon(android.R.drawable.ic_menu_agenda);
if (DownloadService.busyWith(e.id)) { if (DownloadService.busyWith(e.id)) {
menu.add(0, 3, 0, "Cancel download").setIcon(android.R.drawable.ic_menu_close_clear_cancel); menu.add(0, 3, 0, "Cancelar descarga").setIcon(android.R.drawable.ic_menu_close_clear_cancel);
} else if (Downloads.has(this, e.id)) { } else if (Downloads.has(this, e.id)) {
menu.add(0, 3, 0, "Delete download").setIcon(android.R.drawable.ic_menu_delete); menu.add(0, 3, 0, "Eliminar descarga").setIcon(android.R.drawable.ic_menu_delete);
} else { } else {
menu.add(0, 3, 0, "Download").setIcon(android.R.drawable.ic_menu_save); menu.add(0, 3, 0, "Descargar").setIcon(android.R.drawable.ic_menu_save);
} }
menu.add(0, 4, 0, "Go to show").setIcon(android.R.drawable.ic_menu_view); menu.add(0, 4, 0, "Ir ao programa").setIcon(android.R.drawable.ic_menu_view);
if (active) menu.add(0, 5, 0, "Stop").setIcon(android.R.drawable.ic_menu_close_clear_cancel); if (active) menu.add(0, 5, 0, "Deter").setIcon(android.R.drawable.ic_menu_close_clear_cancel);
return true; return true;
} }
@@ -261,7 +261,7 @@ public class PlayerActivity extends BaseActivity {
case 2: case 2:
if (active) { if (active) {
ps.finishAndStop(); ps.finishAndStop();
toast("Marked as played"); toast("Marcado como escoitado");
} else { } else {
episodeAction(M_PLAYED, e, null); episodeAction(M_PLAYED, e, null);
} }
@@ -285,15 +285,15 @@ public class PlayerActivity extends BaseActivity {
private void sleepDialog() { private void sleepDialog() {
final int[] minutes = {0, 5, 10, 15, 30, 45, 60, -1}; final int[] minutes = {0, 5, 10, 15, 30, 45, 60, -1};
String[] labels = {"Off", "5 minutes", "10 minutes", "15 minutes", "30 minutes", "45 minutes", "1 hour", String[] labels = {"Desactivado", "5 minutos", "10 minutos", "15 minutos", "30 minutos", "45 minutos", "1 hora",
"End of episode"}; "Ao final do episodio"};
new AlertDialog.Builder(this).setTitle("Sleep timer") new AlertDialog.Builder(this).setTitle("Temporizador de pausa")
.setItems(labels, new DialogInterface.OnClickListener() { .setItems(labels, new DialogInterface.OnClickListener() {
public void onClick(DialogInterface d, int which) { public void onClick(DialogInterface d, int which) {
PlayerService ps = PlayerService.get(); PlayerService ps = PlayerService.get();
if (ps == null) return; if (ps == null) return;
ps.setSleep(minutes[which]); ps.setSleep(minutes[which]);
toast(minutes[which] == 0 ? "Sleep timer off" : "Sleep timer set"); toast(minutes[which] == 0 ? "Temporizador desactivado" : "Temporizador activado");
refresh(); refresh();
} }
}).show(); }).show();
+35 -24
View File
@@ -129,6 +129,7 @@ public class PlayerService extends Service implements MediaPlayer.OnPreparedList
if (ep != null && prepared && state != ENDED) syncNow(true); if (ep != null && prepared && state != ENDED) syncNow(true);
releasePlayer(); releasePlayer();
net.shutdown(); net.shutdown();
StreamProxy.shutdown();
try { try {
unregisterReceiver(noisy); unregisterReceiver(noisy);
} catch (Exception ignored) { } catch (Exception ignored) {
@@ -255,7 +256,7 @@ public class PlayerService extends Service implements MediaPlayer.OnPreparedList
} }
lastPosMs = positionMs(); lastPosMs = positionMs();
state = PAUSED; state = PAUSED;
status = "Paused"; status = "En pausa";
pausedAt = SystemClock.elapsedRealtime(); pausedAt = SystemClock.elapsedRealtime();
syncNow(false); syncNow(false);
wifi.release(); wifi.release();
@@ -344,7 +345,7 @@ public class PlayerService extends Service implements MediaPlayer.OnPreparedList
markFinished(); markFinished();
releasePlayer(); releasePlayer();
state = ENDED; state = ENDED;
status = "Marked as played"; status = "Marcado como escoitado";
lastPosMs = dur; lastPosMs = dur;
pausedAt = SystemClock.elapsedRealtime(); pausedAt = SystemClock.elapsedRealtime();
wifi.release(); wifi.release();
@@ -363,7 +364,7 @@ public class PlayerService extends Service implements MediaPlayer.OnPreparedList
private void open(final Episode e, final double startSec, boolean playNow) { private void open(final Episode e, final double startSec, boolean playNow) {
releasePlayer(); releasePlayer();
state = LOADING; state = LOADING;
status = "Connecting…"; status = "Conectando…";
playOnPrepared = playNow; playOnPrepared = playNow;
showNotification(true); showNotification(true);
final int gen = ++generation; final int gen = ++generation;
@@ -380,6 +381,12 @@ public class PlayerService extends Service implements MediaPlayer.OnPreparedList
}); });
} }
/** Direct URL for http:// servers; a local proxy URL for https:// ones (see StreamProxy). */
private static String streamUrl(Context c, String contentUrl) throws java.io.IOException {
if (StreamProxy.needed(c)) return StreamProxy.get(c).register(contentUrl);
return Api.withToken(c, Api.absolute(c, contentUrl));
}
private Source findSource(Context c, Episode e, double startSec) { private Source findSource(Context c, Episode e, double startSec) {
Source s = new Source(); Source s = new Source();
File f = Downloads.audio(c, e); File f = Downloads.audio(c, e);
@@ -400,12 +407,12 @@ public class PlayerService extends Service implements MediaPlayer.OnPreparedList
JSONObject t = tracks == null ? null : tracks.optJSONObject(0); JSONObject t = tracks == null ? null : tracks.optJSONObject(0);
String cu = t == null ? "" : Util.str(t, "contentUrl"); String cu = t == null ? "" : Util.str(t, "contentUrl");
if (cu.length() == 0) { if (cu.length() == 0) {
s.error = "The server sent no audio for this episode."; s.error = "O servidor non enviou audio para este episodio.";
} else if (cu.contains(".m3u8")) { } else if (cu.contains(".m3u8")) {
s.error = "This episode needs transcoding, which Android 2.x can't play. Try downloading it."; s.error = "Este episodio precisa transcodificación, que Android 2.x non pode reproducir. Proba a descargalo.";
} else { } else {
s.contentUrl = cu; s.contentUrl = cu;
s.url = Api.withToken(c, Api.absolute(c, cu)); s.url = streamUrl(c, cu);
} }
} }
} catch (Exception ex) { } catch (Exception ex) {
@@ -435,7 +442,7 @@ public class PlayerService extends Service implements MediaPlayer.OnPreparedList
local = s.local; local = s.local;
resumeMs = (int) (s.start * 1000); resumeMs = (int) (s.start * 1000);
lastPosMs = resumeMs; lastPosMs = resumeMs;
prepare(s.url, s.offline ? "Offline · progress will sync later" : null); prepare(s.url, s.offline ? "Sen conexión · o progreso sincronizarase despois" : null);
} }
private void prepare(String url, String note) { private void prepare(String url, String note) {
@@ -460,10 +467,10 @@ public class PlayerService extends Service implements MediaPlayer.OnPreparedList
wifi.acquire(); wifi.acquire();
} }
state = LOADING; state = LOADING;
status = note != null ? note : local ? "Opening saved file…" : "Buffering…"; status = note != null ? note : local ? "Abrindo o ficheiro gardado…" : "Cargando…";
mp.prepareAsync(); mp.prepareAsync();
} catch (Exception ex) { } catch (Exception ex) {
fail("Can't open the audio: " + Util.describe(ex)); fail("Non se pode abrir o audio: " + Util.describe(ex));
} }
} }
@@ -483,7 +490,7 @@ public class PlayerService extends Service implements MediaPlayer.OnPreparedList
start(); start();
} else { } else {
state = PAUSED; state = PAUSED;
status = "Paused"; status = "En pausa";
showNotification(false); showNotification(false);
} }
} }
@@ -499,12 +506,12 @@ public class PlayerService extends Service implements MediaPlayer.OnPreparedList
mp.setVolume(1f, 1f); mp.setVolume(1f, 1f);
mp.start(); mp.start();
} catch (Exception ex) { } catch (Exception ex) {
fail("Playback failed: " + Util.describe(ex)); fail("Fallou a reprodución: " + Util.describe(ex));
return; return;
} }
if (!local) wifi.acquire(); if (!local) wifi.acquire();
state = PLAYING; state = PLAYING;
status = local ? (sessionId == null ? "Saved file · offline" : "Saved file") : "Streaming"; status = local ? (sessionId == null ? "Ficheiro gardado · sen conexión" : "Ficheiro gardado") : "Reproducindo en liña";
playingSince = SystemClock.elapsedRealtime(); playingSince = SystemClock.elapsedRealtime();
lastTick = playingSince; lastTick = playingSince;
if (lastSync == 0) lastSync = playingSince; if (lastSync == 0) lastSync = playingSince;
@@ -548,11 +555,11 @@ public class PlayerService extends Service implements MediaPlayer.OnPreparedList
// Old Android's streamer "completes" when the connection drops. Treat that as an error. // Old Android's streamer "completes" when the connection drops. Treat that as an error.
if (!local && dur > 0 && pos < dur - 30000) { if (!local && dur > 0 && pos < dur - 30000) {
lastPosMs = pos; lastPosMs = pos;
retryStream("Connection lost, reconnecting…"); retryStream("Perdeuse a conexión, reconectando…");
return; return;
} }
state = ENDED; state = ENDED;
status = "Finished"; status = "Rematado";
lastPosMs = dur; lastPosMs = dur;
pausedAt = SystemClock.elapsedRealtime(); pausedAt = SystemClock.elapsedRealtime();
wifi.release(); wifi.release();
@@ -566,9 +573,9 @@ public class PlayerService extends Service implements MediaPlayer.OnPreparedList
if (p != mp) return true; if (p != mp) return true;
if (prepared) lastPosMs = positionMs(); if (prepared) lastPosMs = positionMs();
if (!local && contentUrl != null) { if (!local && contentUrl != null) {
retryStream("Connection lost, reconnecting…"); retryStream("Perdeuse a conexión, reconectando…");
} else { } else {
fail("Playback error (" + what + "/" + extra + ")."); fail("Erro de reprodución (" + what + "/" + extra + ").");
} }
return true; return true;
} }
@@ -576,7 +583,7 @@ public class PlayerService extends Service implements MediaPlayer.OnPreparedList
/** Re-opens the stream at the last position with a fresh token. Gives up after three tries. */ /** Re-opens the stream at the last position with a fresh token. Gives up after three tries. */
private void retryStream(final String msg) { private void retryStream(final String msg) {
if (retries >= 3 || contentUrl == null) { if (retries >= 3 || contentUrl == null) {
fail("Lost connection to the server. Press play to try again."); fail("Perdeuse a conexión co servidor. Preme reproducir para tentalo de novo.");
retries = 0; retries = 0;
return; return;
} }
@@ -603,7 +610,11 @@ public class PlayerService extends Service implements MediaPlayer.OnPreparedList
if (gen != generation) return; if (gen != generation) return;
resumeMs = pos; resumeMs = pos;
playOnPrepared = wasPlaying; playOnPrepared = wasPlaying;
prepare(Api.withToken(ctx, Api.absolute(ctx, cu)), msg); try {
prepare(streamUrl(ctx, cu), msg);
} catch (Exception ex) {
fail(Util.describe(ex));
}
} }
}); });
} }
@@ -664,7 +675,7 @@ public class PlayerService extends Service implements MediaPlayer.OnPreparedList
if (sleepAt > 0 && now >= sleepAt) { if (sleepAt > 0 && now >= sleepAt) {
sleepAt = 0; sleepAt = 0;
pause(); pause();
status = "Sleep timer: paused"; status = "Temporizador: en pausa";
} }
} else if (state == PAUSED || state == ENDED || state == ERROR) { } else if (state == PAUSED || state == ENDED || state == ERROR) {
// Free the phone's memory if left paused for a long time. // Free the phone's memory if left paused for a long time.
@@ -745,11 +756,11 @@ public class PlayerService extends Service implements MediaPlayer.OnPreparedList
PendingIntent pi = PendingIntent.getActivity(this, 0, i, PendingIntent.FLAG_UPDATE_CURRENT); PendingIntent pi = PendingIntent.getActivity(this, 0, i, PendingIntent.FLAG_UPDATE_CURRENT);
String what; String what;
switch (state) { switch (state) {
case PLAYING: what = "Playing"; break; case PLAYING: what = "Reproducindo"; break;
case LOADING: what = "Loading…"; break; case LOADING: what = "Cargando…"; break;
case ENDED: what = "Finished"; break; case ENDED: what = "Rematado"; break;
case ERROR: what = "Stopped: tap to retry"; break; case ERROR: what = "Detido: toca para tentalo de novo"; break;
default: what = "Paused"; break; default: what = "En pausa"; break;
} }
n.setLatestEventInfo(this, ep.title, Util.join(what, ep.show), pi); n.setLatestEventInfo(this, ep.title, Util.join(what, ep.show), pi);
if (foreground) { if (foreground) {
+13 -8
View File
@@ -45,7 +45,7 @@ public class PodcastActivity extends BaseActivity {
title.setText(getIntent().getStringExtra("title")); title.setText(getIntent().getStringExtra("title"));
subtitle.setText(getIntent().getStringExtra("author")); subtitle.setText(getIntent().getStringExtra("author"));
Covers.load(this, (ImageView) findViewById(R.id.cover), itemId, Covers.load(this, (ImageView) findViewById(R.id.cover), itemId,
(int) (44 * getResources().getDisplayMetrics().density)); getResources().getDimensionPixelSize(R.dimen.cover_podcast));
Prefs p = Prefs.get(this); Prefs p = Prefs.get(this);
oldestFirst = p.sp.getBoolean("oldest_first", false); oldestFirst = p.sp.getBoolean("oldest_first", false);
@@ -104,7 +104,7 @@ public class PodcastActivity extends BaseActivity {
loading.setVisibility(View.GONE); loading.setVisibility(View.GONE);
episodes = list; episodes = list;
if (showTitle.length() > 0) title.setText(showTitle); if (showTitle.length() > 0) title.setText(showTitle);
String n = list.size() == 1 ? "1 episode" : list.size() + " episodes"; String n = list.size() == 1 ? "1 episodio" : list.size() + " episodios";
subtitle.setText(Util.join(author, n)); subtitle.setText(Util.join(author, n));
render(); render();
} }
@@ -117,6 +117,11 @@ public class PodcastActivity extends BaseActivity {
} }
empty.setText(Util.describe(e)); empty.setText(Util.describe(e));
empty.setVisibility(View.VISIBLE); empty.setVisibility(View.VISIBLE);
offerTrust(new Runnable() {
public void run() {
load();
}
});
} }
}.start(); }.start();
} }
@@ -140,10 +145,10 @@ public class PodcastActivity extends BaseActivity {
} }
rows.add(Rows.Row.episode(e)); rows.add(Rows.Row.episode(e));
} }
if (hidden > 0) rows.add(0, Rows.Row.header(hidden + " PLAYED EPISODES HIDDEN")); if (hidden > 0) rows.add(0, Rows.Row.header(hidden + " EPISODIOS ESCOITADOS OCULTOS"));
adapter.set(rows); adapter.set(rows);
boolean none = rows.isEmpty(); boolean none = rows.isEmpty();
empty.setText(none ? "No episodes." : ""); empty.setText(none ? "Non hai episodios." : "");
empty.setVisibility(none ? View.VISIBLE : View.GONE); empty.setVisibility(none ? View.VISIBLE : View.GONE);
} }
@@ -167,10 +172,10 @@ public class PodcastActivity extends BaseActivity {
@Override @Override
public boolean onPrepareOptionsMenu(Menu menu) { public boolean onPrepareOptionsMenu(Menu menu) {
menu.clear(); menu.clear();
menu.add(0, 1, 0, oldestFirst ? "Newest first" : "Oldest first").setIcon(android.R.drawable.ic_menu_sort_by_size); menu.add(0, 1, 0, oldestFirst ? "Máis recentes primeiro" : "Máis antigos primeiro").setIcon(android.R.drawable.ic_menu_sort_by_size);
menu.add(0, 2, 0, hidePlayed ? "Show played" : "Hide played").setIcon(android.R.drawable.ic_menu_view); menu.add(0, 2, 0, hidePlayed ? "Amosar escoitados" : "Ocultar escoitados").setIcon(android.R.drawable.ic_menu_view);
menu.add(0, 3, 0, "Refresh").setIcon(android.R.drawable.ic_menu_rotate); menu.add(0, 3, 0, "Actualizar").setIcon(android.R.drawable.ic_menu_rotate);
menu.add(0, 4, 0, "Now playing").setIcon(android.R.drawable.ic_media_play); menu.add(0, 4, 0, "En reprodución").setIcon(android.R.drawable.ic_media_play);
return true; return true;
} }
+2 -3
View File
@@ -36,7 +36,6 @@ final class Prefs {
String token() { return s("access"); } String token() { return s("access"); }
String refreshToken() { return s("refresh"); } String refreshToken() { return s("refresh"); }
String mode() { return s("mode"); } String mode() { return s("mode"); }
boolean trustAll() { return sp.getBoolean("trust_all", false); }
boolean wifiOnly() { return sp.getBoolean("wifi_only", true); } boolean wifiOnly() { return sp.getBoolean("wifi_only", true); }
boolean covers() { return sp.getBoolean("covers", true); } boolean covers() { return sp.getBoolean("covers", true); }
String libraryId() { return s("library_id"); } String libraryId() { return s("library_id"); }
@@ -61,8 +60,8 @@ final class Prefs {
return MODE_JWT.equals(mode()) && refreshToken().length() > 0; return MODE_JWT.equals(mode()) && refreshToken().length() > 0;
} }
void setServer(String server, boolean trustAll) { void setServer(String server) {
sp.edit().putString("server", server).putBoolean("trust_all", trustAll).commit(); sp.edit().putString("server", server).remove("trust_all").commit();
} }
synchronized void setAuth(String user, String mode, String access, String refresh) { synchronized void setAuth(String user, String mode, String access, String refresh) {
+226
View File
@@ -0,0 +1,226 @@
package com.miniabs;
/** Generated by tools/gen_roots.py from the system CA store. Do not edit. */
final class Roots {
private Roots() {
}
static final String[] DER_BASE64 = {
// ISRG_Root_X1 sha256=96bcec06264976f37460779acf28c5a7cfe8a3c0aae11a8ffcee05c0bddf08c6
"MIIFazCCA1OgAwIBAgIRAIIQz7DSQONZRGPgu2OCiwAwDQYJKoZIhvcNAQELBQAwTzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIElu"
+ "dGVybmV0IFNlY3VyaXR5IFJlc2VhcmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwHhcNMTUwNjA0MTEwNDM4WhcNMzUw"
+ "NjA0MTEwNDM4WjBPMQswCQYDVQQGEwJVUzEpMCcGA1UEChMgSW50ZXJuZXQgU2VjdXJpdHkgUmVzZWFyY2ggR3JvdXAxFTATBgNV"
+ "BAMTDElTUkcgUm9vdCBYMTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAK3oJHP0FDfzm54rVygch77ct984kIxuPOZX"
+ "oHj3dcKi/vVqbvYATyjb3miGbESTtrFj/RQSa78f0uoxmyF+0TM8ukj13Xnfs7j/EvEhmkvBioZxaUpmZmyPfjxwv60pIgbz5MDm"
+ "gK7iS4+3mX6UA5/TR5d8mUgjU+g4rk8Kb4Mu0UlXjIB0ttov0DiNewNwIRt18jA8+o+u3dpjq+sWT8KOEUt+zwvo/7V3LvSye0rg"
+ "TBIlDHCNAymg4VMk7BPZ7hm/ELNKjD+Jo2FR3qyHB5T0Y3HsLuJvW5iB4YlcNHlsdu87kGJ55tukmi8mxdAQ4Q7e2RCOFvu396j3"
+ "x+UCB5iPNgiV5+I3lg02dZ77DnKxHZu8A/lJBdiB3QW0KtZB6awBdpUKD9jf1b0SHzUvKBds0pjBqAlkd25HN7rOrFleaJ1/ctaJ"
+ "xQZBKT5ZPt0m9STJEadao0xAH0ahmbWnOlFuhjuefXKnEgV4We0+UXgVCwOPjdAvBbI+e0ocS3MFEvzG6uBQE3xDk3SzynTnjh8B"
+ "CNAw1FtxNrQHusEwMFxIt4I7mKZ9YIqioymCzLq9gwQbooMDQaHWBfEbwrbwqHyGO0aoSCqI3Haadr8faqU9GY/rOPNk3sgrDQoo"
+ "//fb4hVC1CLQJ13hef4Y53CIrU7m2Ys6xt0nUW7/vGT1M0NPAgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIBBjAPBgNVHRMBAf8EBTAD"
+ "AQH/MB0GA1UdDgQWBBR5tFnme7bl5AFzgAiIyBpY9umbbjANBgkqhkiG9w0BAQsFAAOCAgEAVR9YqbyyqFDQDLHYGmkgJykIrGF1"
+ "XIpu+ILlaS/V9lZLubhzEFnTIZd+50xx+7LSYK05qAvqFyFWhfFQDlnrzuBZ6brJFe+GnY+EgPbk6ZGQ3BebYhtF8GaV0nxvwuo7"
+ "7x/Py9auJ/GpsMiu/X1+mvoiBOv/2X/qkSsisRcOj/KKNFtY2PwByVS5uCbMiogziUwthDyC3+6WVwW6LLv3xLfHTjuCvjHIInNz"
+ "ktHCgKQ5ORAzI4JMPJ+GslWYHb4phowim57iaztXOoJwTdwJx4nLCgdNbOhdjsnvzqvHu7UrTkXWStAmzOVyyghqpZXjFaH3pO3J"
+ "LF+l+/+sKAIuvtd7u+Nxe5AW0wdeRlN8NwdCjNPElpzVmbUq4JUagEiuTDkHzsxHpFKVK7q4+63SM1N95R1NbdWhscdCb+ZAJzVc"
+ "oyi3B43njTOQ5yOf+1CceWxG1bQVs5ZufpsMljq4Ui0/1lvh+wjChP4kqKOJ2qxq4RgqsahDYVvTH9w7jXbyLeiNdd8XM2w9U/t7"
+ "y0Ff/9yi0GE44Za4rF2LN9d11TPAmRGunUHBcnWEvgJBQl9nJEiU0Zsnvgc/ubhPgXRR4Xq37Z0j4r7g1SgEEzwxA57demyPxgcY"
+ "xn/eR44/KJ4EBs+lVDR3veyJm+kXQ99b21/+jh5Xos1AnX5iItreGCc=",
// GTS_Root_R1 sha256=d947432abde7b7fa90fc2e6b59101b1280e0e1c7e4e40fa3c6887fff57a7f4cf
"MIIFVzCCAz+gAwIBAgINAgPlk28xsBNJiGuiFzANBgkqhkiG9w0BAQwFADBHMQswCQYDVQQGEwJVUzEiMCAGA1UEChMZR29vZ2xl"
+ "IFRydXN0IFNlcnZpY2VzIExMQzEUMBIGA1UEAxMLR1RTIFJvb3QgUjEwHhcNMTYwNjIyMDAwMDAwWhcNMzYwNjIyMDAwMDAwWjBH"
+ "MQswCQYDVQQGEwJVUzEiMCAGA1UEChMZR29vZ2xlIFRydXN0IFNlcnZpY2VzIExMQzEUMBIGA1UEAxMLR1RTIFJvb3QgUjEwggIi"
+ "MA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQC2EQKLHuOhd5s73L+UPreVp0A8of2C+X0yBoJx9vaMf/vo27xqLpeXo4xL+Sv2"
+ "sfnOhB2x+cWX3u+58qPpvBKJXqeqUqv4IyfLpLGcY9vXmX7wCl7raKb0xlpHDU0QM+NOsROjyBhsS+z8CZDfnWQpJSMHobTSPS5g"
+ "4M/SCYe7zUjwTcLCeoiKu7rPWRnWr4+wB7CeMfGCwcDfLqZtbBkOtdh+JhpFAz2weaSUKK0PfyblqAj+lug8aJRT7oM6iCsVlgmy"
+ "4HqMLnXWnOunVmSPlk9orj2XwoSPwLxAwAtcvfaHszVsrBhQf4TgTM2S0yDpM7xSma8ytSmzJSq0SPly4cpk9+aCEI3oncKKiPo4"
+ "Zor8Y/kB+Xj9e1x3+naH+uzfsQ55lVe0vSbv1gHR6xYKu44LtcXFilWr06zqkUspzBmkMiVOKvFlRNACzqrOSbTqn3yDsEB750Or"
+ "p2yjj32JgfpMpf/VjsPOS+C12LOORc92wO1AK/1TD7Cn1TsNsYqiA94xrcx36m97PtbfkSIS5r762DL8EGMUUXLeXdYWk70paDPv"
+ "OmbsB4om3xPXV2V4J95eSRQAogB/mqghtqmxlbCluQ0WEdrHbEg8QOB+DVrNVjzRlwW5y0vtOUucxD/SVRNuJLDWcfr0wbrM7Rv1"
+ "/oFB2ACYPTrIrnqYNxgFlQIDAQABo0IwQDAOBgNVHQ8BAf8EBAMCAYYwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQU5K8rJnEa"
+ "K0gnhS9SZizv8IkTcT4wDQYJKoZIhvcNAQEMBQADggIBAJ+qQibbC5u+/x6Wki4+omVKapi6Ist9wTrYggoGxval3sBOh2Z5ofmm"
+ "WJyq+bXmYOfg6LEeQkEzCzc9zolwFcq1JKjPa7XSQCGYzyI0zzvFIoTgxQ6KfF2I5DUkzps+GlQebtuyh6f88/qBVRRiClmpIgUx"
+ "PoLW7ttXNLwzldMXG+gnoot7TiYaelpkttGsN/H9oPM47HLwEXWdyzRSjeZ2axfG34arJ45JK3VmgRAhpuo+9K4l/3wV3s6MJT/K"
+ "YnAK9y8JZgfIPxz88NtFMN9iiMG1D53Dn0reWVlHxYciNuaCp+0KueIHoI17eko8cdLiA6EfMgfdG+RCzgwARWGAtQsgWSl4vflV"
+ "y2PFPEz0tv/bal8xa5meLMFrUKTX5hgUvYU/Z6tGn6D/Qqc6f1zLXbBwHSs09dR2CQzreExZBfMzQsNhFRAbd03OIozUhfJFfbdT"
+ "6u9AWpQKXCBfTkBdYiJ23//OYb2MI3jSNwLgjt7RETeJ9r/tSQdirpLsQBqvFAnZ0E6yove+7u7Y/9waLd64NnHi/Hm3lCXRSHNb"
+ "oTXns5lndcEZOitHTtNCjv0xyBZm2tIMPNuzjsmhDYAPexZ3FL//2wmUspO8IFgV6dtxQ/PeEMMA3KgqlbbC1j+Qa3bbbP6MvPJw"
+ "NQzcmRk13NfIRmPVNnGuV/u3gm3c",
// USERTrust_RSA_Certification_Authority sha256=e793c9b02fd8aa13e21c31228accb08119643b749c898964b1746d46c3d4cbd2
"MIIF3jCCA8agAwIBAgIQAf1tMPyjylGoG7xkDjUDLTANBgkqhkiG9w0BAQwFADCBiDELMAkGA1UEBhMCVVMxEzARBgNVBAgTCk5l"
+ "dyBKZXJzZXkxFDASBgNVBAcTC0plcnNleSBDaXR5MR4wHAYDVQQKExVUaGUgVVNFUlRSVVNUIE5ldHdvcmsxLjAsBgNVBAMTJVVT"
+ "RVJUcnVzdCBSU0EgQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkwHhcNMTAwMjAxMDAwMDAwWhcNMzgwMTE4MjM1OTU5WjCBiDELMAkG"
+ "A1UEBhMCVVMxEzARBgNVBAgTCk5ldyBKZXJzZXkxFDASBgNVBAcTC0plcnNleSBDaXR5MR4wHAYDVQQKExVUaGUgVVNFUlRSVVNU"
+ "IE5ldHdvcmsxLjAsBgNVBAMTJVVTRVJUcnVzdCBSU0EgQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkwggIiMA0GCSqGSIb3DQEBAQUA"
+ "A4ICDwAwggIKAoICAQCAEmUXNg7D2wiz0KxXDXbtzSfTTK1Qg2HiqiBNCS1kCdzOiZ/MPans9s/B3PHTsdZ7NygRK0faOca8Ohm0"
+ "X6a9fZ2jY0K2dvKpOyuR+OJv0OwWIJAJPuLodMkYtJHUYmTbf6MG8YgYapAiPLz+E/CHFHv25B+O1ORRxhFnRghRy4YUVD+8M/5+"
+ "bJz/Fp0YvVGONaanZshyZ9shZrHUm3gDwFA66Mzw3LyeTP6vBZY1H1dat//O+T23LLb2VN3I5xI6Ta5MirdcmrS3ID3KfyI0rn47"
+ "aGYBROcBTkZTmzNg95S+UzeQc0PzMsNT79uq/nROacdrjGCT3sTHDN/hMq7MkztReJVni+49Vv4M0GkPGw/zJSZrM233bkf6c0Pl"
+ "fg6lZrEpfDKEY1WJxA3Bk1QwGROs0303p+tdOmw1XNtB1xLaqUkL39iAigmTYo61Zs8liM2EuLE/pDkP2QKe6xJMlXzzawWpXhaD"
+ "zLhn4ugTncxbgtNMs+1b/97lc6wjOy0AvzVVdAlJ2ElYGn+SNuZRkg7zJn0cTRe8yexDJtC/QV9AqURE9JnnV4eeUB9XVKg+/XRj"
+ "L7FQZQnmWEIuQxpMtPAlR1n6BB6T1CZGSlCBst6+eLf8ZxXhyVeEHg9j1uliutZfVS7qXMYoCAQlObgOK6nyTJccBz8NUvXt7y+C"
+ "DwIDAQABo0IwQDAdBgNVHQ4EFgQUU3m/WqorSs9UgOHYm8Cd8rIDZsswDgYDVR0PAQH/BAQDAgEGMA8GA1UdEwEB/wQFMAMBAf8w"
+ "DQYJKoZIhvcNAQEMBQADggIBAFzUfA3P9wF9QZllDHPFUp/L+M+ZBn8b2kMVn54CVVeWFPFSPCeHlCjtHzoBN6J2/FNQwISbxmtO"
+ "uowhT6KOVWKR82kV2LyI48SqC/3vqOlLVSoGIG1VeCkZ7l8wXEskEVX/JJpuXior7gtNn3/3ATiUFJVDBwn7YKnuHKsSjKCaXqeY"
+ "alltiz8I+8jRRa8YFWSQEg9zKC7F4iRO/Fjs8PRF/iKz6y+O0tlFYQXBl2+odnKPi4w2r78NBc5xjeambx9spnFixdjQg3IM8WcR"
+ "iQycE0xyNN+81XHfqnHd4blsjDwSXWXavVcStkNr/+XeTWYRUc+ZruwXtuhxkYzeSf7dNXGiFSeUHM9h4ya7b6NnJSFd5t0dCy5o"
+ "GzuCr+yDZ4XUmFF0sbmZgIn/f3gZXHlKYC6SQK5MNyosycdiyA5d9zZbyuAlJQG03RoHnHcAP9Dc1ew91Pq7P8yF1m9/qS3fuQL3"
+ "9ZeatTXaw2ewh0qpKJ4jjv9cJ2vhsE/zB+4ALtRZh8tSQZXq9EfX7mRBVXyNWQKV3WKdwrnuWih0hKWbt5DHDAff9Yk2dDLWKMGw"
+ "sAvgnEzDHNb842m1R0aBL6KCq9NjRHDEjf8tM7qtj3u1cIiuPhnPQCjY/MiQu12ZIvVS5ljFH4gxQ+6IHdfGjjxDah2nGN59PRbx"
+ "YvnKkKj9",
// DigiCert_Global_Root_G2 sha256=cb3ccbb76031e5e0138f8dd39a23f9de47ffc35e43c1144cea27d46a5ab1cb5f
"MIIDjjCCAnagAwIBAgIQAzrx5qcRqaC7KGSxHQn65TANBgkqhkiG9w0BAQsFADBhMQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGln"
+ "aUNlcnQgSW5jMRkwFwYDVQQLExB3d3cuZGlnaWNlcnQuY29tMSAwHgYDVQQDExdEaWdpQ2VydCBHbG9iYWwgUm9vdCBHMjAeFw0x"
+ "MzA4MDExMjAwMDBaFw0zODAxMTUxMjAwMDBaMGExCzAJBgNVBAYTAlVTMRUwEwYDVQQKEwxEaWdpQ2VydCBJbmMxGTAXBgNVBAsT"
+ "EHd3dy5kaWdpY2VydC5jb20xIDAeBgNVBAMTF0RpZ2lDZXJ0IEdsb2JhbCBSb290IEcyMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A"
+ "MIIBCgKCAQEAuzfNNNx7a8myaJCtSnX/RrohCgiN9RlUyfuI2/Ou8jqJkTx65qsGGmvPrC3oXgkkRLpimn7Wo6h+4FR1IAWsULec"
+ "YxpsMNzaHxmx1x7e/dfgy5SDN67sH0NO3Xss0r0upS/kqbitOtSZpLYl6ZtrAGCSYP9PIUkY92eQq2EGnI/yuum06ZIya7XzV+hd"
+ "G82MHauVBJVJ8zUtluNJbd134/tJS7SsVQepj5WztCO7TG1F8PapspUwtP1MVYwnSlcUfIKdzXOS0xZKBgyMUNGPHgm+F6HmIcr9"
+ "g+UQvIOlCsRnKPZzFBQ9RnbDhxSJITRNrw9FDKZJobq7nMWxM4MphQIDAQABo0IwQDAPBgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB"
+ "/wQEAwIBhjAdBgNVHQ4EFgQUTiJUIBiV5uNu5g/6+rkS7QYXjzkwDQYJKoZIhvcNAQELBQADggEBAGBnKJRvDkhj6zHd6mcY1Yl9"
+ "PMWLSn/pvtsrF9+wX3N3KjITOYFnQoQj8kVnNeyIv/iPsGEMNKSuIEyExtv4NeF22d+mQrvHRAiGfzZ0JFrabA0UWTW98kndth/J"
+ "sw1HKj2ZL7tcu7XUIOGZX1NGFdtom/DzMNU+MeKNhJ7jitralj41E6Vf8PlwUHBHQRFXGU7Aj64GxJUTFy8bJZ918rGOmaFvE7FB"
+ "cf6IKshPECBV1/MUReXgRPTqh5Uykw7+U0b6LJ3/iyK5S9kJRaTepLiaWN0bfVKfjllDiIGknibVb63dDcY3fe0Dkhvld1927jyN"
+ "xF1WW6LZZm6zNTflMrY=",
// Amazon_Root_CA_1 sha256=8ecde6884f3d87b1125ba31ac3fcb13d7016de7f57cc904fe1cb97c6ae98196e
"MIIDQTCCAimgAwIBAgITBmyfz5m/jAo54vB4ikPmljZbyjANBgkqhkiG9w0BAQsFADA5MQswCQYDVQQGEwJVUzEPMA0GA1UEChMG"
+ "QW1hem9uMRkwFwYDVQQDExBBbWF6b24gUm9vdCBDQSAxMB4XDTE1MDUyNjAwMDAwMFoXDTM4MDExNzAwMDAwMFowOTELMAkGA1UE"
+ "BhMCVVMxDzANBgNVBAoTBkFtYXpvbjEZMBcGA1UEAxMQQW1hem9uIFJvb3QgQ0EgMTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCC"
+ "AQoCggEBALJ4gHHKeNXjca9HgFB0fW7Y14h29Jlo91ghYPl0hAEvrAIthtOgQ3pOsqTQNroBvo3bSMgHFzZM9O6II8c+6zf1tRn4"
+ "SWiw3te5djgdYZ6k/oI2peVKVuRF4fn9tBb6dNqcmzU5L/qwIFAGbHrQgLKm+a/sRxmPUDgH3KKHOVj4utWp+UhnMJbulHheb4mj"
+ "UcAwhmahRWa6VOujw5H5SNz/0egwLX0tdHA114gk957EWW67c4cX8jJGKLhD+rcdqsq08p8kDi1L93FcXmn/6pUCyziKrlA4b9v7"
+ "LWIbxcceVOF34GfID5yHI9Y/QCB/IIDEgEw+OyQmjgSubJrIqg0CAwEAAaNCMEAwDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8E"
+ "BAMCAYYwHQYDVR0OBBYEFIQYzIU07LwMlJQuCFmcx7IQTgoIMA0GCSqGSIb3DQEBCwUAA4IBAQCY8jdaQZChGsV2USggNiMOruYo"
+ "u6r4lK5IpDB/G/wkjUu0yKGX9rbxenDIU5PMCCjjmCXPI6T53iHTfIUJrU6adTrCC2qJeHZERxhlbI1Bjjt/msv0tadQ1wUsN+gD"
+ "S63pYaACbvXy8MWy7Vu33PqUXHeeE6V/Uq2V8viTO96LXFvKWlJbYK8U90vvo/ufQJVtMVT8QtPHRh8jrdkPSHCa2XV4cdFyQzR1"
+ "bldZwgJcJmApzyMZFo6IQ6XU5MsI+yMRQ+hDKXJioaldXgjUkK642M4UwtBV8ob2xJNDd2ZhwLnoQdeXeGADbkpyrqXRfboQnoZs"
+ "G4q5WTP468SQvvG5",
// Microsoft_RSA_Root_Certificate_Authority_2017 sha256=c741f70f4b2a8d88bf2e71c14122ef53ef10eba0cfa5e64cfa20f418853073e0
"MIIFqDCCA5CgAwIBAgIQHtOXCV/YtLNHcB6qvn9FszANBgkqhkiG9w0BAQwFADBlMQswCQYDVQQGEwJVUzEeMBwGA1UEChMVTWlj"
+ "cm9zb2Z0IENvcnBvcmF0aW9uMTYwNAYDVQQDEy1NaWNyb3NvZnQgUlNBIFJvb3QgQ2VydGlmaWNhdGUgQXV0aG9yaXR5IDIwMTcw"
+ "HhcNMTkxMjE4MjI1MTIyWhcNNDIwNzE4MjMwMDIzWjBlMQswCQYDVQQGEwJVUzEeMBwGA1UEChMVTWljcm9zb2Z0IENvcnBvcmF0"
+ "aW9uMTYwNAYDVQQDEy1NaWNyb3NvZnQgUlNBIFJvb3QgQ2VydGlmaWNhdGUgQXV0aG9yaXR5IDIwMTcwggIiMA0GCSqGSIb3DQEB"
+ "AQUAA4ICDwAwggIKAoICAQDKW76UM4wplZEWCpW9R2LBifOZNt9GkMml7Xhqb0eRaPgnZ1AzHaGm++DlQ6OEAlcBXZxIQIJTELy/"
+ "xztokLaCLeX0ZdDMbRnMlfl7rEqUrQ7eS0MdhweSE5CAg2Q1OQT85elss7YfUJQ4ZVBcF0a5toW1HLUX6NZFndiyJrDKxHBKrmCk"
+ "3bPZ7Pw71VdyvD/IybLeS2v4I2wDwAW9lcfNcztmgGTjGqwu+UcF8ga2m3P1eDNbx6H7JyqhtJqRjJHTOoI+dkC0zVJhUXAoP8XF"
+ "WvLJjEm7FFtNyP9nTUwSlq31/niol4fX/V4ggNyhSyL71Imtus5Hl0dVe49FyGcohJUcaDDv70ngNXtk55iwlNpNhTs+VcQor1fz"
+ "nhPbRiefHqJeRIOkpcrVE7NLP8TjwuaGYaRSMLl6IE9vDzhTyzMMEyuP1pq9KsgtsRx9S1HKR9FIJ3Jdh+vVReZIZZ2vUpC6W6IY"
+ "ZVcSn2i51BVrlMRpIpj0M+Dt+VGOQVDJNE92kKz8OMHY4Xu54+OU4UZpyw4KUGsTuqwPN1q3ErWQgR5WrlcihtnJ0tHXUeOrO8ZV"
+ "/R4O03QK0dqq6mm4lyiPSMQH+FJDOvTKVTUssKZqwJz58oHhEmrARdlns87/I6KJClTUFLkqqNfs+avNJVgyeY+QW5g5xAgGwax/"
+ "Dj0ApQIDAQABo1QwUjAOBgNVHQ8BAf8EBAMCAYYwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUCctZf4aycI8awznjwNnpv7tN"
+ "siMwEAYJKwYBBAGCNxUBBAMCAQAwDQYJKoZIhvcNAQEMBQADggIBAKyvPl3CEZaJjqPnktaXFbgToqZCLgLNFgVZJ8og6Lq46Brs"
+ "TaiXVq5lQ7GPAJtSzVXNUzltYkyLDVt8LkS/gxCP81OCgMNPOsduET/m4xaRhPtthH80dK2Jp86519efhGSSvpWhrQlTM93uCupK"
+ "UY5vVau6tZRGrox/2KJQJWVggEbbMwSubLWYdFQl3JPk+ONVFT24bcMKpBLBaYVu32TxU5nhSnUgnZUP5NbcA/FZGOhHibJXWpS2"
+ "qdgXKxdJ5XbLwVaZOjex/2kskZGT4d9Mozd2TaGf+G0eHdP67Pv0RR0Tbc/3WeUiJ3IrhvNXuzDtJE3cfVa7o7P4NHmJweDyAmH3"
+ "pvwPuxwXC65B2Xy9J6P9LjrRk5Sxcx0ki69bIImtt2dmefU6xqaWM/5TkshGsRGRxpl/j8nWZjEgQRCHLQzWwa80mMpkg/sTV9HB"
+ "8Dx6jKXB/ZUhoHHBk2dxEuqPiAppGWSZI1b7rCoucL5mxAyE7+WL85MB+GqQk2dLsmijtWKP6T+MejteD+eMuMZ87zf9dOLITzNy"
+ "4ZQ5bb0Sr74MTnB8G2+NszKTc0QWbej09+CVgI+WXTik9KveCjCHk9hNAHFiRSdLOkKEW39lt2c0Ui2cFmuqqNh7o0JMcccMyj6D"
+ "5KbvtwEwXlGjefVwaaZBRA+GsCyRxj3qrg+E",
// SSL.com_Root_Certification_Authority_RSA sha256=85666a562ee0be5ce925c1d8890a6f76a87ec16d4d7d5f29ea7419cf20123b69
"MIIF3TCCA8WgAwIBAgIIeyyb0xaAMpkwDQYJKoZIhvcNAQELBQAwfDELMAkGA1UEBhMCVVMxDjAMBgNVBAgMBVRleGFzMRAwDgYD"
+ "VQQHDAdIb3VzdG9uMRgwFgYDVQQKDA9TU0wgQ29ycG9yYXRpb24xMTAvBgNVBAMMKFNTTC5jb20gUm9vdCBDZXJ0aWZpY2F0aW9u"
+ "IEF1dGhvcml0eSBSU0EwHhcNMTYwMjEyMTczOTM5WhcNNDEwMjEyMTczOTM5WjB8MQswCQYDVQQGEwJVUzEOMAwGA1UECAwFVGV4"
+ "YXMxEDAOBgNVBAcMB0hvdXN0b24xGDAWBgNVBAoMD1NTTCBDb3Jwb3JhdGlvbjExMC8GA1UEAwwoU1NMLmNvbSBSb290IENlcnRp"
+ "ZmljYXRpb24gQXV0aG9yaXR5IFJTQTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAPkP3aMrfcvQKv7sZ4Wm5y4bunfh"
+ "4/WvpOz6Sl2RxFdHaxh3a3by/ZPkPQ/CFp4LZsNWlJ4Xg4XOVu/yFv0AYvUiCVToZRdOQbngT0aXqhvIuG5iXmmxX9sqAn78bMrz"
+ "Qdjt0Oj8P2FI7bADFB0QDksZ4LtO7IZl/zbzXmcCC52GVWH9ejjt/uIZALdvoVBidXQ8oPrIJZK0bnoix/geoeOy3ZExqysdBP+l"
+ "SgQ36YWkMyv94tZVNHwZpEpox7Ko07fKoZOI68GXvIz5HdkihCR0xwQ9aqkpk8zruFvh/l8lqjRYyMEjVJ0bmBHDOJx+PYZspQ9A"
+ "hnwC9FwCTyjLrnGfDzrIM/4RJTXq/LrFYD3ZfBjVsqnTdXgDciLKOsMf7yzlLqn6niy2UUb9rwPW6mBo6oUWNmuF6R7As93EJNyA"
+ "KoFBbZQ+yODJgUEAnl6/f8UImKIYLEJAs/lvOCdLToD0PYFH4Ih86hzOtXVcUS4cK38acijnALXRdMbX5J+tB5O2UzU1/Dfkw/Zd"
+ "Fr4hc96SCvigY2q8lpJqPvi8ZVWb3vUNiSYE/CUapiVpy8JtynziWV+XrOvvLsi81xtZPCvM8hnIk2snYxnP/Okm+Mpxm3+T/jRn"
+ "hE6Z6/yzeAkzcLpmpnbtG3PrGqUNxCITIJRWCk4sbE6x/c+cCbqiM+2HAgMBAAGjYzBhMB0GA1UdDgQWBBTdBAkHovV6fVJTEpKV"
+ "7jiAJQ2mWTAPBgNVHRMBAf8EBTADAQH/MB8GA1UdIwQYMBaAFN0ECQei9Xp9UlMSkpXuOIAlDaZZMA4GA1UdDwEB/wQEAwIBhjAN"
+ "BgkqhkiG9w0BAQsFAAOCAgEAIBgRlCn7Jp0cHh5wYfGVcpNxJK1ok1iOMq8bs3AD/CUrdIWQPXhq9LmLpZc7tRiRux6n+UBbkflV"
+ "ma8eEdBcHadm47GUBwwyOabqG7B52B2ccETjit3E+ZUfijhDPwGFpUenPUayvOUiaPd7nNgsPgohyC0zrL/FgZkxdMF1ccW+sfAj"
+ "RfSda/wZY52jvATGGAslu1OJD7OAUN5F7kR/q5R4ZJjT9ijdh9hwZXT7DrkT66cPYakylszeu+1jTBi7qUD3oFRuIIhxdRjqerQ0"
+ "cuAjJ3dctpDqhiVAq+8zD8ufgr6iIPv2tS0a5sKFsXQP+8hlAqRSAUfdSSLBv9jra6x+3uxjMxW3IwiPxg+NQVrdjsW5j+VFP3jb"
+ "utIbQLH+cU0/4IGiul607BXgk90IH37hVZkLId6Tngr75qNJvTYw/ud3sqB1l7UtgYgXZSD32pAAn8lSzDLKNXz1PQ/YK9f1JmzJ"
+ "BjSWFupwWRoyeXkLtoh/D1JIPb9s2KJELtFOt3JY04kTlf5Eq/jXixtunLwsoFvVagCvXzfh1foQC5ichucmj87w7G6KVwuA406y"
+ "wKBjYZC6VWg3dGq2ktufoYYitmUnDuy2n0Jg5GfCtdpBC8TTi2EbvPofkSvXRAdeuims2cXp71NIWuuA8ShYIc2wBlX7Jz9TkHCp"
+ "BB5XJ7k=",
// Buypass_Class_2_Root_CA sha256=9a114025197c5bb95d94e63d55cd43790847b646b23cdf11ada4a00eff15fb48
"MIIFWTCCA0GgAwIBAgIBAjANBgkqhkiG9w0BAQsFADBOMQswCQYDVQQGEwJOTzEdMBsGA1UECgwUQnV5cGFzcyBBUy05ODMxNjMz"
+ "MjcxIDAeBgNVBAMMF0J1eXBhc3MgQ2xhc3MgMiBSb290IENBMB4XDTEwMTAyNjA4MzgwM1oXDTQwMTAyNjA4MzgwM1owTjELMAkG"
+ "A1UEBhMCTk8xHTAbBgNVBAoMFEJ1eXBhc3MgQVMtOTgzMTYzMzI3MSAwHgYDVQQDDBdCdXlwYXNzIENsYXNzIDIgUm9vdCBDQTCC"
+ "AiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBANfHXvfBB9R3+0Mh9PT1aeTuMgHbo4Yf5FkNuud1g1Lr6hxhFUi7HQfKjK6w"
+ "3Jad6sNgkoaCKHOcVgb/S2TwDCo3SbXlzwx87vFKu3MwZfPVL4O2fuPn9Z6rYPnT8Z2SdIrkHJasW4DptfQxh6NR/Md+oW+OU3fU"
+ "l8FVM5I+GC911K2GScuVr1QGbNgGE41b/+EmGVnAJLqBcXmQRFBoJJRfuLMR8SlBYaNByyM21cHxMlAQTn/0hpPshNOOvEu/XAFO"
+ "Bz3cFIqUCqTqc/sLUegTBxj6DvEr0VQVfTzh97QZQmdiXnfgolXsttlpF9U6r0TtSsWe5HonfOV116rLJeffawrbD02TTqigzXsu"
+ "8lkBarcNuAeBfos4GzjmCleZPe4h6KP1DBbdi+w0jpwqHAAVF41og9JwnxgIzRFo1clrUs3ERo/ctfPYV3Me6ZQ5BL/T3jjetFPs"
+ "aRyifsSP5BtwrfKi+fv3FmRmaZ9JUaLiFRhnBkp/1Wy1TbMz4GHrXb7pmA8y1x1LPC5aAVKRCfLf6o3YBkBjqhHk/sM3nhRSP/Ti"
+ "zPJhk9H9Z2vXUq6/aKtAQ6BXNVN48FP4YUIHZMbXb5tMOA1jrGKvNouicwoN9SG9dKpN6nIDSdvHXx1iY8f93ZHsM+71bbRuMGje"
+ "yNYmsHVee7QHIJihdjK4TWxPAgMBAAGjQjBAMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYEFMmAd+BikoL1RpzzuvdMw964o605"
+ "MA4GA1UdDwEB/wQEAwIBBjANBgkqhkiG9w0BAQsFAAOCAgEAU18h9bqwOlI5LJKwbADJ784g7wbylp7ppHR/ehb8t/W2+xUbP6um"
+ "wHJdELFx7rxP462sA20ucS6vxOOto70MEae0/0qyexAQH6dXQbLArvQsWdZHEIjzIVEpMMpghq9Gqx3tOluwlN5E40EIosHsHdb9"
+ "T7bWR9AUC8rmyrV7d35BH16Dx7aMOZawP5aBQW9gkOLo+fsicdl9sz1Gv7SEr5AcD48Saq/v7h56rgJKihcrdv6sVIkkLE8/trKn"
+ "ToyokZf7KcZ7XC25y2a2t6hbElGFtQl+Ynhw/qlqYLYdDnkM/crqJIByw5c/8nerQyIKx+u2DISCLIBrQYoIwOula9+ZEsuK1V6A"
+ "DJHgJgg2SMX6OBE1/yWDLfJ6v9r9jv6ly0UsH8SIU653DtmadsWOLB2jutXsMq7Aqqz30XpN69QH4kj3Io6wpJ9qzo6ysmD0oyLQ"
+ "I+uUWnpp3Q+/QFesa1lQ2aOZ4W7+jQF5JyMV3pKdewlNWudLSDBaGOYKbeaP4NK75t98biGCwWg5TbSYWGZizEqQXsP6JwSxeRV0"
+ "mcy+rSDeJmAc61ZRpqPq5KM/p/9h3PFaTWwyI0PurKju7koSCTxdccK+efrCh2gdC/1cacwG0Jp9VJkqyTkaGa9LKkPzY11aWOIv"
+ "4x3kqdbQCtCev9eBCfHJxyYNrJgWVqA=",
// HARICA_TLS_RSA_Root_CA_2021 sha256=d95d0e8eda79525bf9beb11b14d2100d3294985f0c62d9fabd9cd999eccb7b1d
"MIIFpDCCA4ygAwIBAgIQOcqTHO9D88aOk8f0ZIk4fjANBgkqhkiG9w0BAQsFADBsMQswCQYDVQQGEwJHUjE3MDUGA1UECgwuSGVs"
+ "bGVuaWMgQWNhZGVtaWMgYW5kIFJlc2VhcmNoIEluc3RpdHV0aW9ucyBDQTEkMCIGA1UEAwwbSEFSSUNBIFRMUyBSU0EgUm9vdCBD"
+ "QSAyMDIxMB4XDTIxMDIxOTEwNTUzOFoXDTQ1MDIxMzEwNTUzN1owbDELMAkGA1UEBhMCR1IxNzA1BgNVBAoMLkhlbGxlbmljIEFj"
+ "YWRlbWljIGFuZCBSZXNlYXJjaCBJbnN0aXR1dGlvbnMgQ0ExJDAiBgNVBAMMG0hBUklDQSBUTFMgUlNBIFJvb3QgQ0EgMjAyMTCC"
+ "AiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAIvC569lmwVnlskNJLnQDmT8zuIkGCyEf3dRywQRNrhe7Wlxp57kJQmXZ8FH"
+ "ws+RFjZiPTgE4VGC/6zStGndLuwRo0Xua2s7TL+MjaQenRG56Tj5eg4MmOIjHdFOY9TnuEFE+2uva9of08WRiFukiZLRgeaMOVig"
+ "1mlDqa2YUlhu2wr7a89o+uOkXjpFc5gH6l8Cct4MpbOfrqkdtx2z/IpZ525yZa31MJQjB/OCFks1mJxTuy/K5FrZx40d/JiZ+yyk"
+ "gmvwKh+OC19xXFyuQnspiYHLA6OZyoieC0AJQTPb5lh6/a6ZcMBaD9YThnEvdmn8kN3bLW7R8pv1GmuebxWMevBLKKAiOIAkbDak"
+ "O/IwkfN4E8/BPzWr8R0RI7VDIp4BkrcYAuUR0YLbFQDMYTfBKnya4dC6s1BG7oKsnTH4+yPiAwBIcKMJJnkVU2DzOFytOOqBAGMU"
+ "uTNe3QvboEUHGjMJ+E20pwKmafTCWQWIZYVWrkvL4N48fS0ayOn7H6NhStYqE613TBoYm5EPWNgGVMWX+Ko/IIqmhaZ39qb8HOLu"
+ "bpQzKoNQhArlT4b4UEV4AIHrW2jjJo3Me1xR9BQsQL4aYB16cmEdH2MtiKrOokWQCPxrvrNQKlr9qEgYRtaQQJKQCoReaDH46+0N"
+ "0x3GfZkYVVYnZS6NRcUk7M7jAgMBAAGjQjBAMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYEFApII6ZgpJIKM+qTW8VX6iVNvRLu"
+ "MA4GA1UdDwEB/wQEAwIBhjANBgkqhkiG9w0BAQsFAAOCAgEAPpBIqm5iFSVmewzVjIuJndftTgfvnNAUX15QvWiWkKQUEapobQk1"
+ "OUAJ2vQJLDSle1mESSmXdMgHHkdt8s4cUCbjnj1AUz/3f5Z2EMVGpdAgS1D0NTsY9FVqQRtHBmg8uwkIYtlfVUKqrFOFrJVWNlar"
+ "5AWMxajaH6NpvVMPxP/cyuN+8kyIhkdGGvMA9YCRotxDQpSbIPDRzbLrLFPCU3hKTwSUQZqPJzLB5UkZv/HywouoCjkxKLR9YjYs"
+ "TewfM7Z+d21+UPCfDtcRj88YxeMn/ibvBZ3PzzfF0HvaO7AWhAw6k9a+F9sPPg4ZeAnHqQJyIkv3N3a6dcSFA1pj1bF1BcK5vZSt"
+ "jBWZp5N99sXzqnTPBIWUmAD04vnKJGW/4GKvyMX6ssmeVkjaef2WdhW+o45WxLM0/L5H9MG0qPzVMIho7suuyWPEdr6sOBjhXlzP"
+ "rjoiUevRi7PzKzMHVIf6tLITe7pTBGIBnfHAT+7hOtSLIBD6Alfm78ELt5BGnBkpjNxvoEppaZS3JGWg/6w/zgH7IS79aPib8qXP"
+ "MThcFarmlwDB31qlpzmq6YR/PFGoOtmUW4y/Twhx5duoXNTSpv4Ao8YWxw/ogM4cKGR0GQjTQuPOAF1/sdwTsOEFy9EgqoZ0njnn"
+ "kf3/W9b3raYvAwtt41dU63ZTGI0RmLo=",
// Certum_Trusted_Network_CA sha256=5c58468d55f58e497e743982d2b50010b6d165374acf83a7d4a32db768c4408e
"MIIDuzCCAqOgAwIBAgIDBETAMA0GCSqGSIb3DQEBBQUAMH4xCzAJBgNVBAYTAlBMMSIwIAYDVQQKExlVbml6ZXRvIFRlY2hub2xv"
+ "Z2llcyBTLkEuMScwJQYDVQQLEx5DZXJ0dW0gQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkxIjAgBgNVBAMTGUNlcnR1bSBUcnVzdGVk"
+ "IE5ldHdvcmsgQ0EwHhcNMDgxMDIyMTIwNzM3WhcNMjkxMjMxMTIwNzM3WjB+MQswCQYDVQQGEwJQTDEiMCAGA1UEChMZVW5pemV0"
+ "byBUZWNobm9sb2dpZXMgUy5BLjEnMCUGA1UECxMeQ2VydHVtIENlcnRpZmljYXRpb24gQXV0aG9yaXR5MSIwIAYDVQQDExlDZXJ0"
+ "dW0gVHJ1c3RlZCBOZXR3b3JrIENBMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA4/t9o3K6wvDJFIf1awFO4W5AB7pt"
+ "J11/91sts1rHUV+rpDKmYYe2bg+G0jACl/jXaVehGDldamR5xgFZrDwxSjh80gTSSyjoIF87B6LMTXPb865Px1bVWqeWifrzq2jU"
+ "I4ZZJ88JJ7ysbnKDHDBy3+Ci6dLhdHUZvSqeexVUBBvXQzmtVSjF4hq79MDkrjhJM8x2hZ85RdKknvISjFH4fOQtf/WsX+sWn7Et"
+ "0brMkUJ3TCXJkDhv2/DM+44el1k+1WBO5gUo7Ul5E0u6SNsv+XLTOcr+H9g0cvW0QM8xAcPs3hEtF10fuFDRXhmnad4HMyjKUJX5"
+ "p1TLVIZQRan5SQIDAQABo0IwQDAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBQIds3LB/8k9sXN7buQvOKEN0Z19zAOBgNVHQ8B"
+ "Af8EBAMCAQYwDQYJKoZIhvcNAQEFBQADggEBAKaorSLOAT2mo/9i0Eidi15ysHhE49wcrwn9I0j6vSrEuVUEtRCjjSfeC4Jj0O7e"
+ "DDd5QVsisrCaQVymcODU0HfLI9MA4GxWL+FpDQ3Zqr8hgVDZBqWo/5U30Kr+4rP1mS1FhIrlQgnXdAIv94nYmem8J9RHjboNRhx3"
+ "zxSkHLmkMcScKHQDNP8zGSal6Q10tz6XxnboJ5ajZt3hrvJBW8qYVoNzcOSGGtIxQbovvi0TWnZvTuhOgQ4/WwMioBK+ZlgRSssD"
+ "xLQqKi2WF+A5VLxI03YnnZotBqbJ7DnSq9ufmgsnAjUpsUCV5/nonFWIGUbWtzT1fs45mtk48VH3Tyw=",
// Go_Daddy_Root_Certificate_Authority_-_G2 sha256=45140b3247eb9cc8c5b4f0d7b53091f73292089e6e5a63e2749dd3aca9198eda
"MIIDxTCCAq2gAwIBAgIBADANBgkqhkiG9w0BAQsFADCBgzELMAkGA1UEBhMCVVMxEDAOBgNVBAgTB0FyaXpvbmExEzARBgNVBAcT"
+ "ClNjb3R0c2RhbGUxGjAYBgNVBAoTEUdvRGFkZHkuY29tLCBJbmMuMTEwLwYDVQQDEyhHbyBEYWRkeSBSb290IENlcnRpZmljYXRl"
+ "IEF1dGhvcml0eSAtIEcyMB4XDTA5MDkwMTAwMDAwMFoXDTM3MTIzMTIzNTk1OVowgYMxCzAJBgNVBAYTAlVTMRAwDgYDVQQIEwdB"
+ "cml6b25hMRMwEQYDVQQHEwpTY290dHNkYWxlMRowGAYDVQQKExFHb0RhZGR5LmNvbSwgSW5jLjExMC8GA1UEAxMoR28gRGFkZHkg"
+ "Um9vdCBDZXJ0aWZpY2F0ZSBBdXRob3JpdHkgLSBHMjCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAL9xYgjx+lk09xvJ"
+ "GKP3gElY6SKDE6bFIEMBO4Tx5oVJnyfq9oQbTqC023CYxzIBsQU+B07u9PpPL1kwIuerGVZr4oAH/PMWdYA5UXvl+TW2dE6pjYIT"
+ "5LY/qQOD+qK+ihVqf94Lw7YZFAXK6sOoBJQ7RnwyDfMAZiLIjWltNowRGLfTshxgtDj6AozO091GB94KPutdfMh8+7ArU6SSYmlR"
+ "JQVhGkSBjCypQ5Yj36w6gZoOKcUcqeldHraenjAKOc7xiID7S13MMuyFYkMlNAJWJwGRtDtwKj9useiciAF9n9T521NtYJ2/LOdY"
+ "q7hfRvzOxBsDPAnrSTFcaUaz4EcCAwEAAaNCMEAwDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMCAQYwHQYDVR0OBBYEFDqa"
+ "hQcQZyi27/a9BUFuIMGU2g/eMA0GCSqGSIb3DQEBCwUAA4IBAQCZ21151fmXWWcDYfF+OwYxdS2hII5PZYe096acvNjpL9DbWu7P"
+ "dIxztDhC2gV7+AJ1uP2lsdeu9tfeE8tTEH6KRtGX+rcuKxGrkLAngPnon1rpN5+r5N9ss4UXnT3ZJE95kTXWXwTrgIOrmgIttRD0"
+ "2JDHBHNA7XIloKmf7J6raBKZV8aPEjoJpL1E/QYVN8Gb5DKj7Tjo2GTzLH4U/ALqn83/B2gX2yKQOC16jdFU8WnjXzPKej17CuPK"
+ "f1855eJ1usV2GDPOLPAvTK33sefOT6jEm0pUBsV/fdUID+Ic/n4XuKxe9tQWskMJDE32p2u0mYRlynqI4uJEvlz36hz1",
// Starfield_Root_Certificate_Authority_-_G2 sha256=2ce1cb0bf9d2f9e102993fbe215152c3b2dd0cabde1c68e5319b839154dbb7f5
"MIID3TCCAsWgAwIBAgIBADANBgkqhkiG9w0BAQsFADCBjzELMAkGA1UEBhMCVVMxEDAOBgNVBAgTB0FyaXpvbmExEzARBgNVBAcT"
+ "ClNjb3R0c2RhbGUxJTAjBgNVBAoTHFN0YXJmaWVsZCBUZWNobm9sb2dpZXMsIEluYy4xMjAwBgNVBAMTKVN0YXJmaWVsZCBSb290"
+ "IENlcnRpZmljYXRlIEF1dGhvcml0eSAtIEcyMB4XDTA5MDkwMTAwMDAwMFoXDTM3MTIzMTIzNTk1OVowgY8xCzAJBgNVBAYTAlVT"
+ "MRAwDgYDVQQIEwdBcml6b25hMRMwEQYDVQQHEwpTY290dHNkYWxlMSUwIwYDVQQKExxTdGFyZmllbGQgVGVjaG5vbG9naWVzLCBJ"
+ "bmMuMTIwMAYDVQQDEylTdGFyZmllbGQgUm9vdCBDZXJ0aWZpY2F0ZSBBdXRob3JpdHkgLSBHMjCCASIwDQYJKoZIhvcNAQEBBQAD"
+ "ggEPADCCAQoCggEBAL3twQP89o/8ArFvW59I2Z154qK3A2FWGMNHttfKPTUuiUP3oWmb3ooa/RMgnLRJdzIpVv257IzdIvpy3Cdh"
+ "l+72WoTsbhm5iSzchFvVdPtrX8WJpRBSiUZV9Lh1HOZ/5FSuS/hVclcCGfgXcVnrHigHdMWdSL5stPSksPNkN3mSwOxGXn/hbVNM"
+ "Yq/NHwtjuzqd+/x5AJhhdM8mgkBj87JyahkNmcrUDnXMN/uLicFZ8WJ/X7NfZTD4p7dNdloedl40wOiWVpmKs/B/pM293DIxfJHP"
+ "4F8R+GuqSVzRmZTRouNjWwl2tVZi4Ut0HZbUJtQIBFnQmA4O5t78w+wfkPECAwEAAaNCMEAwDwYDVR0TAQH/BAUwAwEB/zAOBgNV"
+ "HQ8BAf8EBAMCAQYwHQYDVR0OBBYEFHwMMh+n2TB/xH1oo2Kooc6rB1snMA0GCSqGSIb3DQEBCwUAA4IBAQARWfolTwNvlJk7mh+C"
+ "hTnUdgWUXuEok21iXQnCoKjUsHU48TRqneSfioYmUeYs0cYtbpUgSpIB7LiKZ3sx4mcujJUDJi5DnUox9g61DLu34jd/IroAow57"
+ "UvtruzvE03lRTs2Q9GcHGcg8RnoNAX3FWOdt5oUwF5okxBDgBPfg8n/Uqgr/Qh037ZTlZFkSIHc40zI+OIF1lnP6aI+xy84fxez6"
+ "nH7PfrHxBy22/L/KpL/QlwVKvOoYKAKQvVR4CSFx09F9HdkWsKlhPdAKACL8x3vLCWRFCztAgfd9fDL1mMpYjn0q7pBZc2T5NnRe"
+ "JaH1ZgUufzkVqSr7UIuOhWn0",
};
}
+7 -7
View File
@@ -78,7 +78,7 @@ final class Rows extends BaseAdapter {
Rows(Activity a) { Rows(Activity a) {
act = a; act = a;
inf = LayoutInflater.from(a); inf = LayoutInflater.from(a);
coverPx = (int) (40 * a.getResources().getDisplayMetrics().density); coverPx = a.getResources().getDimensionPixelSize(R.dimen.cover_row);
} }
void set(List<Row> list) { void set(List<Row> list) {
@@ -147,7 +147,7 @@ final class Rows extends BaseAdapter {
private void bindShow(View v, Show s) { private void bindShow(View v, Show s) {
((TextView) v.findViewById(R.id.title)).setText(s.title); ((TextView) v.findViewById(R.id.title)).setText(s.title);
String n = s.episodes == 1 ? "1 episode" : s.episodes + " episodes"; String n = s.episodes == 1 ? "1 episodio" : s.episodes + " episodios";
((TextView) v.findViewById(R.id.meta)).setText(Util.join(s.author, n)); ((TextView) v.findViewById(R.id.meta)).setText(Util.join(s.author, n));
Covers.load(act, (ImageView) v.findViewById(R.id.cover), s.id, coverPx); Covers.load(act, (ImageView) v.findViewById(R.id.cover), s.id, coverPx);
} }
@@ -175,21 +175,21 @@ final class Rows extends BaseAdapter {
String m = Util.date(e.published); String m = Util.date(e.published);
if (finished) { if (finished) {
m = Util.join(m, "Played"); m = Util.join(m, "Escoitado");
} else if (started && dur > 0) { } else if (started && dur > 0) {
m = Util.join(m, Util.duration(Math.max(0, dur - cur)) + " left"); m = Util.join(m, "quedan " + Util.duration(Math.max(0, dur - cur)));
} else if (dur > 0) { } else if (dur > 0) {
m = Util.join(m, Util.duration(dur)); m = Util.join(m, Util.duration(dur));
} }
if (DownloadService.busyWith(e.id)) { if (DownloadService.busyWith(e.id)) {
Episode now = DownloadService.current; Episode now = DownloadService.current;
m = Util.join(m, now != null && now.id.equals(e.id) ? "Saving " + DownloadService.percent + "%" : "Queued"); m = Util.join(m, now != null && now.id.equals(e.id) ? "Gardando " + DownloadService.percent + "%" : "Na cola");
} else if (Downloads.has(act, e.id)) { } else if (Downloads.has(act, e.id)) {
m = Util.join(m, "Saved"); m = Util.join(m, "Gardado");
} }
PlayerService ps = PlayerService.get(); PlayerService ps = PlayerService.get();
if (ps != null && ps.episode() != null && ps.episode().id.equals(e.id) && ps.isPlaying()) { if (ps != null && ps.episode() != null && ps.episode().id.equals(e.id) && ps.isPlaying()) {
m = Util.join("Now playing", m); m = Util.join("En reprodución", m);
} }
meta.setText(m); meta.setText(m);
+31 -11
View File
@@ -17,14 +17,14 @@ public class SettingsActivity extends PreferenceActivity {
Preference.OnPreferenceChangeListener skipSummary = new Preference.OnPreferenceChangeListener() { Preference.OnPreferenceChangeListener skipSummary = new Preference.OnPreferenceChangeListener() {
public boolean onPreferenceChange(Preference pref, Object value) { public boolean onPreferenceChange(Preference pref, Object value) {
pref.setSummary(value + " seconds"); pref.setSummary(value + " segundos");
return true; return true;
} }
}; };
ListPreference back = (ListPreference) findPreference("skip_back"); ListPreference back = (ListPreference) findPreference("skip_back");
ListPreference fwd = (ListPreference) findPreference("skip_fwd"); ListPreference fwd = (ListPreference) findPreference("skip_fwd");
back.setSummary(p.skipBack() + " seconds"); back.setSummary(p.skipBack() + " segundos");
fwd.setSummary(p.skipFwd() + " seconds"); fwd.setSummary(p.skipFwd() + " segundos");
back.setOnPreferenceChangeListener(skipSummary); back.setOnPreferenceChangeListener(skipSummary);
fwd.setOnPreferenceChangeListener(skipSummary); fwd.setOnPreferenceChangeListener(skipSummary);
@@ -35,22 +35,42 @@ public class SettingsActivity extends PreferenceActivity {
} }
}); });
String who = p.username().length() > 0 ? p.username() : "API key"; String who = p.username().length() > 0 ? p.username() : "Chave da API";
findPreference("account").setSummary(who + " @ " + p.server()); findPreference("account").setSummary(who + " @ " + p.server());
findPreference("delete_all").setOnPreferenceClickListener(new Preference.OnPreferenceClickListener() { findPreference("delete_all").setOnPreferenceClickListener(new Preference.OnPreferenceClickListener() {
public boolean onPreferenceClick(Preference pref) { public boolean onPreferenceClick(Preference pref) {
new AlertDialog.Builder(SettingsActivity.this) new AlertDialog.Builder(SettingsActivity.this)
.setMessage("Delete every downloaded episode from the SD card?") .setMessage("Eliminar todos os episodios descargados da tarxeta SD?")
.setPositiveButton("Delete", new DialogInterface.OnClickListener() { .setPositiveButton("Eliminar", new DialogInterface.OnClickListener() {
public void onClick(DialogInterface d, int w) { public void onClick(DialogInterface d, int w) {
PlayerService ps = PlayerService.get(); PlayerService ps = PlayerService.get();
if (ps != null && ps.isLocal()) ps.stop(); if (ps != null && ps.isLocal()) ps.stop();
int n = Downloads.deleteAll(SettingsActivity.this); int n = Downloads.deleteAll(SettingsActivity.this);
Toast.makeText(SettingsActivity.this, n + " deleted", Toast.LENGTH_SHORT).show(); Toast.makeText(SettingsActivity.this, n + " eliminados", Toast.LENGTH_SHORT).show();
} }
}) })
.setNegativeButton("Cancel", null).show(); .setNegativeButton("Cancelar", null).show();
return true;
}
});
final Preference pins = findPreference("pins");
int n = Trust.pinned(this).size();
pins.setSummary(n == 0 ? "Ningún" : n + " (toca para esquecelos)");
pins.setEnabled(n > 0);
pins.setOnPreferenceClickListener(new Preference.OnPreferenceClickListener() {
public boolean onPreferenceClick(Preference pref) {
new AlertDialog.Builder(SettingsActivity.this)
.setMessage("Esquecer os certificados nos que decidiches confiar? Preguntarase de novo a próxima vez.")
.setPositiveButton("Esquecer", new DialogInterface.OnClickListener() {
public void onClick(DialogInterface d, int w) {
Trust.forgetPins(SettingsActivity.this);
pins.setSummary("Ningún");
pins.setEnabled(false);
}
})
.setNegativeButton("Cancelar", null).show();
return true; return true;
} }
}); });
@@ -58,8 +78,8 @@ public class SettingsActivity extends PreferenceActivity {
findPreference("logout").setOnPreferenceClickListener(new Preference.OnPreferenceClickListener() { findPreference("logout").setOnPreferenceClickListener(new Preference.OnPreferenceClickListener() {
public boolean onPreferenceClick(Preference pref) { public boolean onPreferenceClick(Preference pref) {
new AlertDialog.Builder(SettingsActivity.this) new AlertDialog.Builder(SettingsActivity.this)
.setMessage("Log out of " + p.server() + "? Downloads stay on the SD card.") .setMessage("Pechar sesión en " + p.server() + "? As descargas quedan na tarxeta SD.")
.setPositiveButton("Log out", new DialogInterface.OnClickListener() { .setPositiveButton("Pechar sesión", new DialogInterface.OnClickListener() {
public void onClick(DialogInterface d, int w) { public void onClick(DialogInterface d, int w) {
PlayerService ps = PlayerService.get(); PlayerService ps = PlayerService.get();
if (ps != null) ps.stop(); if (ps != null) ps.stop();
@@ -69,7 +89,7 @@ public class SettingsActivity extends PreferenceActivity {
finish(); // Home sees the logout on resume and opens the login screen finish(); // Home sees the logout on resume and opens the login screen
} }
}) })
.setNegativeButton("Cancel", null).show(); .setNegativeButton("Cancelar", null).show();
return true; return true;
} }
}); });
+202
View File
@@ -0,0 +1,202 @@
package com.miniabs;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.net.InetAddress;
import java.net.ServerSocket;
import java.net.Socket;
import java.security.SecureRandom;
import java.util.LinkedHashMap;
import java.util.Locale;
import java.util.Map;
import android.content.Context;
/**
* Lets Android's media player stream from an HTTPS server. The player can't do HTTPS before
* Android 3.1 and wouldn't use MiniABS's certificate trust anyway, so it plays
* http://127.0.0.1:PORT/<random id> and this proxy fetches the real stream (forwarding seeks as
* Range requests) with a fresh sign-in token each time.
*/
final class StreamProxy implements Runnable {
private static StreamProxy instance;
private final Context c;
private final ServerSocket server;
private final LinkedHashMap<String, String> routes = new LinkedHashMap<String, String>() { // id -> content URL
@Override
protected boolean removeEldestEntry(Map.Entry<String, String> eldest) {
return size() > 20;
}
};
private final SecureRandom random = new SecureRandom();
private volatile boolean running = true;
private StreamProxy(Context c) throws IOException {
this.c = c.getApplicationContext();
server = new ServerSocket(0, 8, InetAddress.getByName("127.0.0.1"));
Thread t = new Thread(this, "MiniABS proxy");
t.setDaemon(true);
t.start();
}
static synchronized StreamProxy get(Context c) throws IOException {
if (instance == null || !instance.running) instance = new StreamProxy(c);
return instance;
}
static synchronized void shutdown() {
if (instance != null) {
instance.running = false;
try {
instance.server.close();
} catch (IOException ignored) {
}
instance = null;
}
}
/** True when streams need to go through the proxy (an https:// server). */
static boolean needed(Context c) {
return Prefs.get(c).server().toLowerCase(Locale.US).startsWith("https://");
}
/** Returns a local URL the media player can open for this server-relative content URL. */
synchronized String register(String contentUrl) {
byte[] b = new byte[12];
random.nextBytes(b);
StringBuilder id = new StringBuilder();
for (byte x : b) id.append(Integer.toHexString((x & 0xff) | 0x100).substring(1));
routes.put(id.toString(), contentUrl);
// keep an extension so the player can guess the format
String path = contentUrl.split("\\?")[0];
int dot = path.lastIndexOf('.');
String ext = dot > path.lastIndexOf('/') ? path.substring(dot) : "";
return "http://127.0.0.1:" + server.getLocalPort() + "/" + id + ext;
}
private synchronized String route(String id) {
return routes.get(id);
}
public void run() {
while (running) {
try {
final Socket s = server.accept();
Thread t = new Thread(new Runnable() {
public void run() {
serve(s);
}
}, "MiniABS proxy conn");
t.setDaemon(true);
t.start();
} catch (IOException e) {
if (!running) return;
}
}
}
private void serve(Socket s) {
HttpURLConnection up = null;
try {
s.setSoTimeout(30000);
InputStream in = s.getInputStream();
OutputStream out = s.getOutputStream();
String request = readLine(in);
String range = null;
String line;
while ((line = readLine(in)) != null && line.length() > 0) {
int colon = line.indexOf(':');
if (colon > 0 && line.substring(0, colon).trim().equalsIgnoreCase("Range")) {
range = line.substring(colon + 1).trim();
}
}
String[] parts = request == null ? new String[0] : request.split(" ");
boolean head = parts.length > 0 && parts[0].equals("HEAD");
String id = parts.length > 1 ? parts[1].replaceFirst("^/", "").replaceFirst("\\..*$", "") : "";
String target = route(id);
if (target == null) {
respond(out, "404 Not Found");
return;
}
int code = 0;
for (int attempt = 0; attempt < 2; attempt++) {
Api.ensureFresh(c, 120);
up = Api.open(c, Api.withToken(c, Api.absolute(c, target)), head ? "HEAD" : "GET");
up.setRequestProperty("Accept-Encoding", "identity");
if (range != null) up.setRequestProperty("Range", range);
code = Api.responseCode(up);
if (code == 401 && attempt == 0 && Prefs.get(c).canRefresh()) {
up.disconnect();
Api.refresh(c, true);
continue;
}
break;
}
StringBuilder h = new StringBuilder();
h.append("HTTP/1.1 ").append(code).append(' ').append(reason(code)).append("\r\n");
copyHeader(up, h, "Content-Type");
copyHeader(up, h, "Content-Length");
copyHeader(up, h, "Content-Range");
copyHeader(up, h, "Accept-Ranges");
copyHeader(up, h, "Last-Modified");
h.append("Connection: close\r\n\r\n");
out.write(h.toString().getBytes("ISO-8859-1"));
if (!head && code < 400) {
InputStream body = up.getInputStream();
try {
byte[] buf = new byte[16384];
int n;
while (running && (n = body.read(buf)) > 0) out.write(buf, 0, n);
} finally {
body.close();
}
}
out.flush();
} catch (Exception e) {
// the player closed the connection (seek or stop), or the server went away
} finally {
if (up != null) up.disconnect();
try {
s.close();
} catch (IOException ignored) {
}
}
}
private static void copyHeader(HttpURLConnection up, StringBuilder h, String name) {
String v = up.getHeaderField(name);
if (v != null) h.append(name).append(": ").append(v).append("\r\n");
}
private static void respond(OutputStream out, String status) throws IOException {
out.write(("HTTP/1.1 " + status + "\r\nContent-Length: 0\r\nConnection: close\r\n\r\n").getBytes("ISO-8859-1"));
out.flush();
}
private static String reason(int code) {
switch (code) {
case 200: return "OK";
case 206: return "Partial Content";
case 401: return "Unauthorized";
case 403: return "Forbidden";
case 404: return "Not Found";
case 416: return "Range Not Satisfiable";
default: return "Status";
}
}
private static String readLine(InputStream in) throws IOException {
StringBuilder sb = new StringBuilder();
int ch;
while ((ch = in.read()) != -1) {
if (ch == '\n') break;
if (ch != '\r' && sb.length() < 8192) sb.append((char) ch);
}
return ch == -1 && sb.length() == 0 ? null : sb.toString();
}
}
+340
View File
@@ -0,0 +1,340 @@
package com.miniabs;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.security.KeyStore;
import java.security.MessageDigest;
import java.security.cert.CertificateException;
import java.security.cert.CertificateFactory;
import java.security.cert.X509Certificate;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.List;
import java.util.Locale;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import javax.net.ssl.HttpsURLConnection;
import javax.net.ssl.SSLContext;
import javax.net.ssl.SSLSocketFactory;
import javax.net.ssl.TrustManager;
import javax.net.ssl.TrustManagerFactory;
import javax.net.ssl.X509TrustManager;
import android.content.Context;
import android.util.Base64;
/**
* Decides which HTTPS certificates MiniABS accepts:
* 1. anything the phone itself trusts;
* 2. certificates chaining to the modern roots in {@link Roots} (Android 2.3's list predates Let's Encrypt);
* 3. certificates the user chose to trust after checking the fingerprint ("pins").
*/
final class Trust {
private Trust() {
}
/** The last certificate chain that wasn't trusted, so the UI can offer to trust it. */
static final class Rejected {
final X509Certificate[] chain;
final String reason;
final long at;
Rejected(X509Certificate[] chain, String reason) {
this.chain = chain;
this.reason = reason;
this.at = System.currentTimeMillis();
}
boolean expired() {
return reason != null && reason.startsWith("expired");
}
}
static volatile Rejected lastRejected;
private static SSLSocketFactory factory;
private static List<X509Certificate> bundled;
// ------------------------------------------------------------------ setup
static synchronized SSLSocketFactory factory(Context c) throws IOException {
if (factory == null) {
try {
SSLContext ctx = SSLContext.getInstance("TLS");
ctx.init(null, new TrustManager[]{new Manager(c.getApplicationContext())}, null);
factory = ctx.getSocketFactory();
} catch (Exception e) {
throw new IOException("As conexións seguras non están dispoñibles: " + e.getMessage());
}
}
return factory;
}
static void apply(Context c, HttpsURLConnection conn) throws IOException {
conn.setSSLSocketFactory(factory(c));
}
/** Recently rejected (this attempt), or null. */
static Rejected recentRejection() {
Rejected r = lastRejected;
return r != null && System.currentTimeMillis() - r.at < 60000 ? r : null;
}
private static synchronized List<X509Certificate> bundled() {
if (bundled == null) {
List<X509Certificate> list = new ArrayList<X509Certificate>();
for (String b64 : Roots.DER_BASE64) {
try {
list.add(parse(Base64.decode(b64, Base64.DEFAULT)));
} catch (Exception ignored) {
// a root this phone can't parse; skip it
}
}
bundled = list;
}
return bundled;
}
static X509Certificate parse(byte[] der) throws CertificateException {
return (X509Certificate) CertificateFactory.getInstance("X.509")
.generateCertificate(new ByteArrayInputStream(der));
}
// ------------------------------------------------------------------ pins
static List<X509Certificate> pinned(Context c) {
List<X509Certificate> out = new ArrayList<X509Certificate>();
String s = Prefs.get(c).sp.getString("pins", "");
if (s.length() == 0) return out;
for (String b64 : s.split(",")) {
try {
out.add(parse(Base64.decode(b64, Base64.DEFAULT)));
} catch (Exception ignored) {
}
}
return out;
}
/** Trusts the top certificate the server sent (its issuing CA, or the certificate itself if self-signed). */
static synchronized X509Certificate pin(Context c, X509Certificate[] chain) {
X509Certificate top = top(chain);
try {
String b64 = Base64.encodeToString(top.getEncoded(), Base64.NO_WRAP);
String s = Prefs.get(c).sp.getString("pins", "");
if (!Arrays.asList(s.split(",")).contains(b64)) {
Prefs.get(c).sp.edit().putString("pins", s.length() == 0 ? b64 : s + "," + b64).commit();
}
} catch (Exception ignored) {
}
factory = null;
lastRejected = null;
return top;
}
static synchronized void forgetPins(Context c) {
Prefs.get(c).sp.edit().remove("pins").commit();
factory = null;
}
/** Walks up the chain as far as the server's own certificates go. */
static X509Certificate top(X509Certificate[] chain) {
X509Certificate cur = chain[0];
for (int step = 0; step < 8; step++) {
if (cur.getIssuerX500Principal().equals(cur.getSubjectX500Principal())) return cur; // self-signed
X509Certificate next = issuerIn(cur, chain);
if (next == null) return cur;
cur = next;
}
return cur;
}
// ------------------------------------------------------------------ verification
private static final class Manager implements X509TrustManager {
private final Context c;
private X509TrustManager system;
Manager(Context c) {
this.c = c;
try {
TrustManagerFactory f = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());
f.init((KeyStore) null);
for (TrustManager t : f.getTrustManagers()) {
if (t instanceof X509TrustManager) system = (X509TrustManager) t;
}
} catch (Exception ignored) {
// no system trust store: rely on bundled roots and pins
}
}
public void checkServerTrusted(X509Certificate[] chain, String authType) throws CertificateException {
if (chain == null || chain.length == 0) throw new CertificateException("No certificate");
if (system != null) {
try {
system.checkServerTrusted(chain, authType);
return;
} catch (CertificateException ignored) {
// fall through to our own roots and pins
} catch (RuntimeException ignored) {
}
}
List<X509Certificate> anchors = new ArrayList<X509Certificate>(bundled());
anchors.addAll(pinned(c));
String why = verify(chain, anchors);
if (why == null) return;
lastRejected = new Rejected(chain.clone(), why);
throw new CertificateException("Certificate not trusted: " + why);
}
public void checkClientTrusted(X509Certificate[] chain, String authType) throws CertificateException {
throw new CertificateException("Not used");
}
public X509Certificate[] getAcceptedIssuers() {
return new X509Certificate[0];
}
}
/**
* Returns null if chain[0] chains up to one of the anchors, or a short reason why not.
* Every certificate that signs another must be a CA, so a website's own certificate
* can't be used to forge one for a different site.
*/
static String verify(X509Certificate[] chain, List<X509Certificate> anchors) {
X509Certificate cur = chain[0];
try {
cur.checkValidity();
} catch (Exception e) {
return "expired or not yet valid (check the phone's date)";
}
for (int step = 0; step < 8; step++) {
for (X509Certificate a : anchors) {
if (Arrays.equals(encoded(a), encoded(cur))) return null; // trusted as-is
}
for (X509Certificate a : anchors) {
if (a.getSubjectX500Principal().equals(cur.getIssuerX500Principal()) && isCa(a) && signedBy(cur, a)) {
return null;
}
}
X509Certificate next = issuerIn(cur, chain);
if (next == null) return "unknown issuer";
if (!isCa(next)) return "signed by a certificate that isn't a CA";
try {
next.checkValidity();
} catch (Exception e) {
return "expired intermediate certificate (check the phone's date)";
}
cur = next;
}
return "chain too long";
}
private static X509Certificate issuerIn(X509Certificate cert, X509Certificate[] chain) {
for (X509Certificate x : chain) {
if (x == cert || Arrays.equals(encoded(x), encoded(cert))) continue;
if (x.getSubjectX500Principal().equals(cert.getIssuerX500Principal()) && signedBy(cert, x)) return x;
}
return null;
}
private static boolean isCa(X509Certificate x) {
if (x.getBasicConstraints() < 0) return false;
boolean[] ku = x.getKeyUsage();
return ku == null || ku.length <= 5 || ku[5]; // keyCertSign
}
private static boolean signedBy(X509Certificate cert, X509Certificate issuer) {
try {
cert.verify(issuer.getPublicKey());
return true;
} catch (Exception e) {
return false;
}
}
private static byte[] encoded(X509Certificate x) {
try {
return x.getEncoded();
} catch (Exception e) {
return new byte[0];
}
}
/** Standard host name check: DNS/IP subject alternative names (one-label wildcards), else the CN. */
static boolean hostMatches(X509Certificate cert, String host) {
host = host.toLowerCase(Locale.US);
boolean hasDns = false;
try {
Collection<List<?>> sans = cert.getSubjectAlternativeNames();
if (sans != null) {
for (List<?> e : sans) {
int type = ((Integer) e.get(0)).intValue();
String v = String.valueOf(e.get(1)).toLowerCase(Locale.US);
if (type == 2) {
hasDns = true;
if (nameMatches(v, host)) return true;
} else if (type == 7 && v.equals(host)) {
return true;
}
}
}
} catch (Exception ignored) {
}
if (hasDns) return false;
return nameMatches(commonName(cert.getSubjectX500Principal()).toLowerCase(Locale.US), host);
}
private static boolean nameMatches(String pattern, String host) {
if (pattern.startsWith("*.")) {
String suffix = pattern.substring(1); // ".example.com"
if (host.length() <= suffix.length() || !host.endsWith(suffix)) return false;
return host.substring(0, host.length() - suffix.length()).indexOf('.') < 0;
}
return pattern.equals(host);
}
// ------------------------------------------------------------------ display
static String fingerprint(X509Certificate x) {
try {
byte[] d = MessageDigest.getInstance("SHA-256").digest(x.getEncoded());
StringBuilder sb = new StringBuilder();
for (int i = 0; i < d.length; i++) {
if (i > 0) sb.append(i == 16 ? "\n" : ":");
int v = d[i] & 0xff;
sb.append("0123456789ABCDEF".charAt(v >> 4)).append("0123456789ABCDEF".charAt(v & 15));
}
return sb.toString();
} catch (Exception e) {
return "?";
}
}
static String commonName(javax.security.auth.x500.X500Principal p) {
String n = p.getName();
Matcher m = Pattern.compile("(?:^|,)CN=([^,]+)").matcher(n);
if (m.find()) return m.group(1);
m = Pattern.compile("(?:^|,)O=([^,]+)").matcher(n);
return m.find() ? m.group(1) : n;
}
/** Text for the "trust this server?" dialog. */
static String describe(Rejected r, String host) {
X509Certificate leaf = r.chain[0];
X509Certificate top = top(r.chain);
StringBuilder sb = new StringBuilder();
sb.append("Este teléfono non recoñece o certificado de ").append(host).append(".\n\n");
sb.append("Emitido para: ").append(commonName(leaf.getSubjectX500Principal())).append('\n');
sb.append("Emitido por: ").append(commonName(leaf.getIssuerX500Principal())).append('\n');
sb.append("Válido ata: ").append(Util.date(leaf.getNotAfter().getTime(), true)).append("\n\n");
sb.append("Pegada dixital SHA-256:\n").append(fingerprint(leaf)).append("\n\n");
sb.append("Continúa só se coincide coa pegada que amosa o navegador do teu ordenador para este sitio.");
if (top != leaf) {
sb.append("\n\nMiniABS confiará desde agora nos certificados emitidos por «")
.append(commonName(top.getSubjectX500Principal())).append("».");
}
return sb.toString();
}
}
+50
View File
@@ -0,0 +1,50 @@
package com.miniabs;
import java.net.URL;
import android.app.Activity;
import android.app.AlertDialog;
import android.content.DialogInterface;
/** Asks the user whether to trust a certificate the phone didn't recognise. */
final class TrustPrompt {
private TrustPrompt() {
}
private static boolean showing;
/**
* If the last connection failed on an unrecognised certificate, shows its details and offers to trust it.
* Returns true if the prompt was shown.
*/
static boolean offer(final Activity a, final Runnable retry) {
final Trust.Rejected r = Trust.recentRejection();
if (r == null || r.expired() || showing || a.isFinishing()) return false;
String host;
try {
host = new URL(Prefs.get(a).server()).getHost();
} catch (Exception e) {
host = "o servidor";
}
showing = true;
new AlertDialog.Builder(a)
.setTitle("Comproba este certificado")
.setMessage(Trust.describe(r, host))
.setPositiveButton("Confiar", new DialogInterface.OnClickListener() {
public void onClick(DialogInterface d, int w) {
Trust.pin(a, r.chain);
if (retry != null) retry.run();
}
})
.setNegativeButton("Cancelar", null)
.setOnCancelListener(null)
.show()
.setOnDismissListener(new DialogInterface.OnDismissListener() {
public void onDismiss(DialogInterface d) {
showing = false;
Trust.lastRejected = null;
}
});
return true;
}
}
+31 -15
View File
@@ -5,10 +5,7 @@ import java.net.ConnectException;
import java.net.SocketException; import java.net.SocketException;
import java.net.SocketTimeoutException; import java.net.SocketTimeoutException;
import java.net.UnknownHostException; import java.net.UnknownHostException;
import java.text.SimpleDateFormat;
import java.util.Calendar; import java.util.Calendar;
import java.util.Date;
import java.util.Locale;
import javax.net.ssl.SSLException; import javax.net.ssl.SSLException;
@@ -47,12 +44,24 @@ final class Util {
return m + ":" + ss; return m + ":" + ss;
} }
/** Galician month abbreviations (Android 2.3 has no Galician date data of its own). */
private static final String[] MONTHS = {"xan", "feb", "mar", "abr", "mai", "xuñ", "xul", "ago", "set", "out",
"nov", "dec"};
/** "14 out", or "14 out 2025" for other years. */
static String date(long ms) { static String date(long ms) {
if (ms <= 0) return ""; if (ms <= 0) return "";
Calendar now = Calendar.getInstance(), c = Calendar.getInstance(); Calendar now = Calendar.getInstance(), c = Calendar.getInstance();
c.setTimeInMillis(ms); c.setTimeInMillis(ms);
String pattern = c.get(Calendar.YEAR) == now.get(Calendar.YEAR) ? "d MMM" : "d MMM yyyy"; return date(ms, c.get(Calendar.YEAR) != now.get(Calendar.YEAR));
return new SimpleDateFormat(pattern, Locale.getDefault()).format(new Date(ms)); }
static String date(long ms, boolean withYear) {
if (ms <= 0) return "";
Calendar c = Calendar.getInstance();
c.setTimeInMillis(ms);
String d = c.get(Calendar.DAY_OF_MONTH) + " " + MONTHS[c.get(Calendar.MONTH)];
return withYear ? d + " " + c.get(Calendar.YEAR) : d;
} }
static String plainText(String html) { static String plainText(String html) {
@@ -87,21 +96,28 @@ final class Util {
if (e instanceof Api.AuthException) return e.getMessage(); if (e instanceof Api.AuthException) return e.getMessage();
if (e instanceof Api.ApiException) { if (e instanceof Api.ApiException) {
Api.ApiException a = (Api.ApiException) e; Api.ApiException a = (Api.ApiException) e;
if (a.code == 401) return "Not signed in. Please log in again."; if (a.code == 401) return "Non iniciaches sesión. Inicia sesión de novo.";
if (a.code == 403) return "Your account isn't allowed to do that."; if (a.code == 403) return "A túa conta non ten permiso para facer iso.";
if (a.code == 404) return "Not found on the server."; if (a.code == 404) return "Non se atopou no servidor.";
String m = a.getMessage(); String m = a.getMessage();
return "Server error " + a.code + (m != null && m.length() > 0 ? ": " + m : ""); return "Erro do servidor " + a.code + (m != null && m.length() > 0 ? ": " + m : "");
} }
if (e instanceof UnknownHostException) return "Server not found. Check the address and your connection."; if (e instanceof UnknownHostException) return "Non se atopou o servidor. Comproba o enderezo e a conexión.";
if (e instanceof SocketTimeoutException) return "The server took too long to answer."; if (e instanceof SocketTimeoutException) return "O servidor tardou demasiado en responder.";
if (e instanceof ConnectException) return "Can't connect to the server. Is it running and reachable?"; if (e instanceof ConnectException) return "Non se pode conectar co servidor. Está en marcha e accesible?";
if (e instanceof SSLException) { if (e instanceof SSLException) {
return "Secure (HTTPS) connection failed. Android 2.x can't use modern HTTPS: try your server's local http:// address."; Trust.Rejected r = Trust.recentRejection();
if (r != null) {
return r.expired() ? "O certificado do servidor caducou ou a data do teléfono é incorrecta."
: "Este teléfono non recoñece o certificado do servidor.";
}
return "Non se puido establecer unha conexión segura. Android 2.3 só admite TLS 1.0, así que o servidor "
+ "precisa un porto HTTPS antigo (ve o README). Na rede da casa tamén funciona o enderezo http://.";
} }
if (e instanceof SocketException) return "Connection dropped. Check your network."; if (e instanceof SocketException) return "Cortouse a conexión. Comproba a rede.";
if (e instanceof FileNotFoundException) return "Not found on the server."; if (e instanceof FileNotFoundException) return "Non se atopou no servidor.";
String m = e.getMessage(); String m = e.getMessage();
if (m != null && m.contains("not verified")) return "O certificado do servidor é doutro enderezo.";
return m == null || m.length() == 0 ? e.getClass().getSimpleName() : m; return m == null || m.length() == 0 ? e.getClass().getSimpleName() : m;
} }
} }
+3 -3
View File
@@ -48,14 +48,14 @@ public class ApiTest {
check(cut.getString("d").equals("0123456789…"), "long strings truncated with an ellipsis"); check(cut.getString("d").equals("0123456789…"), "long strings truncated with an ellipsis");
System.out.println("Sign-in"); System.out.println("Sign-in");
p.setServer(LoginActivity_normalize(base + "/login/"), false); p.setServer(LoginActivity_normalize(base + "/login/"));
check(p.server().equals(base), "address normalised (" + p.server() + ")"); check(p.server().equals(base), "address normalised (" + p.server() + ")");
check(Api.status(c, p.server()).equals("2.30.0"), "status check finds the server"); check(Api.status(c, p.server()).equals("2.30.0"), "status check finds the server");
try { try {
Api.login(c, p.server(), "demo", "wrong"); Api.login(c, p.server(), "demo", "wrong");
check(false, "wrong password rejected"); check(false, "wrong password rejected");
} catch (Api.ApiException e) { } catch (Api.ApiException e) {
check(e.code == 401 && e.getMessage().contains("Wrong"), "wrong password rejected with a clear message"); check(e.code == 401 && e.getMessage().contains("contrasinal incorrectos"), "wrong password rejected with a clear message");
} }
String lib = Api.login(c, p.server(), "demo", "pw"); String lib = Api.login(c, p.server(), "demo", "pw");
check(lib.equals("lib-pod"), "login returns default library"); check(lib.equals("lib-pod"), "login returns default library");
@@ -193,7 +193,7 @@ public class ApiTest {
} }
System.out.println("Sub-path servers"); System.out.println("Sub-path servers");
p.setServer("http://nas.local:8080/abs", false); p.setServer("http://nas.local:8080/abs");
check(Api.absolute(c, "/abs/api/items/x/file/1").equals("http://nas.local:8080/abs/api/items/x/file/1"), check(Api.absolute(c, "/abs/api/items/x/file/1").equals("http://nas.local:8080/abs/api/items/x/file/1"),
"content URL under a sub-path not doubled"); "content URL under a sub-path not doubled");
check(Api.absolute(c, "/api/items/x/file/1").equals("http://nas.local:8080/abs/api/items/x/file/1"), check(Api.absolute(c, "/api/items/x/file/1").equals("http://nas.local:8080/abs/api/items/x/file/1"),
+172
View File
@@ -0,0 +1,172 @@
package com.miniabs;
import java.io.ByteArrayOutputStream;
import java.io.FileInputStream;
import java.io.InputStream;
import java.net.HttpURLConnection;
import java.net.URL;
import java.security.MessageDigest;
import java.security.cert.CertificateFactory;
import java.security.cert.X509Certificate;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import org.json.JSONArray;
import org.json.JSONObject;
import android.content.Context;
/** HTTPS: certificate trust, pinning, attacks, raw PATCH and the streaming proxy, over real TLS. */
public class TrustTest {
static int pass, fail;
static Context c = new Context();
static Prefs p = Prefs.get(c);
static void check(boolean ok, String what) {
if (ok) pass++;
else fail++;
System.out.println((ok ? " PASS " : " FAIL ") + what);
}
static X509Certificate cert(String path) throws Exception {
return (X509Certificate) CertificateFactory.getInstance("X.509").generateCertificate(new FileInputStream(path));
}
/** Tries /status on a server; returns the exception, or null on success. */
static Exception tryStatus(int port) {
Trust.lastRejected = null;
try {
Api.status(c, "https://localhost:" + port);
return null;
} catch (Exception e) {
return e;
}
}
static byte[] get(String url, String range, int[] code, String[] contentRange) throws Exception {
HttpURLConnection h = (HttpURLConnection) new URL(url).openConnection();
if (range != null) h.setRequestProperty("Range", range);
code[0] = h.getResponseCode();
if (contentRange != null) contentRange[0] = h.getHeaderField("Content-Range");
if (code[0] >= 400) return new byte[0];
InputStream in = h.getInputStream();
ByteArrayOutputStream out = new ByteArrayOutputStream();
byte[] b = new byte[8192];
int n;
while ((n = in.read(b)) > 0) out.write(b, 0, n);
in.close();
return out.toByteArray();
}
static byte[] audio(int from, int to) {
byte[] b = new byte[to - from + 1];
for (int i = from; i <= to; i++) b[i - from] = (byte) (i % 251);
return b;
}
public static void main(String[] a) throws Exception {
String pki = a[0];
int good = Integer.parseInt(a[1]), wrongHost = Integer.parseInt(a[2]), forged = Integer.parseInt(a[3]),
expired = Integer.parseInt(a[4]), self = Integer.parseInt(a[5]);
System.out.println("Bundled roots");
List<X509Certificate> roots = new ArrayList<X509Certificate>();
for (String r : Roots.DER_BASE64) roots.add(Trust.parse(android.util.Base64.decode(r, 0)));
check(roots.size() == 12, "12 modern root certificates bundled");
String isrg = Trust.fingerprint(roots.get(0)).replace("\n", ":");
check(isrg.equals("96:BC:EC:06:26:49:76:F3:74:60:77:9A:CF:28:C5:A7:CF:E8:A3:C0:AA:E1:1A:8F:FC:EE:05:C0:BD:DF:08:C6"),
"Let's Encrypt ISRG Root X1 matches its published fingerprint");
X509Certificate root = cert(pki + "/root.crt"), inter = cert(pki + "/int.crt"), leaf = cert(pki + "/leaf.crt");
check(Trust.verify(new X509Certificate[]{leaf, inter}, Arrays.asList(root)) == null,
"leaf + intermediate validate up to a bundled-style root");
check("unknown issuer".equals(Trust.verify(new X509Certificate[]{leaf}, Arrays.asList(root))),
"missing intermediate is rejected");
System.out.println("Unknown certificate, then trusting it");
p.setServer("https://localhost:" + good);
Exception e = tryStatus(good);
Trust.Rejected r = Trust.recentRejection();
check(e instanceof javax.net.ssl.SSLException && r != null, "unrecognised certificate rejected and remembered");
check(Util.describe(e).equals("Este teléfono non recoñece o certificado do servidor."),
"error message: \"" + Util.describe(e) + "\"");
String text = Trust.describe(r, "localhost");
byte[] d = MessageDigest.getInstance("SHA-256").digest(leaf.getEncoded());
String fp = String.format("%02X:%02X:%02X", d[0], d[1], d[2]);
check(text.contains(fp) && text.contains("Emitido por: Test Intermediate R1")
&& text.contains("certificados emitidos por «Test Intermediate R1»"),
"prompt shows the server's fingerprint and what will be trusted");
X509Certificate pinned = Trust.pin(c, r.chain);
check(pinned.equals(inter), "trusting pins the issuing CA, so renewals keep working");
check(tryStatus(good) == null, "server reachable after trusting");
System.out.println("Signed in over HTTPS");
Api.login(c, p.server(), "demo", "pw");
check(Api.libraries(c).length() == 2, "API calls work");
Api.setProgress(c, "pod1", "ep7", 100, 1800, Boolean.TRUE);
JSONArray log = (JSONArray) Api.call(c, "GET", "/__log", null);
boolean patched = false;
for (int i = 0; i < log.length(); i++) {
JSONObject l = log.getJSONObject(i);
if (l.getString("m").equals("PATCH") && l.getString("p").equals("/api/me/progress/pod1/ep7")) patched = true;
}
check(patched, "mark-as-played PATCH works over TLS (raw socket path)");
System.out.println("Streaming proxy");
check(StreamProxy.needed(c), "https server streams through the proxy");
String local = StreamProxy.get(c).register("/api/items/pod1/file/ino7");
check(local.startsWith("http://127.0.0.1:"), "player gets a local http:// URL: " + local.replaceAll("/[0-9a-f]{24}", "/<id>"));
int[] code = new int[1];
String[] cr = new String[1];
byte[] whole = get(local, null, code, null);
check(code[0] == 200 && Arrays.equals(whole, audio(0, 199999)), "whole file streamed correctly (200 KB)");
byte[] part = get(local, "bytes=150000-150999", code, cr);
check(code[0] == 206 && "bytes 150000-150999/200000".equals(cr[0]) && Arrays.equals(part, audio(150000, 150999)),
"seek: Range request forwarded, 206 + Content-Range passed back");
byte[] tail = get(local, "bytes=199000-", code, cr);
check(code[0] == 206 && Arrays.equals(tail, audio(199000, 199999)), "open-ended range works");
int before = ((JSONObject) Api.call(c, "GET", "/__refreshes", null)).getInt("n");
Api.call(c, "GET", "/__expire", null);
byte[] again = get(local, "bytes=0-99", code, cr);
int after = ((JSONObject) Api.call(c, "GET", "/__refreshes", null)).getInt("n");
check(code[0] == 206 && Arrays.equals(again, audio(0, 99)) && after == before + 1,
"token expired mid-episode: proxy refreshes it and the stream continues");
get(local.replaceAll("/[0-9a-f]{24}", "/000000000000000000000000"), null, code, null);
check(code[0] == 404, "unknown stream id refused (other apps can't use the proxy)");
System.out.println("Attacks and broken certificates");
Exception wh = tryStatus(wrongHost);
check(wh != null, "certificate for another host rejected, even from a trusted CA");
Exception fe = tryStatus(forged);
Trust.Rejected fr = Trust.recentRejection();
check(fe != null && fr != null && fr.reason.contains("isn't a CA"),
"forged certificate signed by a normal site certificate rejected (" + (fr == null ? "-" : fr.reason) + ")");
Trust.pin(c, fr.chain);
check(tryStatus(forged) != null, "...and still rejected even if the user taps Trust");
Exception ee = tryStatus(expired);
check(ee != null && Trust.recentRejection() != null && Trust.recentRejection().expired()
&& Util.describe(ee).contains("caducou"),
"expired certificate rejected: \"" + Util.describe(ee) + "\"");
System.out.println("Self-signed server");
Exception se = tryStatus(self);
Trust.Rejected sr = Trust.recentRejection();
check(se != null && sr != null, "self-signed certificate needs confirming");
X509Certificate selfPin = Trust.pin(c, sr.chain);
check(selfPin.equals(cert(pki + "/self.crt")) && tryStatus(self) == null, "after trusting, it connects");
System.out.println("Forgetting");
check(Trust.pinned(c).size() == 2, "two certificates trusted (" + Trust.pinned(c).size() + ")");
Trust.forgetPins(c);
check(tryStatus(good) != null && tryStatus(self) != null, "forgetting them brings the prompt back");
System.out.println("Host names");
check(Trust.hostMatches(leaf, "localhost") && Trust.hostMatches(leaf, "127.0.0.1")
&& !Trust.hostMatches(leaf, "evil.com"), "SAN DNS and IP matching");
StreamProxy.shutdown();
System.out.println();
System.out.println(pass + " passed, " + fail + " failed");
System.exit(fail == 0 ? 0 : 1);
}
}
+69
View File
@@ -0,0 +1,69 @@
#!/bin/sh
# Test certificates: a root CA, an intermediate CA, and server certificates, good and bad.
set -e
D=${1:-build/pki}
rm -rf "$D" && mkdir -p "$D" && cd "$D"
q() { openssl "$@" > /dev/null 2>err.log || { cat err.log; exit 1; }; }
cat > ca.ext <<'EOF'
basicConstraints=critical,CA:TRUE
keyUsage=critical,keyCertSign,cRLSign
EOF
cat > leaf.ext <<'EOF'
basicConstraints=CA:FALSE
keyUsage=critical,digitalSignature,keyEncipherment
extendedKeyUsage=serverAuth
subjectAltName=DNS:localhost,IP:127.0.0.1
EOF
cat > other.ext <<'EOF'
basicConstraints=CA:FALSE
keyUsage=critical,digitalSignature,keyEncipherment
extendedKeyUsage=serverAuth
subjectAltName=DNS:other.example
EOF
q req -x509 -newkey rsa:2048 -nodes -keyout root.key -out root.crt -days 3650 -subj "/CN=Test Root CA" -addext "basicConstraints=critical,CA:TRUE" -addext "keyUsage=critical,keyCertSign,cRLSign"
q req -newkey rsa:2048 -nodes -keyout int.key -out int.csr -subj "/CN=Test Intermediate R1"
q x509 -req -in int.csr -CA root.crt -CAkey root.key -CAcreateserial -out int.crt -days 1825 -extfile ca.ext
# good server certificate
q req -newkey rsa:2048 -nodes -keyout leaf.key -out leaf.csr -subj "/CN=localhost"
q x509 -req -in leaf.csr -CA int.crt -CAkey int.key -CAcreateserial -out leaf.crt -days 90 -extfile leaf.ext
cat leaf.crt int.crt > good.chain
# certificate for a different host, same CA
q req -newkey rsa:2048 -nodes -keyout other.key -out other.csr -subj "/CN=other.example"
q x509 -req -in other.csr -CA int.crt -CAkey int.key -CAcreateserial -out other.crt -days 90 -extfile other.ext
cat other.crt int.crt > wronghost.chain
# forgery: an attacker uses their own valid (non-CA) certificate to sign one for localhost
q req -newkey rsa:2048 -nodes -keyout forged.key -out forged.csr -subj "/CN=localhost"
q x509 -req -in forged.csr -CA other.crt -CAkey other.key -CAcreateserial -out forged.crt -days 90 -extfile leaf.ext
cat forged.crt other.crt int.crt > forged.chain
cp forged.key forged.chain.key
# expired certificate
q req -newkey rsa:2048 -nodes -keyout old.key -out old.csr -subj "/CN=localhost"
python3 - <<'PY'
import datetime
from cryptography import x509
from cryptography.hazmat.primitives import hashes, serialization
ca = x509.load_pem_x509_certificate(open("int.crt", "rb").read())
ca_key = serialization.load_pem_private_key(open("int.key", "rb").read(), None)
csr = x509.load_pem_x509_csr(open("old.csr", "rb").read())
cert = (x509.CertificateBuilder().subject_name(csr.subject).issuer_name(ca.subject)
.public_key(csr.public_key()).serial_number(x509.random_serial_number())
.not_valid_before(datetime.datetime(2020, 1, 1)).not_valid_after(datetime.datetime(2021, 1, 1))
.add_extension(x509.BasicConstraints(ca=False, path_length=None), critical=False)
.add_extension(x509.SubjectAlternativeName([x509.DNSName("localhost")]), critical=False)
.sign(ca_key, hashes.SHA256()))
open("old.crt", "wb").write(cert.public_bytes(serialization.Encoding.PEM))
PY
cat old.crt int.crt > expired.chain
# self-signed server
q req -x509 -newkey rsa:2048 -nodes -keyout self.key -out self.crt -days 365 -subj "/CN=localhost" -addext "subjectAltName=DNS:localhost,IP:127.0.0.1"
cp self.crt self.chain
cp leaf.key good.chain.key; cp other.key wronghost.chain.key; cp old.key expired.chain.key; cp self.key self.chain.key
ls *.chain
+34 -1
View File
@@ -14,6 +14,8 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
PORT = int(sys.argv[1]) PORT = int(sys.argv[1])
LEGACY = len(sys.argv) > 2 and sys.argv[2] == "legacy" LEGACY = len(sys.argv) > 2 and sys.argv[2] == "legacy"
CHAIN = sys.argv[3] if len(sys.argv) > 3 else None # serve HTTPS with this certificate chain
AUDIO = bytes(i % 251 for i in range(200000))
lock = threading.Lock() lock = threading.Lock()
state = {"access": None, "refresh": None, "n": 0, "log": [], "progress": {}, "refreshes": 0} state = {"access": None, "refresh": None, "n": 0, "log": [], "progress": {}, "refreshes": 0}
@@ -159,6 +161,26 @@ class H(BaseHTTPRequestHandler):
"currentTime": 600.0, "duration": 1807.5, "playMethod": 0, "currentTime": 600.0, "duration": 1807.5, "playMethod": 0,
"audioTracks": [{"index": 1, "contentUrl": "/api/items/pod1/file/ino7", "audioTracks": [{"index": 1, "contentUrl": "/api/items/pod1/file/ino7",
"mimeType": "audio/mpeg", "duration": 1807.5}]}) "mimeType": "audio/mpeg", "duration": 1807.5}]})
if path == "/api/items/pod1/file/ino7":
rng = self.headers.get("Range")
if rng and rng.startswith("bytes="):
a, _, b = rng[6:].partition("-")
a = int(a)
b = int(b) if b else len(AUDIO) - 1
part = AUDIO[a:b + 1]
self.send_response(206)
self.send_header("Content-Range", "bytes %d-%d/%d" % (a, a + len(part) - 1, len(AUDIO)))
else:
part = AUDIO
self.send_response(200)
self.send_header("Content-Type", "audio/mpeg")
self.send_header("Accept-Ranges", "bytes")
self.send_header("Content-Length", str(len(part)))
self.send_header("Connection", "close")
self.end_headers()
if method != "HEAD":
self.wfile.write(part)
return
if path.startswith("/api/session/") and method == "POST": if path.startswith("/api/session/") and method == "POST":
return self.send(200, "OK", "text/plain") return self.send(200, "OK", "text/plain")
if path.startswith("/api/me/progress/") and method == "PATCH": if path.startswith("/api/me/progress/") and method == "PATCH":
@@ -172,8 +194,19 @@ class H(BaseHTTPRequestHandler):
def do_POST(self): def do_POST(self):
self.handle_any("POST") self.handle_any("POST")
def do_HEAD(self):
self.handle_any("HEAD")
def do_PATCH(self): def do_PATCH(self):
self.handle_any("PATCH") self.handle_any("PATCH")
ThreadingHTTPServer(("127.0.0.1", PORT), H).serve_forever() srv = ThreadingHTTPServer(("127.0.0.1", PORT), H)
if CHAIN:
import ssl
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
ctx.load_cert_chain(CHAIN, CHAIN + ".key")
srv.socket = ctx.wrap_socket(srv.socket, server_side=True)
# a TLS handshake the client rejects shows up as an error here; that's expected in tests
srv.handle_error = lambda request, client_address: None
srv.serve_forever()
+24 -8
View File
@@ -1,5 +1,5 @@
#!/bin/sh #!/bin/sh
# Runs MiniABS's API/JSON/progress code on the desktop JVM against mock_abs.py. # Runs MiniABS's API/JSON/progress/HTTPS code on the desktop JVM against mock_abs.py.
set -e set -e
cd "$(dirname "$0")/.." cd "$(dirname "$0")/.."
OUT=build/test OUT=build/test
@@ -7,14 +7,30 @@ JSON_JAR=/usr/share/java/com.android.json.jar
rm -rf "$OUT" && mkdir -p "$OUT" rm -rf "$OUT" && mkdir -p "$OUT"
S=src/com/miniabs S=src/com/miniabs
javac -nowarn -encoding UTF-8 -cp "$JSON_JAR" -d "$OUT" $(find test/shim -name '*.java') \ javac -nowarn -encoding UTF-8 -cp "$JSON_JAR" -d "$OUT" $(find test/shim -name '*.java') \
$S/Json.java $S/Prefs.java $S/Episode.java $S/Util.java $S/Api.java $S/App.java test/com/miniabs/ApiTest.java $S/Json.java $S/Prefs.java $S/Episode.java $S/Util.java $S/Api.java $S/App.java \
$S/Trust.java $S/Roots.java $S/StreamProxy.java \
test/com/miniabs/ApiTest.java test/com/miniabs/TrustTest.java
CP="$OUT:$JSON_JAR"
PIDS=""
cleanup() { [ -n "$PIDS" ] && kill $PIDS 2>/dev/null || true; }
trap cleanup EXIT
port() { echo $((20000 + $(od -An -N2 -tu2 /dev/urandom | tr -d ' ') % 20000)); }
for mode in current legacy; do for mode in current legacy; do
PORT=$((18000 + $(od -An -N2 -tu2 /dev/urandom | tr -d ' ') % 1000)) P=$(port)
python3 test/mock_abs.py $PORT $mode & python3 test/mock_abs.py $P $mode & PIDS="$PIDS $!"
PID=$!
sleep 1 sleep 1
echo "=== $mode server" echo "=== $mode server (http)"
java -cp "$OUT:$JSON_JAR" com.miniabs.ApiTest $PORT $mode || { kill $PID; exit 1; } java -cp "$CP" com.miniabs.ApiTest $P $mode
kill $PID
done done
echo "=== https"
test/make_pki.sh build/pki > /dev/null
ARGS="build/pki"
for chain in good wronghost forged expired self; do
P=$(port)
python3 test/mock_abs.py $P current build/pki/$chain.chain & PIDS="$PIDS $!"
ARGS="$ARGS $P"
done
sleep 1
java -cp "$CP" com.miniabs.TrustTest $ARGS
+5 -2
View File
@@ -1,5 +1,8 @@
package android.util; package android.util;
public class Base64 { public class Base64 {
public static final int URL_SAFE = 8; public static final int DEFAULT = 0, NO_WRAP = 2, URL_SAFE = 8;
public static byte[] decode(String s, int flags) { return java.util.Base64.getUrlDecoder().decode(s); } public static byte[] decode(String s, int flags) {
return (flags & URL_SAFE) != 0 ? java.util.Base64.getUrlDecoder().decode(s) : java.util.Base64.getMimeDecoder().decode(s);
}
public static String encodeToString(byte[] b, int flags) { return java.util.Base64.getEncoder().encodeToString(b); }
} }
+52
View File
@@ -0,0 +1,52 @@
"""Generates src/com/miniabs/Roots.java: modern RSA root certificates that Android 2.3's
built-in list lacks (Let's Encrypt, Google Trust Services, Sectigo/ZeroSSL, ...).
Only RSA roots: Android 2.x's TLS 1.0 can't use ECDSA certificates anyway.
Run: python3 tools/gen_roots.py /etc/ssl/certs
"""
import base64
import hashlib
import os
import re
import sys
CERT_DIR = sys.argv[1] if len(sys.argv) > 1 else "/etc/ssl/certs"
NAMES = [
"ISRG_Root_X1", # Let's Encrypt
"GTS_Root_R1", # Google Trust Services (also Cloudflare)
"USERTrust_RSA_Certification_Authority", # Sectigo, ZeroSSL
"DigiCert_Global_Root_G2",
"Amazon_Root_CA_1",
"Microsoft_RSA_Root_Certificate_Authority_2017",
"SSL.com_Root_Certification_Authority_RSA",
"Buypass_Class_2_Root_CA",
"HARICA_TLS_RSA_Root_CA_2021",
"Certum_Trusted_Network_CA",
"Go_Daddy_Root_Certificate_Authority_-_G2",
"Starfield_Root_Certificate_Authority_-_G2",
]
out = []
for n in NAMES:
pem = open(os.path.join(CERT_DIR, n + ".pem")).read()
b64 = "".join(re.search(r"-----BEGIN CERTIFICATE-----(.*?)-----END CERTIFICATE-----", pem, re.S).group(1).split())
der = base64.b64decode(b64)
out.append((n, b64, hashlib.sha256(der).hexdigest()))
lines = [
"package com.miniabs;",
"",
"/** Generated by tools/gen_roots.py from the system CA store. Do not edit. */",
"final class Roots {",
" private Roots() {",
" }",
"",
" static final String[] DER_BASE64 = {",
]
for n, b64, sha in out:
lines.append(" // %s sha256=%s" % (n, sha))
chunks = [b64[i:i + 100] for i in range(0, len(b64), 100)]
lines.append(" " + "\n + ".join('"%s"' % c for c in chunks) + ",")
lines += [" };", "}", ""]
open(os.path.join(os.path.dirname(__file__), "..", "src", "com", "miniabs", "Roots.java"), "w").write("\n".join(lines))
print("%d roots written" % len(out))
+5 -5
View File
@@ -88,9 +88,9 @@ def refresh(px, folder):
for folder, k in DENS.items(): for folder, k in DENS.items():
launcher(round(48 * k), folder) launcher(round(48 * k), folder)
status(round(25 * k), folder) status(round(25 * k), folder)
play(round(40 * k), folder, "ic_play", DARK) play(round(32 * k), folder, "ic_play", DARK)
pause(round(40 * k), folder, "ic_pause", DARK) pause(round(32 * k), folder, "ic_pause", DARK)
play(round(28 * k), folder, "ic_play_small", AMBER) play(round(38 * k), folder, "ic_play_small", AMBER)
pause(round(28 * k), folder, "ic_pause_small", AMBER) pause(round(38 * k), folder, "ic_pause_small", AMBER)
refresh(round(24 * k), folder) refresh(round(30 * k), folder)
print("icons ok") print("icons ok")
+260
View File
@@ -0,0 +1,260 @@
"""One-off: translates MiniABS's interface strings to Galician in place.
Each (file, old, new) must match at least once, so nothing is silently missed."""
import os
import re
import sys
ROOT = os.path.join(os.path.dirname(__file__), "..")
S = "src/com/miniabs/"
R = [
# ---------------- layouts / xml
("res/layout/activity_login.xml", 'android:text="Audiobookshelf podcasts"', 'android:text="Podcasts de Audiobookshelf"'),
("res/layout/activity_login.xml", 'android:text="Server address"', 'android:text="Enderezo do servidor"'),
("res/layout/activity_login.xml", 'android:text="Username"', 'android:text="Usuario"'),
("res/layout/activity_login.xml", 'android:text="Password"', 'android:text="Contrasinal"'),
("res/layout/activity_login.xml", 'android:text="Use an API key instead"', 'android:text="Usar unha chave da API"'),
("res/layout/activity_login.xml", 'android:text="Connect"', 'android:text="Conectar"'),
("res/layout/activity_login.xml",
"android:text=\"At home, use your server's local http:// address. For https://, Android 2.3 needs the server to offer TLS 1.0 on a separate port (see README). If the certificate isn't recognised, you'll be shown its fingerprint to check.\"",
'android:text="Na casa, usa o enderezo local http:// do servidor. Para https://, Android 2.3 precisa que o servidor ofreza TLS 1.0 nun porto á parte (ve o README). Se o certificado non se recoñece, amosaráseche a súa pegada dixital para que a comprobes."'),
("res/layout/activity_home.xml", 'android:text="Latest"', 'android:text="Últimos"'),
("res/layout/activity_home.xml", 'android:text="Shows"', 'android:text="Programas"'),
("res/layout/activity_home.xml", 'android:text="Saved"', 'android:text="Gardados"'),
("res/xml/settings.xml", 'android:title="Playback"', 'android:title="Reprodución"'),
("res/xml/settings.xml", 'android:title="Skip back"', 'android:title="Retroceder"'),
("res/xml/settings.xml", 'android:title="Skip forward"', 'android:title="Avanzar"'),
("res/xml/settings.xml", 'android:title="Downloads"', 'android:title="Descargas"'),
("res/xml/settings.xml", 'android:title="Download on Wi-Fi only"', 'android:title="Descargar só con wifi"'),
("res/xml/settings.xml", 'android:summary="Saves your mobile data"', 'android:summary="Aforra datos móbiles"'),
("res/xml/settings.xml", 'android:title="Delete all downloads"', 'android:title="Eliminar todas as descargas"'),
("res/xml/settings.xml", 'android:title="Display"', 'android:title="Aparencia"'),
("res/xml/settings.xml", 'android:title="Show cover art"', 'android:title="Amosar portadas"'),
("res/xml/settings.xml", 'android:summary="Turn off to save data and memory"', 'android:summary="Desactívao para aforrar datos e memoria"'),
("res/xml/settings.xml", 'android:title="Account"', 'android:title="Conta"'),
("res/xml/settings.xml", 'android:title="Signed in"', 'android:title="Sesión iniciada"'),
("res/xml/settings.xml", 'android:title="Certificates you trusted"', 'android:title="Certificados nos que confías"'),
("res/xml/settings.xml", 'android:title="Log out"', 'android:title="Pechar sesión"'),
("res/xml/settings.xml", 'android:title="About"', 'android:title="Acerca de"'),
("res/xml/settings.xml", 'android:summary="A tiny Audiobookshelf podcast client for Android 2.2+"',
'android:summary="Un pequeno cliente de podcasts de Audiobookshelf para Android 2.2+"'),
("res/values/arrays.xml", " seconds</item>", " segundos</item>"),
("AndroidManifest.xml", 'android:label="Settings"', 'android:label="Axustes"'),
# ---------------- Api / Json / Trust (errors)
(S + "Api.java", '"Unexpected reply from server"', '"Resposta inesperada do servidor"'),
(S + "Json.java", '"Unexpected reply from server"', '"Resposta inesperada do servidor"'),
(S + "Json.java", '"Reply was cut off"', '"A resposta chegou incompleta"'),
(S + "Api.java", '"Your sign-in has expired. Please log in again."', '"A túa sesión caducou. Inicia sesión de novo."'),
(S + "Api.java", '"The server redirected; check the address in Settings."', '"O servidor redirixiu a petición; comproba o enderezo do servidor."'),
(S + "Api.java", '"Please log in."', '"Inicia sesión."'),
(S + "Api.java", '"No Audiobookshelf server at this address."', '"Non hai ningún servidor de Audiobookshelf neste enderezo."'),
(S + "Api.java", "\"This server hasn't been set up yet. Finish setup in a browser first.\"",
'"Este servidor aínda non está configurado. Remata a configuración nun navegador."'),
(S + "Api.java", '"Wrong username or password."', '"Usuario ou contrasinal incorrectos."'),
(S + "Api.java", '"Too many attempts. Wait a minute and try again."', '"Demasiados intentos. Agarda un minuto e téntao de novo."'),
(S + "Api.java", "\"The server didn't return a sign-in token.\"", '"O servidor non devolveu ningún token de sesión."'),
(S + "Api.java", "\"That API key wasn't accepted.\"", '"Non se aceptou esa chave da API."'),
(S + "Api.java", "\"Certificate doesn't match \"", '"O certificado non corresponde a "'),
(S + "Trust.java", "\"Secure connections aren't available: \"", '"As conexións seguras non están dispoñibles: "'),
(S + "Trust.java", '"This phone doesn\'t recognise the certificate of "', '"Este teléfono non recoñece o certificado de "'),
(S + "Trust.java", '"Issued to: "', '"Emitido para: "'),
(S + "Trust.java", '"Issued by: "', '"Emitido por: "'),
(S + "Trust.java", '"Valid until: "', '"Válido ata: "'),
(S + "Trust.java", '"SHA-256 fingerprint:\\n"', '"Pegada dixital SHA-256:\\n"'),
(S + "Trust.java", "\"Only continue if this matches the fingerprint your computer's browser shows for the site.\"",
'"Continúa só se coincide coa pegada que amosa o navegador do teu ordenador para este sitio."'),
(S + "Trust.java", '"\\n\\nMiniABS will then trust certificates issued by \\""', '"\\n\\nMiniABS confiará desde agora nos certificados emitidos por «"'),
(S + "Trust.java", '.append("\\".");', '.append("».");'),
(S + "Trust.java", "DateFormat.getDateInstance().format(leaf.getNotAfter())", "Util.date(leaf.getNotAfter().getTime(), true)"),
(S + "Trust.java", "import java.text.DateFormat;\n", ""),
(S + "TrustPrompt.java", '"the server"', '"o servidor"'),
(S + "TrustPrompt.java", '"Check this certificate"', '"Comproba este certificado"'),
(S + "TrustPrompt.java", '"Trust"', '"Confiar"'),
(S + "TrustPrompt.java", '"Cancel"', '"Cancelar"'),
# ---------------- Util
(S + "Util.java", '"Not signed in. Please log in again."', '"Non iniciaches sesión. Inicia sesión de novo."'),
(S + "Util.java", "\"Your account isn't allowed to do that.\"", '"A túa conta non ten permiso para facer iso."'),
(S + "Util.java", '"Not found on the server."', '"Non se atopou no servidor."'),
(S + "Util.java", '"Server error "', '"Erro do servidor "'),
(S + "Util.java", '"Server not found. Check the address and your connection."', '"Non se atopou o servidor. Comproba o enderezo e a conexión."'),
(S + "Util.java", '"The server took too long to answer."', '"O servidor tardou demasiado en responder."'),
(S + "Util.java", "\"Can't connect to the server. Is it running and reachable?\"",
'"Non se pode conectar co servidor. Está en marcha e accesible?"'),
(S + "Util.java", "\"The server's certificate has expired, or the phone's date is wrong.\"",
'"O certificado do servidor caducou ou a data do teléfono é incorrecta."'),
(S + "Util.java", "\"This phone doesn't recognise the server's certificate.\"", '"Este teléfono non recoñece o certificado do servidor."'),
(S + "Util.java", "\"Couldn't set up a secure connection. Android 2.3 only speaks TLS 1.0, so the server needs \"\n + \"a legacy HTTPS port (see README). On your home network, the http:// address works too.\"",
'"Non se puido establecer unha conexión segura. Android 2.3 só admite TLS 1.0, así que o servidor "\n + "precisa un porto HTTPS antigo (ve o README). Na rede da casa tamén funciona o enderezo http://."'),
(S + "Util.java", '"Connection dropped. Check your network."', '"Cortouse a conexión. Comproba a rede."'),
(S + "Util.java", "\"The server's certificate is for a different address.\"", '"O certificado do servidor é doutro enderezo."'),
# ---------------- Downloads / DownloadService
(S + "Downloads.java", '"No SD card available. Insert or unmount it from USB to download."',
'"Non hai tarxeta SD dispoñible. Insírea ou desconéctaa do USB para descargar."'),
(S + "Downloads.java", "\"Can't use the SD card.\"", '"Non se pode usar a tarxeta SD."'),
(S + "Downloads.java", "\"Can't create a folder on the SD card.\"", '"Non se pode crear un cartafol na tarxeta SD."'),
(S + "DownloadService.java", '"Download failed: "', '"Fallou a descarga: "'),
(S + "DownloadService.java", '"not on Wi-Fi (Settings: Download on Wi-Fi only)"', '"non hai wifi (Axustes: Descargar só con wifi)"'),
(S + "DownloadService.java", '"this episode has no audio file"', '"este episodio non ten ficheiro de audio"'),
(S + "DownloadService.java", '"sign-in expired; log in again"', '"a sesión caducou; inicia sesión de novo"'),
(S + "DownloadService.java", "\"your account isn't allowed to download\"", '"a túa conta non ten permiso para descargar"'),
(S + "DownloadService.java", "\"couldn't save the file\"", '"non se puido gardar o ficheiro"'),
(S + "DownloadService.java", '"Downloading " + pct + "%"', '"Descargando " + pct + " %"'),
(S + "DownloadService.java", '" more queued"', '" máis na cola"'),
# ---------------- BaseActivity
(S + "BaseActivity.java", '"Certificate trusted. Try again."', '"Certificado aceptado. Téntao de novo."'),
(S + "BaseActivity.java", 'Util.clock(dur - pos) + " left"', '"quedan " + Util.clock(dur - pos)'),
(S + "BaseActivity.java", '"Paused"', '"En pausa"'),
(S + "BaseActivity.java", '"Play"', '"Reproducir"'),
(S + "BaseActivity.java", '"Cancel download"', '"Cancelar descarga"'),
(S + "BaseActivity.java", '"Delete download"', '"Eliminar descarga"'),
(S + "BaseActivity.java", '"Download"', '"Descargar"'),
(S + "BaseActivity.java", '"Mark as unplayed"', '"Marcar como non escoitado"'),
(S + "BaseActivity.java", '"Mark as played"', '"Marcar como escoitado"'),
(S + "BaseActivity.java", '"Go to show"', '"Ir ao programa"'),
(S + "BaseActivity.java", "\"You're not on Wi-Fi. Download over mobile data this time?\"",
'"Non estás nunha wifi. Descargar con datos móbiles esta vez?"'),
(S + "BaseActivity.java", '"Downloading. Wi-Fi-only is now off (change it in Settings)."',
'"Descargando. Desactivouse «só con wifi» (podes cambialo en Axustes)."'),
(S + "BaseActivity.java", '"Cancel"', '"Cancelar"'),
(S + "BaseActivity.java", '"Downloading…"', '"Descargando…"'),
(S + "BaseActivity.java", '"Download cancelled"', '"Descarga cancelada"'),
(S + "BaseActivity.java", '"Download deleted"', '"Descarga eliminada"'),
(S + "BaseActivity.java", '"Marked as played"', '"Marcado como escoitado"'),
(S + "BaseActivity.java", '"Marked as unplayed"', '"Marcado como non escoitado"'),
# ---------------- HomeActivity
(S + "HomeActivity.java", '"Synced offline progress"', '"Sincronizouse o progreso gardado sen conexión"'),
(S + "HomeActivity.java", '"This server has no podcast libraries."', '"Este servidor non ten bibliotecas de podcasts."'),
(S + "HomeActivity.java", '"CONTINUE LISTENING"', '"CONTINUAR ESCOITANDO"'),
(S + "HomeActivity.java", '"LATEST EPISODES"', '"ÚLTIMOS EPISODIOS"'),
(S + "HomeActivity.java", '"Load more"', '"Cargar máis"'),
(S + "HomeActivity.java", '"No episodes yet."', '"Aínda non hai episodios."'),
(S + "HomeActivity.java", '"\\n\\nTap refresh to try again."', '"\\n\\nToca actualizar para tentalo de novo."'),
(S + "HomeActivity.java", '"No podcasts in this library."', '"Non hai podcasts nesta biblioteca."'),
(S + "HomeActivity.java", "\"Can't reach the server. Showing saved episodes.\"",
'"Non se pode acceder ao servidor. Amósanse os episodios gardados."'),
(S + "HomeActivity.java", '"DOWNLOADING"', '"DESCARGANDO"'),
(S + "HomeActivity.java", '"SAVED ON SD CARD ("', '"GARDADOS NA TARXETA SD ("'),
(S + "HomeActivity.java", '"Nothing saved yet.\\n\\nLong-press an episode and choose Download to listen offline."',
'"Aínda non gardaches nada.\\n\\nMantén premido un episodio e escolle Descargar para escoitalo sen conexión."'),
(S + "HomeActivity.java", '"Loading…"', '"Cargando…"'),
(S + "HomeActivity.java", '"Still loading libraries…"', '"Aínda se están cargando as bibliotecas…"'),
(S + "HomeActivity.java", '"This is your only podcast library."', '"Esta é a túa única biblioteca de podcasts."'),
(S + "HomeActivity.java", '"Podcast library"', '"Biblioteca de podcasts"'),
(S + "HomeActivity.java", '"Refresh"', '"Actualizar"'),
(S + "HomeActivity.java", '"Library"', '"Biblioteca"'),
(S + "HomeActivity.java", '"Now playing"', '"En reprodución"'),
(S + "HomeActivity.java", '"Settings"', '"Axustes"'),
# ---------------- LoginActivity
(S + "LoginActivity.java", '"API key"', '"Chave da API"'),
(S + "LoginActivity.java", '"Password"', '"Contrasinal"'),
(S + "LoginActivity.java", '"Enter your server address."', '"Escribe o enderezo do servidor."'),
(S + "LoginActivity.java", '"Paste your API key."', '"Pega a túa chave da API."'),
(S + "LoginActivity.java", '"Enter your username."', '"Escribe o teu usuario."'),
(S + "LoginActivity.java", '"Connecting…"', '"Conectando…"'),
# ---------------- PlayerActivity
(S + "PlayerActivity.java", '"Nothing playing"', '"Non se está a reproducir nada"'),
(S + "PlayerActivity.java", '"Pick an episode from the list."', '"Escolle un episodio da lista."'),
(S + "PlayerActivity.java", '"Sleep at end"', '"Pausa ao final do episodio"'),
(S + "PlayerActivity.java", '"Sleep in "', '"Pausa en "'),
(S + "PlayerActivity.java", '"Connecting…"', '"Conectando…"'),
(S + "PlayerActivity.java", '"Saved on SD card"', '"Gardado na tarxeta SD"'),
(S + "PlayerActivity.java", '"Sleep timer"', '"Temporizador de pausa"'),
(S + "PlayerActivity.java", '"Mark as played"', '"Marcar como escoitado"'),
(S + "PlayerActivity.java", '"Cancel download"', '"Cancelar descarga"'),
(S + "PlayerActivity.java", '"Delete download"', '"Eliminar descarga"'),
(S + "PlayerActivity.java", '"Download"', '"Descargar"'),
(S + "PlayerActivity.java", '"Go to show"', '"Ir ao programa"'),
(S + "PlayerActivity.java", '"Stop"', '"Deter"'),
(S + "PlayerActivity.java", '"Marked as played"', '"Marcado como escoitado"'),
(S + "PlayerActivity.java", '"Off"', '"Desactivado"'),
(S + "PlayerActivity.java", ' minutes"', ' minutos"'),
(S + "PlayerActivity.java", '"1 hour"', '"1 hora"'),
(S + "PlayerActivity.java", '"End of episode"', '"Ao final do episodio"'),
(S + "PlayerActivity.java", '"Sleep timer off"', '"Temporizador desactivado"'),
(S + "PlayerActivity.java", '"Sleep timer set"', '"Temporizador activado"'),
# ---------------- PlayerService
(S + "PlayerService.java", '"Paused"', '"En pausa"'),
(S + "PlayerService.java", '"Marked as played"', '"Marcado como escoitado"'),
(S + "PlayerService.java", '"Connecting…"', '"Conectando…"'),
(S + "PlayerService.java", '"The server sent no audio for this episode."', '"O servidor non enviou audio para este episodio."'),
(S + "PlayerService.java", "\"This episode needs transcoding, which Android 2.x can't play. Try downloading it.\"",
'"Este episodio precisa transcodificación, que Android 2.x non pode reproducir. Proba a descargalo."'),
(S + "PlayerService.java", '"Offline · progress will sync later"', '"Sen conexión · o progreso sincronizarase despois"'),
(S + "PlayerService.java", '"Opening saved file…"', '"Abrindo o ficheiro gardado…"'),
(S + "PlayerService.java", '"Buffering…"', '"Cargando…"'),
(S + "PlayerService.java", "\"Can't open the audio: \"", '"Non se pode abrir o audio: "'),
(S + "PlayerService.java", '"Playback failed: "', '"Fallou a reprodución: "'),
(S + "PlayerService.java", '"Saved file · offline"', '"Ficheiro gardado · sen conexión"'),
(S + "PlayerService.java", '"Saved file"', '"Ficheiro gardado"'),
(S + "PlayerService.java", '"Streaming"', '"Reproducindo en liña"'),
(S + "PlayerService.java", '"Connection lost, reconnecting…"', '"Perdeuse a conexión, reconectando…"'),
(S + "PlayerService.java", '"Finished"', '"Rematado"'),
(S + "PlayerService.java", '"Playback error ("', '"Erro de reprodución ("'),
(S + "PlayerService.java", '"Lost connection to the server. Press play to try again."',
'"Perdeuse a conexión co servidor. Preme reproducir para tentalo de novo."'),
(S + "PlayerService.java", '"Sleep timer: paused"', '"Temporizador: en pausa"'),
(S + "PlayerService.java", '"Playing"', '"Reproducindo"'),
(S + "PlayerService.java", '"Loading…"', '"Cargando…"'),
(S + "PlayerService.java", '"Stopped: tap to retry"', '"Detido: toca para tentalo de novo"'),
# ---------------- PodcastActivity
(S + "PodcastActivity.java", '"1 episode"', '"1 episodio"'),
(S + "PodcastActivity.java", '" episodes"', '" episodios"'),
(S + "PodcastActivity.java", '" PLAYED EPISODES HIDDEN"', '" EPISODIOS ESCOITADOS OCULTOS"'),
(S + "PodcastActivity.java", '"No episodes."', '"Non hai episodios."'),
(S + "PodcastActivity.java", '"Newest first"', '"Máis recentes primeiro"'),
(S + "PodcastActivity.java", '"Oldest first"', '"Máis antigos primeiro"'),
(S + "PodcastActivity.java", '"Show played"', '"Amosar escoitados"'),
(S + "PodcastActivity.java", '"Hide played"', '"Ocultar escoitados"'),
(S + "PodcastActivity.java", '"Refresh"', '"Actualizar"'),
(S + "PodcastActivity.java", '"Now playing"', '"En reprodución"'),
# ---------------- Rows
(S + "Rows.java", '"1 episode"', '"1 episodio"'),
(S + "Rows.java", '" episodes"', '" episodios"'),
(S + "Rows.java", '"Played"', '"Escoitado"'),
(S + "Rows.java", 'Util.duration(Math.max(0, dur - cur)) + " left"', '"quedan " + Util.duration(Math.max(0, dur - cur))'),
(S + "Rows.java", '"Saving "', '"Gardando "'),
(S + "Rows.java", '"Queued"', '"Na cola"'),
(S + "Rows.java", '"Saved"', '"Gardado"'),
(S + "Rows.java", '"Now playing"', '"En reprodución"'),
# ---------------- SettingsActivity
(S + "SettingsActivity.java", '" seconds"', '" segundos"'),
(S + "SettingsActivity.java", '"API key"', '"Chave da API"'),
(S + "SettingsActivity.java", '"Delete every downloaded episode from the SD card?"',
'"Eliminar todos os episodios descargados da tarxeta SD?"'),
(S + "SettingsActivity.java", '"Delete"', '"Eliminar"'),
(S + "SettingsActivity.java", '" deleted"', '" eliminados"'),
(S + "SettingsActivity.java", '"Cancel"', '"Cancelar"'),
(S + "SettingsActivity.java", '"None"', '"Ningún"'),
(S + "SettingsActivity.java", '" (tap to forget)"', '" (toca para esquecelos)"'),
(S + "SettingsActivity.java", "\"Forget the certificates you chose to trust? You'll be asked again next time.\"",
'"Esquecer os certificados nos que decidiches confiar? Volverase preguntar a próxima vez."'),
(S + "SettingsActivity.java", '"Forget"', '"Esquecer"'),
(S + "SettingsActivity.java", '"Log out of "', '"Pechar sesión en "'),
(S + "SettingsActivity.java", '"? Downloads stay on the SD card."', '"? As descargas quedan na tarxeta SD."'),
(S + "SettingsActivity.java", '"Log out"', '"Pechar sesión"'),
]
missing = []
for f, old, new in R:
p = os.path.join(ROOT, f)
s = open(p, encoding="utf-8").read()
if old not in s:
missing.append((f, old[:70]))
continue
open(p, "w", encoding="utf-8").write(s.replace(old, new))
if missing:
for m in missing:
print("NOT FOUND", m)
sys.exit(1)
print(len(R), "replacements applied")