Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0fea07f1b2 | ||
|
|
275bee3c11 | ||
|
|
b7e43d7d61 |
No files matched your search
@@ -1,8 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
package="com.miniabs"
|
||||
android:versionCode="1"
|
||||
android:versionName="1.0"
|
||||
android:versionCode="5"
|
||||
android:versionName="1.4"
|
||||
android:installLocation="auto">
|
||||
|
||||
<uses-sdk android:minSdkVersion="8" android:targetSdkVersion="10" />
|
||||
@@ -54,7 +54,7 @@
|
||||
|
||||
<activity
|
||||
android:name=".SettingsActivity"
|
||||
android:label="Settings"
|
||||
android:label="Axustes"
|
||||
android:theme="@android:style/Theme"
|
||||
android:screenOrientation="portrait"
|
||||
android:configChanges="keyboardHidden|orientation" />
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 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
|
||||
|
||||
@@ -10,12 +10,58 @@ A tiny Audiobookshelf **podcast** client for the Samsung Galaxy Mini (GT-S5570)
|
||||
|
||||
## Connecting
|
||||
|
||||
Use your server's **local http:// address**, e.g. `http://192.168.1.10:13378`.
|
||||
|
||||
Android 2.x only speaks old TLS and has an outdated certificate list, so it usually can't connect to modern HTTPS servers (including Let's Encrypt behind a reverse proxy). The "Accept any HTTPS certificate" box only helps with self-signed certificates on servers that still allow older TLS.
|
||||
At home, the simplest option is your server's **local http:// address**, e.g. `http://192.168.1.10:13378`.
|
||||
|
||||
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
|
||||
|
||||
- **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.
|
||||
|
||||
`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`).
|
||||
|
Before Width: | Height: | Size: 499 B After Width: | Height: | Size: 312 B |
|
Before Width: | Height: | Size: 346 B After Width: | Height: | Size: 510 B |
|
Before Width: | Height: | Size: 650 B After Width: | Height: | Size: 760 B |
|
Before Width: | Height: | Size: 880 B After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 816 B After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 381 B After Width: | Height: | Size: 354 B |
|
Before Width: | Height: | Size: 349 B After Width: | Height: | Size: 413 B |
|
Before Width: | Height: | Size: 470 B After Width: | Height: | Size: 467 B |
|
Before Width: | Height: | Size: 527 B After Width: | Height: | Size: 656 B |
|
Before Width: | Height: | Size: 377 B After Width: | Height: | Size: 446 B |
|
Before Width: | Height: | Size: 408 B After Width: | Height: | Size: 355 B |
|
Before Width: | Height: | Size: 413 B After Width: | Height: | Size: 486 B |
|
Before Width: | Height: | Size: 513 B After Width: | Height: | Size: 561 B |
|
Before Width: | Height: | Size: 656 B After Width: | Height: | Size: 834 B |
|
Before Width: | Height: | Size: 463 B After Width: | Height: | Size: 699 B |
@@ -1,9 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<selector xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:state_pressed="true">
|
||||
<shape android:shape="oval"><solid android:color="#FFC766" /></shape>
|
||||
<shape><solid android:color="#FFC766" /><corners android:radius="23dp" /></shape>
|
||||
</item>
|
||||
<item>
|
||||
<shape android:shape="oval"><solid android:color="@color/accent" /></shape>
|
||||
<shape><solid android:color="@color/accent" /><corners android:radius="23dp" /></shape>
|
||||
</item>
|
||||
</selector>
|
||||
@@ -1,5 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="oval">
|
||||
<solid android:color="@color/accent" />
|
||||
<size android:width="14dp" android:height="14dp" />
|
||||
<stroke android:width="2dp" android:color="@color/bg" />
|
||||
<size android:width="@dimen/seek_thumb" android:height="@dimen/seek_thumb" />
|
||||
</shape>
|
||||
@@ -10,7 +10,7 @@
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="MiniABS"
|
||||
android:textSize="15sp"
|
||||
android:textSize="@dimen/header_text"
|
||||
android:textStyle="bold"
|
||||
android:textColor="@color/accent" />
|
||||
|
||||
@@ -20,8 +20,8 @@
|
||||
android:layout_height="fill_parent"
|
||||
android:layout_weight="1"
|
||||
android:gravity="center_vertical"
|
||||
android:paddingLeft="8dp"
|
||||
android:textSize="11sp"
|
||||
android:paddingLeft="10dp"
|
||||
android:textSize="@dimen/header_sub"
|
||||
android:textColor="@color/sub"
|
||||
android:singleLine="true"
|
||||
android:ellipsize="end"
|
||||
@@ -29,7 +29,7 @@
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/refresh"
|
||||
android:layout_width="36dp"
|
||||
android:layout_width="@dimen/header_h"
|
||||
android:layout_height="fill_parent"
|
||||
android:background="@drawable/row_sel"
|
||||
android:src="@drawable/ic_refresh" />
|
||||
@@ -37,12 +37,12 @@
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="fill_parent"
|
||||
android:layout_height="32dp"
|
||||
android:layout_height="@dimen/tab_h"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<TextView android:id="@+id/tab_latest" style="@style/Tab" android:text="Latest" />
|
||||
<TextView android:id="@+id/tab_shows" style="@style/Tab" android:text="Shows" />
|
||||
<TextView android:id="@+id/tab_saved" style="@style/Tab" android:text="Saved" />
|
||||
<TextView android:id="@+id/tab_latest" style="@style/Tab" android:text="Últimos" />
|
||||
<TextView android:id="@+id/tab_shows" style="@style/Tab" android:text="Programas" />
|
||||
<TextView android:id="@+id/tab_saved" style="@style/Tab" android:text="Gardados" />
|
||||
</LinearLayout>
|
||||
|
||||
<include layout="@layout/list_area" />
|
||||
|
||||
@@ -15,50 +15,50 @@
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical"
|
||||
android:layout_marginBottom="4dp">
|
||||
android:layout_marginBottom="6dp">
|
||||
|
||||
<ImageView
|
||||
android:layout_width="36dp"
|
||||
android:layout_height="36dp"
|
||||
android:layout_width="48dp"
|
||||
android:layout_height="48dp"
|
||||
android:src="@drawable/ic_launcher" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:paddingLeft="8dp">
|
||||
android:paddingLeft="10dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="MiniABS"
|
||||
android:textSize="18sp"
|
||||
android:textSize="@dimen/login_title"
|
||||
android:textStyle="bold"
|
||||
android:textColor="@color/accent" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Audiobookshelf podcasts"
|
||||
android:textSize="11sp"
|
||||
android:text="Podcasts de Audiobookshelf"
|
||||
android:textSize="@dimen/login_tip"
|
||||
android:textColor="@color/sub" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
|
||||
<TextView style="@style/Label" android:text="Server address" />
|
||||
<TextView style="@style/Label" android:text="Enderezo do servidor" />
|
||||
<EditText
|
||||
android:id="@+id/server"
|
||||
style="@style/Field"
|
||||
android:hint="http://192.168.1.10:13378"
|
||||
android:inputType="textUri" />
|
||||
|
||||
<TextView android:id="@+id/user_label" style="@style/Label" android:text="Username" />
|
||||
<TextView android:id="@+id/user_label" style="@style/Label" android:text="Usuario" />
|
||||
<EditText
|
||||
android:id="@+id/user"
|
||||
style="@style/Field"
|
||||
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
|
||||
android:id="@+id/pass"
|
||||
style="@style/Field"
|
||||
@@ -66,43 +66,39 @@
|
||||
|
||||
<CheckBox
|
||||
android:id="@+id/use_key"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_width="fill_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:textSize="12sp"
|
||||
android:text="Use an API key instead" />
|
||||
|
||||
<CheckBox
|
||||
android:id="@+id/trust"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textSize="12sp"
|
||||
android:text="Accept any HTTPS certificate (insecure)" />
|
||||
android:layout_marginTop="4dp"
|
||||
android:minHeight="48dp"
|
||||
android:textSize="@dimen/login_check"
|
||||
android:text="Usar unha chave da API" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/connect"
|
||||
android:layout_width="fill_parent"
|
||||
android:layout_height="42dp"
|
||||
android:layout_height="@dimen/login_btn_h"
|
||||
android:layout_marginTop="6dp"
|
||||
android:background="@drawable/btn_accent"
|
||||
android:text="Connect"
|
||||
android:text="Conectar"
|
||||
android:textStyle="bold"
|
||||
android:textSize="14sp"
|
||||
android:textSize="@dimen/login_btn_text"
|
||||
android:textColor="#121519" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/status"
|
||||
android:layout_width="fill_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:textSize="12sp"
|
||||
android:layout_marginTop="10dp"
|
||||
android:textSize="@dimen/login_status"
|
||||
android:textColor="@color/accent" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="fill_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:textSize="10sp"
|
||||
android:layout_marginTop="10dp"
|
||||
android:textSize="@dimen/login_tip"
|
||||
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>
|
||||
</ScrollView>
|
||||
@@ -11,7 +11,9 @@
|
||||
android:layout_height="fill_parent"
|
||||
android:layout_weight="1"
|
||||
android:gravity="center_vertical"
|
||||
android:textSize="12sp"
|
||||
android:paddingRight="10dp"
|
||||
android:textSize="@dimen/header_sub"
|
||||
android:textStyle="bold"
|
||||
android:textColor="@color/accent"
|
||||
android:singleLine="true"
|
||||
android:ellipsize="end"
|
||||
@@ -27,9 +29,9 @@
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/cover"
|
||||
android:layout_width="84dp"
|
||||
android:layout_height="84dp"
|
||||
android:layout_marginRight="8dp"
|
||||
android:layout_width="@dimen/cover_player"
|
||||
android:layout_height="@dimen/cover_player"
|
||||
android:layout_marginRight="10dp"
|
||||
android:scaleType="centerCrop"
|
||||
android:background="@color/surface" />
|
||||
|
||||
@@ -43,7 +45,7 @@
|
||||
android:id="@+id/title"
|
||||
android:layout_width="fill_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:textSize="14sp"
|
||||
android:textSize="@dimen/player_title"
|
||||
android:textStyle="bold"
|
||||
android:textColor="@color/text"
|
||||
android:maxLines="3"
|
||||
@@ -53,8 +55,8 @@
|
||||
android:id="@+id/date"
|
||||
android:layout_width="fill_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="2dp"
|
||||
android:textSize="10sp"
|
||||
android:layout_marginTop="3dp"
|
||||
android:textSize="@dimen/player_meta"
|
||||
android:textColor="@color/sub"
|
||||
android:singleLine="true" />
|
||||
|
||||
@@ -63,9 +65,10 @@
|
||||
android:layout_width="fill_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="2dp"
|
||||
android:textSize="10sp"
|
||||
android:textSize="@dimen/player_meta"
|
||||
android:textColor="@color/fresh"
|
||||
android:maxLines="2" />
|
||||
android:maxLines="2"
|
||||
android:ellipsize="end" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
|
||||
@@ -73,28 +76,29 @@
|
||||
android:id="@+id/seek"
|
||||
android:layout_width="fill_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:paddingLeft="14dp"
|
||||
android:paddingRight="14dp"
|
||||
android:minHeight="4dp"
|
||||
android:maxHeight="4dp"
|
||||
android:paddingLeft="18dp"
|
||||
android:paddingRight="18dp"
|
||||
android:paddingTop="6dp"
|
||||
android:paddingBottom="6dp"
|
||||
android:minHeight="@dimen/seek_track"
|
||||
android:maxHeight="@dimen/seek_track"
|
||||
android:progressDrawable="@drawable/seek_track"
|
||||
android:thumb="@drawable/seek_thumb"
|
||||
android:thumbOffset="7dp"
|
||||
android:thumbOffset="13dp"
|
||||
android:max="1000" />
|
||||
|
||||
<RelativeLayout
|
||||
android:layout_width="fill_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:paddingLeft="10dp"
|
||||
android:paddingRight="10dp"
|
||||
android:paddingTop="2dp">
|
||||
android:paddingLeft="12dp"
|
||||
android:paddingRight="12dp">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/pos"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_alignParentLeft="true"
|
||||
android:textSize="11sp"
|
||||
android:textSize="@dimen/player_time"
|
||||
android:textColor="@color/sub" />
|
||||
|
||||
<TextView
|
||||
@@ -102,7 +106,7 @@
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_alignParentRight="true"
|
||||
android:textSize="11sp"
|
||||
android:textSize="@dimen/player_time"
|
||||
android:textColor="@color/sub" />
|
||||
</RelativeLayout>
|
||||
|
||||
@@ -112,33 +116,33 @@
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center"
|
||||
android:paddingTop="4dp"
|
||||
android:paddingBottom="6dp">
|
||||
android:paddingBottom="8dp">
|
||||
|
||||
<Button
|
||||
android:id="@+id/back"
|
||||
android:layout_width="58dp"
|
||||
android:layout_height="38dp"
|
||||
android:layout_width="@dimen/skip_w"
|
||||
android:layout_height="@dimen/skip_h"
|
||||
android:background="@drawable/btn_flat"
|
||||
android:textColor="@color/text"
|
||||
android:textSize="13sp"
|
||||
android:textSize="@dimen/skip_text"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/play"
|
||||
android:layout_width="56dp"
|
||||
android:layout_height="56dp"
|
||||
android:layout_marginLeft="16dp"
|
||||
android:layout_marginRight="16dp"
|
||||
android:layout_width="@dimen/play_w"
|
||||
android:layout_height="@dimen/play_h"
|
||||
android:layout_marginLeft="@dimen/play_gap"
|
||||
android:layout_marginRight="@dimen/play_gap"
|
||||
android:background="@drawable/btn_round"
|
||||
android:src="@drawable/ic_play" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/fwd"
|
||||
android:layout_width="58dp"
|
||||
android:layout_height="38dp"
|
||||
android:layout_width="@dimen/skip_w"
|
||||
android:layout_height="@dimen/skip_h"
|
||||
android:background="@drawable/btn_flat"
|
||||
android:textColor="@color/text"
|
||||
android:textSize="13sp"
|
||||
android:textSize="@dimen/skip_text"
|
||||
android:textStyle="bold" />
|
||||
</LinearLayout>
|
||||
|
||||
@@ -154,9 +158,9 @@
|
||||
android:id="@+id/desc"
|
||||
android:layout_width="fill_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:padding="8dp"
|
||||
android:textSize="12sp"
|
||||
android:padding="10dp"
|
||||
android:textSize="@dimen/player_desc"
|
||||
android:textColor="@color/sub"
|
||||
android:lineSpacingExtra="2dp" />
|
||||
android:lineSpacingExtra="3dp" />
|
||||
</ScrollView>
|
||||
</LinearLayout>
|
||||
@@ -9,14 +9,14 @@
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:background="@color/header"
|
||||
android:padding="6dp"
|
||||
android:padding="8dp"
|
||||
android:gravity="center_vertical">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/cover"
|
||||
android:layout_width="44dp"
|
||||
android:layout_height="44dp"
|
||||
android:layout_marginRight="8dp"
|
||||
android:layout_width="@dimen/cover_podcast"
|
||||
android:layout_height="@dimen/cover_podcast"
|
||||
android:layout_marginRight="10dp"
|
||||
android:scaleType="centerCrop"
|
||||
android:background="@color/surface" />
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
android:id="@+id/title"
|
||||
android:layout_width="fill_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:textSize="14sp"
|
||||
android:textSize="@dimen/podcast_title"
|
||||
android:textStyle="bold"
|
||||
android:textColor="@color/text"
|
||||
android:maxLines="2"
|
||||
@@ -38,12 +38,8 @@
|
||||
|
||||
<TextView
|
||||
android:id="@+id/subtitle"
|
||||
android:layout_width="fill_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:textSize="10sp"
|
||||
android:textColor="@color/sub"
|
||||
android:singleLine="true"
|
||||
android:ellipsize="end" />
|
||||
style="@style/RowMeta"
|
||||
android:layout_marginTop="2dp" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
|
||||
|
||||
@@ -13,13 +13,14 @@
|
||||
android:layout_gravity="center"
|
||||
android:gravity="center"
|
||||
android:padding="20dp"
|
||||
android:textSize="13sp"
|
||||
android:textSize="@dimen/text_empty"
|
||||
android:textColor="@color/sub"
|
||||
android:lineSpacingExtra="2dp"
|
||||
android:visibility="gone" />
|
||||
|
||||
<ProgressBar
|
||||
android:id="@+id/loading"
|
||||
style="?android:attr/progressBarStyleSmall"
|
||||
style="?android:attr/progressBarStyle"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center"
|
||||
|
||||
@@ -11,13 +11,13 @@
|
||||
android:id="@+id/mini_progress"
|
||||
style="?android:attr/progressBarStyleHorizontal"
|
||||
android:layout_width="fill_parent"
|
||||
android:layout_height="2dp"
|
||||
android:layout_height="@dimen/mini_progress"
|
||||
android:progressDrawable="@drawable/thin_progress"
|
||||
android:max="1000" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="fill_parent"
|
||||
android:layout_height="44dp"
|
||||
android:layout_height="@dimen/mini_h"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical">
|
||||
|
||||
@@ -28,14 +28,14 @@
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical"
|
||||
android:gravity="center_vertical"
|
||||
android:paddingLeft="8dp"
|
||||
android:paddingLeft="10dp"
|
||||
android:background="@drawable/row_sel">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/mini_title"
|
||||
android:layout_width="fill_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:textSize="13sp"
|
||||
android:textSize="@dimen/mini_title"
|
||||
android:textColor="@color/text"
|
||||
android:singleLine="true"
|
||||
android:ellipsize="end" />
|
||||
@@ -44,7 +44,8 @@
|
||||
android:id="@+id/mini_sub"
|
||||
android:layout_width="fill_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:textSize="10sp"
|
||||
android:layout_marginTop="1dp"
|
||||
android:textSize="@dimen/mini_sub"
|
||||
android:textColor="@color/sub"
|
||||
android:singleLine="true"
|
||||
android:ellipsize="end" />
|
||||
@@ -52,8 +53,8 @@
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/mini_play"
|
||||
android:layout_width="44dp"
|
||||
android:layout_height="44dp"
|
||||
android:layout_width="@dimen/mini_btn"
|
||||
android:layout_height="@dimen/mini_btn"
|
||||
android:background="@drawable/row_sel"
|
||||
android:src="@drawable/ic_play_small" />
|
||||
</LinearLayout>
|
||||
|
||||
@@ -3,18 +3,18 @@
|
||||
android:layout_width="fill_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:minHeight="48dp"
|
||||
android:paddingLeft="6dp"
|
||||
android:paddingRight="6dp"
|
||||
android:paddingTop="5dp"
|
||||
android:paddingBottom="5dp">
|
||||
android:minHeight="@dimen/row_min"
|
||||
android:paddingLeft="@dimen/row_pad"
|
||||
android:paddingRight="@dimen/row_pad"
|
||||
android:paddingTop="7dp"
|
||||
android:paddingBottom="7dp">
|
||||
|
||||
<View
|
||||
android:id="@+id/dot"
|
||||
android:layout_width="7dp"
|
||||
android:layout_height="7dp"
|
||||
android:layout_marginTop="5dp"
|
||||
android:layout_marginRight="6dp" />
|
||||
android:layout_width="@dimen/dot"
|
||||
android:layout_height="@dimen/dot"
|
||||
android:layout_marginTop="6dp"
|
||||
android:layout_marginRight="8dp" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
@@ -26,37 +26,23 @@
|
||||
android:id="@+id/show"
|
||||
android:layout_width="fill_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:textSize="10sp"
|
||||
android:textSize="@dimen/text_show"
|
||||
android:textColor="@color/accent"
|
||||
android:singleLine="true"
|
||||
android:ellipsize="end" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/title"
|
||||
android:layout_width="fill_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:textSize="13sp"
|
||||
android:textColor="@color/text"
|
||||
android:maxLines="2"
|
||||
android:ellipsize="end" />
|
||||
<TextView android:id="@+id/title" style="@style/RowTitle" />
|
||||
|
||||
<ProgressBar
|
||||
android:id="@+id/progress"
|
||||
style="?android:attr/progressBarStyleHorizontal"
|
||||
android:layout_width="fill_parent"
|
||||
android:layout_height="2dp"
|
||||
android:layout_marginTop="3dp"
|
||||
android:layout_marginBottom="1dp"
|
||||
android:layout_height="@dimen/row_progress"
|
||||
android:layout_marginTop="4dp"
|
||||
android:layout_marginBottom="2dp"
|
||||
android:progressDrawable="@drawable/thin_progress"
|
||||
android:max="1000" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/meta"
|
||||
android:layout_width="fill_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:textSize="10sp"
|
||||
android:textColor="@color/sub"
|
||||
android:singleLine="true"
|
||||
android:ellipsize="end" />
|
||||
<TextView android:id="@+id/meta" style="@style/RowMeta" android:layout_marginTop="1dp" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
@@ -4,9 +4,10 @@
|
||||
android:layout_width="fill_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="@color/surface"
|
||||
android:paddingLeft="8dp"
|
||||
android:paddingTop="4dp"
|
||||
android:paddingBottom="4dp"
|
||||
android:textSize="10sp"
|
||||
android:paddingLeft="10dp"
|
||||
android:paddingRight="10dp"
|
||||
android:paddingTop="6dp"
|
||||
android:paddingBottom="6dp"
|
||||
android:textSize="@dimen/text_section"
|
||||
android:textStyle="bold"
|
||||
android:textColor="@color/sub" />
|
||||
@@ -2,7 +2,8 @@
|
||||
<TextView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:id="@+id/title"
|
||||
android:layout_width="fill_parent"
|
||||
android:layout_height="44dp"
|
||||
android:layout_height="@dimen/row_min"
|
||||
android:gravity="center"
|
||||
android:textSize="13sp"
|
||||
android:textSize="@dimen/text_more"
|
||||
android:textStyle="bold"
|
||||
android:textColor="@color/accent" />
|
||||
@@ -3,15 +3,15 @@
|
||||
android:layout_width="fill_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:minHeight="48dp"
|
||||
android:padding="5dp"
|
||||
android:minHeight="@dimen/row_min"
|
||||
android:padding="7dp"
|
||||
android:gravity="center_vertical">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/cover"
|
||||
android:layout_width="40dp"
|
||||
android:layout_height="40dp"
|
||||
android:layout_marginRight="8dp"
|
||||
android:layout_width="@dimen/cover_row"
|
||||
android:layout_height="@dimen/cover_row"
|
||||
android:layout_marginRight="10dp"
|
||||
android:scaleType="centerCrop"
|
||||
android:background="@color/surface" />
|
||||
|
||||
@@ -21,22 +21,8 @@
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/title"
|
||||
android:layout_width="fill_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:textSize="13sp"
|
||||
android:textColor="@color/text"
|
||||
android:maxLines="2"
|
||||
android:ellipsize="end" />
|
||||
<TextView android:id="@+id/title" style="@style/RowTitle" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/meta"
|
||||
android:layout_width="fill_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:textSize="10sp"
|
||||
android:textColor="@color/sub"
|
||||
android:singleLine="true"
|
||||
android:ellipsize="end" />
|
||||
<TextView android:id="@+id/meta" style="@style/RowMeta" android:layout_marginTop="2dp" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
@@ -1,12 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string-array name="skip_labels">
|
||||
<item>5 seconds</item>
|
||||
<item>10 seconds</item>
|
||||
<item>15 seconds</item>
|
||||
<item>30 seconds</item>
|
||||
<item>45 seconds</item>
|
||||
<item>60 seconds</item>
|
||||
<item>5 segundos</item>
|
||||
<item>10 segundos</item>
|
||||
<item>15 segundos</item>
|
||||
<item>30 segundos</item>
|
||||
<item>45 segundos</item>
|
||||
<item>60 segundos</item>
|
||||
</string-array>
|
||||
<string-array name="skip_values">
|
||||
<item>5</item>
|
||||
|
||||
@@ -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>
|
||||
@@ -7,8 +7,8 @@
|
||||
<color name="header">#0B0D10</color>
|
||||
<color name="line">#2A3038</color>
|
||||
<color name="text">#ECEFF2</color>
|
||||
<color name="sub">#9AA4AE</color>
|
||||
<color name="dim">#5C6670</color>
|
||||
<color name="sub">#A9B2BC</color>
|
||||
<color name="dim">#6E7882</color>
|
||||
<color name="accent">#F5A623</color>
|
||||
<color name="fresh">#6EC1E4</color>
|
||||
<color name="pressed">#2C333C</color>
|
||||
@@ -22,11 +22,11 @@
|
||||
|
||||
<style name="Header">
|
||||
<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:gravity">center_vertical</item>
|
||||
<item name="android:paddingLeft">8dp</item>
|
||||
<item name="android:paddingRight">2dp</item>
|
||||
<item name="android:paddingLeft">10dp</item>
|
||||
<item name="android:paddingRight">0dp</item>
|
||||
</style>
|
||||
|
||||
<style name="Tab">
|
||||
@@ -34,9 +34,10 @@
|
||||
<item name="android:layout_height">fill_parent</item>
|
||||
<item name="android:layout_weight">1</item>
|
||||
<item name="android:gravity">center</item>
|
||||
<item name="android:textSize">13sp</item>
|
||||
<item name="android:textSize">@dimen/tab_text</item>
|
||||
<item name="android:textStyle">bold</item>
|
||||
<item name="android:textColor">@color/sub</item>
|
||||
<item name="android:singleLine">true</item>
|
||||
<item name="android:background">@drawable/tab_bg</item>
|
||||
</style>
|
||||
|
||||
@@ -53,7 +54,8 @@
|
||||
<style name="Field">
|
||||
<item name="android:layout_width">fill_parent</item>
|
||||
<item name="android:layout_height">wrap_content</item>
|
||||
<item name="android:textSize">14sp</item>
|
||||
<item name="android:minHeight">@dimen/login_field_h</item>
|
||||
<item name="android:textSize">@dimen/login_field</item>
|
||||
<item name="android:singleLine">true</item>
|
||||
<item name="android:textColor">#000000</item>
|
||||
</style>
|
||||
@@ -61,8 +63,27 @@
|
||||
<style name="Label">
|
||||
<item name="android:layout_width">wrap_content</item>
|
||||
<item name="android:layout_height">wrap_content</item>
|
||||
<item name="android:textSize">11sp</item>
|
||||
<item name="android:textSize">@dimen/login_label</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>
|
||||
</resources>
|
||||
@@ -1,53 +1,56 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<PreferenceCategory android:title="Playback">
|
||||
<PreferenceCategory android:title="Reprodución">
|
||||
<ListPreference
|
||||
android:key="skip_back"
|
||||
android:title="Skip back"
|
||||
android:title="Retroceder"
|
||||
android:entries="@array/skip_labels"
|
||||
android:entryValues="@array/skip_values"
|
||||
android:defaultValue="15" />
|
||||
<ListPreference
|
||||
android:key="skip_fwd"
|
||||
android:title="Skip forward"
|
||||
android:title="Avanzar"
|
||||
android:entries="@array/skip_labels"
|
||||
android:entryValues="@array/skip_values"
|
||||
android:defaultValue="30" />
|
||||
</PreferenceCategory>
|
||||
|
||||
<PreferenceCategory android:title="Downloads">
|
||||
<PreferenceCategory android:title="Descargas">
|
||||
<CheckBoxPreference
|
||||
android:key="wifi_only"
|
||||
android:title="Download on Wi-Fi only"
|
||||
android:summary="Saves your mobile data"
|
||||
android:title="Descargar só con wifi"
|
||||
android:summary="Aforra datos móbiles"
|
||||
android:defaultValue="true" />
|
||||
<Preference
|
||||
android:key="delete_all"
|
||||
android:title="Delete all downloads" />
|
||||
android:title="Eliminar todas as descargas" />
|
||||
</PreferenceCategory>
|
||||
|
||||
<PreferenceCategory android:title="Display">
|
||||
<PreferenceCategory android:title="Aparencia">
|
||||
<CheckBoxPreference
|
||||
android:key="covers"
|
||||
android:title="Show cover art"
|
||||
android:summary="Turn off to save data and memory"
|
||||
android:title="Amosar portadas"
|
||||
android:summary="Desactívao para aforrar datos e memoria"
|
||||
android:defaultValue="true" />
|
||||
</PreferenceCategory>
|
||||
|
||||
<PreferenceCategory android:title="Account">
|
||||
<PreferenceCategory android:title="Conta">
|
||||
<Preference
|
||||
android:key="account"
|
||||
android:title="Signed in" />
|
||||
android:title="Sesión iniciada" />
|
||||
<Preference
|
||||
android:key="pins"
|
||||
android:title="Certificados nos que confías" />
|
||||
<Preference
|
||||
android:key="logout"
|
||||
android:title="Log out" />
|
||||
android:title="Pechar sesión" />
|
||||
</PreferenceCategory>
|
||||
|
||||
<PreferenceCategory android:title="About">
|
||||
<PreferenceCategory android:title="Acerca de">
|
||||
<Preference
|
||||
android:key="about"
|
||||
android:title="MiniABS 1.0"
|
||||
android:summary="A tiny Audiobookshelf podcast client for Android 2.2+" />
|
||||
android:title="MiniABS 1.4"
|
||||
android:summary="Un pequeno cliente de podcasts de Audiobookshelf para Android 2.2+" />
|
||||
</PreferenceCategory>
|
||||
</PreferenceScreen>
|
||||
@@ -12,17 +12,10 @@ import java.net.ProtocolException;
|
||||
import java.net.Socket;
|
||||
import java.net.URL;
|
||||
import java.net.URLEncoder;
|
||||
import java.security.cert.X509Certificate;
|
||||
|
||||
import javax.net.ssl.HostnameVerifier;
|
||||
import javax.net.ssl.HttpsURLConnection;
|
||||
import javax.net.ssl.SSLContext;
|
||||
import javax.net.ssl.SSLException;
|
||||
import javax.net.ssl.SSLSession;
|
||||
import javax.net.ssl.SSLSocket;
|
||||
import javax.net.ssl.SSLSocketFactory;
|
||||
import javax.net.ssl.TrustManager;
|
||||
import javax.net.ssl.X509TrustManager;
|
||||
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONException;
|
||||
@@ -33,7 +26,7 @@ import android.os.Build;
|
||||
|
||||
/** Audiobookshelf REST client (API as of server 2.x, including the 2.26+ token system). */
|
||||
final class Api {
|
||||
static final String VERSION = "1.0";
|
||||
static final String VERSION = "1.4";
|
||||
|
||||
static class ApiException extends IOException {
|
||||
final int code;
|
||||
@@ -52,7 +45,6 @@ final class Api {
|
||||
}
|
||||
|
||||
private static final Object REFRESH_LOCK = new Object();
|
||||
private static SSLSocketFactory trustAllFactory;
|
||||
|
||||
static {
|
||||
// Froyo's HttpURLConnection reuses dead pooled sockets; turn pooling off.
|
||||
@@ -70,7 +62,7 @@ final class Api {
|
||||
conn.setReadTimeout(30000);
|
||||
conn.setUseCaches(false);
|
||||
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);
|
||||
conn.setRequestProperty("User-Agent", "MiniABS/" + VERSION + " (Android " + Build.VERSION.RELEASE + ")");
|
||||
return conn;
|
||||
@@ -80,38 +72,6 @@ final class Api {
|
||||
conn.setRequestMethod(method);
|
||||
}
|
||||
|
||||
private static synchronized SSLSocketFactory trustAllFactory() throws Exception {
|
||||
if (trustAllFactory == null) {
|
||||
TrustManager tm = new X509TrustManager() {
|
||||
public void checkClientTrusted(X509Certificate[] chain, String type) {
|
||||
}
|
||||
|
||||
public void checkServerTrusted(X509Certificate[] chain, String type) {
|
||||
}
|
||||
|
||||
public X509Certificate[] getAcceptedIssuers() {
|
||||
return new X509Certificate[0];
|
||||
}
|
||||
};
|
||||
SSLContext ctx = SSLContext.getInstance("TLS");
|
||||
ctx.init(null, new TrustManager[]{tm}, null);
|
||||
trustAllFactory = ctx.getSocketFactory();
|
||||
}
|
||||
return trustAllFactory;
|
||||
}
|
||||
|
||||
private static void trustAll(HttpsURLConnection conn) {
|
||||
try {
|
||||
conn.setSSLSocketFactory(trustAllFactory());
|
||||
conn.setHostnameVerifier(new HostnameVerifier() {
|
||||
public boolean verify(String host, SSLSession session) {
|
||||
return true;
|
||||
}
|
||||
});
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH over a plain socket. Android 2.x's HttpURLConnection only sends a body with POST or PUT,
|
||||
* and Audiobookshelf uses PATCH for "mark as played" and for saving progress without a session.
|
||||
@@ -130,18 +90,17 @@ final class Api {
|
||||
sock.setSoTimeout(30000);
|
||||
Socket io = sock;
|
||||
if (https) {
|
||||
boolean trust = p.trustAll();
|
||||
SSLSocketFactory f;
|
||||
try {
|
||||
f = trust ? trustAllFactory() : (SSLSocketFactory) SSLSocketFactory.getDefault();
|
||||
} catch (Exception e) {
|
||||
throw new SSLException("TLS unavailable");
|
||||
SSLSocket ss = (SSLSocket) Trust.factory(c).createSocket(sock, u.getHost(), port, true);
|
||||
try { // send the host name (SNI) where the phone supports it (Android 2.3+)
|
||||
ss.getClass().getMethod("setHostname", String.class).invoke(ss, u.getHost());
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
SSLSocket ss = (SSLSocket) f.createSocket(sock, u.getHost(), port, true);
|
||||
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();
|
||||
throw new SSLException("Certificate doesn't match " + u.getHost());
|
||||
throw new SSLException("O certificado non corresponde a " + u.getHost());
|
||||
}
|
||||
io = ss;
|
||||
}
|
||||
@@ -164,11 +123,11 @@ final class Api {
|
||||
int ch;
|
||||
while ((ch = in.read()) != -1 && ch != '\n' && line.length() < 200) line.append((char) ch);
|
||||
String[] parts = line.toString().trim().split(" ");
|
||||
if (parts.length < 2 || !parts[0].startsWith("HTTP/")) throw new IOException("Unexpected reply from server");
|
||||
if (parts.length < 2 || !parts[0].startsWith("HTTP/")) throw new IOException("Resposta inesperada do servidor");
|
||||
try {
|
||||
code = Integer.parseInt(parts[1]);
|
||||
} catch (NumberFormatException e) {
|
||||
throw new IOException("Unexpected reply from server");
|
||||
throw new IOException("Resposta inesperada do servidor");
|
||||
}
|
||||
byte[] rest = new byte[1024];
|
||||
int total = 0, n;
|
||||
@@ -185,10 +144,10 @@ final class Api {
|
||||
refreshIf(c, used, 0);
|
||||
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 >= 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";
|
||||
}
|
||||
|
||||
@@ -257,7 +216,7 @@ final class Api {
|
||||
static Object call(Context c, String method, String path, JSONObject body, int maxString, String[] deepSkip,
|
||||
boolean mayRefresh) throws IOException {
|
||||
Prefs p = Prefs.get(c);
|
||||
if (!p.loggedIn()) throw new AuthException("Please log in.");
|
||||
if (!p.loggedIn()) throw new AuthException("Inicia sesión.");
|
||||
if ("PATCH".equals(method)) return rawPatch(c, path, body, mayRefresh);
|
||||
if (mayRefresh) ensureFresh(c, 120);
|
||||
String used = p.token();
|
||||
@@ -273,7 +232,7 @@ final class Api {
|
||||
refreshIf(c, used, 0);
|
||||
return call(c, method, path, body, maxString, deepSkip, false);
|
||||
}
|
||||
throw new AuthException("Your sign-in has expired. Please log in again.");
|
||||
throw new AuthException("A túa sesión caducou. Inicia sesión de novo.");
|
||||
}
|
||||
if (code >= 400) throw new ApiException(code, errorText(conn));
|
||||
return readBody(conn, maxString, deepSkip);
|
||||
@@ -284,7 +243,7 @@ final class Api {
|
||||
|
||||
static JSONObject obj(Object o) throws IOException {
|
||||
if (o instanceof JSONObject) return (JSONObject) o;
|
||||
throw new IOException("Unexpected reply from server");
|
||||
throw new IOException("Resposta inesperada do servidor");
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ sign-in
|
||||
@@ -294,13 +253,13 @@ final class Api {
|
||||
HttpURLConnection conn = open(c, server + "/status", "GET");
|
||||
try {
|
||||
int code = responseCode(conn);
|
||||
if (code == 404) throw new IOException("No Audiobookshelf server at this address.");
|
||||
if (code == 404) throw new IOException("Non hai ningún servidor de Audiobookshelf neste enderezo.");
|
||||
if (code >= 400) throw new ApiException(code, errorText(conn));
|
||||
Object o = readBody(conn, 200, null);
|
||||
if (!(o instanceof JSONObject)) throw new IOException("No Audiobookshelf server at this address.");
|
||||
if (!(o instanceof JSONObject)) throw new IOException("Non hai ningún servidor de Audiobookshelf neste enderezo.");
|
||||
JSONObject s = (JSONObject) o;
|
||||
if (s.has("isInit") && !s.optBoolean("isInit", true)) {
|
||||
throw new IOException("This server hasn't been set up yet. Finish setup in a browser first.");
|
||||
throw new IOException("Este servidor aínda non está configurado. Remata a configuración nun navegador.");
|
||||
}
|
||||
return Util.str(s, "serverVersion");
|
||||
} finally {
|
||||
@@ -321,19 +280,19 @@ final class Api {
|
||||
}
|
||||
writeBody(conn, body);
|
||||
int code = responseCode(conn);
|
||||
if (code == 401) throw new ApiException(401, "Wrong username or password.");
|
||||
if (code == 429) throw new ApiException(429, "Too many attempts. Wait a minute and try again.");
|
||||
if (code == 401) throw new ApiException(401, "Usuario ou contrasinal incorrectos.");
|
||||
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));
|
||||
JSONObject o = obj(readBody(conn, 500, null));
|
||||
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 refresh = Util.str(u, "refreshToken");
|
||||
String legacy = Util.str(u, "token");
|
||||
Prefs p = Prefs.get(c);
|
||||
if (access.length() > 0) p.setAuth(user, Prefs.MODE_JWT, access, refresh);
|
||||
else if (legacy.length() > 0) p.setAuth(user, Prefs.MODE_LEGACY, legacy, "");
|
||||
else throw new IOException("The server didn't return a sign-in token.");
|
||||
else throw new IOException("O servidor non devolveu ningún token de sesión.");
|
||||
return Util.str(o, "userDefaultLibraryId");
|
||||
} finally {
|
||||
conn.disconnect();
|
||||
@@ -351,7 +310,7 @@ final class Api {
|
||||
return name;
|
||||
} catch (IOException e) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -376,7 +335,7 @@ final class Api {
|
||||
private static void refreshIf(Context c, String stale, long minSeconds) throws IOException {
|
||||
synchronized (REFRESH_LOCK) {
|
||||
Prefs p = Prefs.get(c);
|
||||
if (!p.canRefresh()) throw new AuthException("Your sign-in has expired. Please log in again.");
|
||||
if (!p.canRefresh()) throw new AuthException("A túa sesión caducou. Inicia sesión de novo.");
|
||||
if (stale != null) {
|
||||
if (!stale.equals(p.token())) return;
|
||||
} else if (p.tokenSecondsLeft() >= minSeconds) {
|
||||
@@ -389,12 +348,12 @@ final class Api {
|
||||
writeBody(conn, null);
|
||||
int code = responseCode(conn);
|
||||
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));
|
||||
JSONObject u = obj(readBody(conn, 500, null)).optJSONObject("user");
|
||||
String access = u == null ? "" : Util.str(u, "accessToken");
|
||||
if (access.length() == 0) throw new AuthException("Your sign-in has expired. Please log in again.");
|
||||
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"));
|
||||
} finally {
|
||||
conn.disconnect();
|
||||
|
||||
@@ -64,9 +64,20 @@ abstract class BaseActivity extends Activity {
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
if (offerTrust(null)) return;
|
||||
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
|
||||
|
||||
protected void setupMiniBar() {
|
||||
@@ -119,13 +130,13 @@ abstract class BaseActivity extends Activity {
|
||||
dur = ps.durationMs();
|
||||
int st = ps.state();
|
||||
if (st == PlayerService.LOADING || st == PlayerService.ERROR) sub = ps.status();
|
||||
else sub = Util.join(e.show, dur > 0 ? Util.clock(dur - pos) + " left" : "");
|
||||
else sub = Util.join(e.show, dur > 0 ? "quedan " + Util.clock(dur - pos) : "");
|
||||
} else {
|
||||
double[] p = App.progress(e.id);
|
||||
double cur = p != null ? p[0] : Math.max(0, App.localPosition(this, e.id));
|
||||
pos = (int) (cur * 1000);
|
||||
dur = (int) (e.duration * 1000);
|
||||
sub = Util.join(e.show, "Paused");
|
||||
sub = Util.join(e.show, "En pausa");
|
||||
}
|
||||
miniSub.setText(sub);
|
||||
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) {
|
||||
menu.setHeaderTitle(e.title);
|
||||
menu.add(0, M_PLAY, 0, "Play");
|
||||
if (DownloadService.busyWith(e.id)) menu.add(0, M_CANCEL, 0, "Cancel download");
|
||||
else if (Downloads.has(this, e.id)) menu.add(0, M_DELETE, 0, "Delete download");
|
||||
else menu.add(0, M_DOWNLOAD, 0, "Download");
|
||||
menu.add(0, M_PLAY, 0, "Reproducir");
|
||||
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, "Eliminar descarga");
|
||||
else menu.add(0, M_DOWNLOAD, 0, "Descargar");
|
||||
double[] p = App.progress(e.id);
|
||||
if (p != null && p[2] > 0) menu.add(0, M_UNPLAYED, 0, "Mark as unplayed");
|
||||
else menu.add(0, M_PLAYED, 0, "Mark as played");
|
||||
if (offerShow) menu.add(0, M_SHOW, 0, "Go to show");
|
||||
if (p != null && p[2] > 0) menu.add(0, M_UNPLAYED, 0, "Marcar como non escoitado");
|
||||
else menu.add(0, M_PLAYED, 0, "Marcar como escoitado");
|
||||
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. */
|
||||
@@ -157,26 +168,26 @@ abstract class BaseActivity extends Activity {
|
||||
case M_DOWNLOAD:
|
||||
if (Prefs.get(this).wifiOnly() && !Util.onWifi(this)) {
|
||||
new AlertDialog.Builder(this)
|
||||
.setMessage("You're not on Wi-Fi. Download over mobile data this time?")
|
||||
.setPositiveButton("Download", new DialogInterface.OnClickListener() {
|
||||
.setMessage("Non estás nunha wifi. Descargar con datos móbiles esta vez?")
|
||||
.setPositiveButton("Descargar", new DialogInterface.OnClickListener() {
|
||||
public void onClick(DialogInterface d, int w) {
|
||||
Prefs.get(BaseActivity.this).sp.edit().putBoolean("wifi_only", false).commit();
|
||||
DownloadService.enqueue(BaseActivity.this, e);
|
||||
toast("Downloading. Wi-Fi-only is now off (change it in Settings).");
|
||||
toast("Descargando. Desactivouse «só con wifi» (podes cambialo en Axustes).");
|
||||
if (onChanged != null) onChanged.run();
|
||||
}
|
||||
})
|
||||
.setNegativeButton("Cancel", null)
|
||||
.setNegativeButton("Cancelar", null)
|
||||
.show();
|
||||
} else {
|
||||
DownloadService.enqueue(this, e);
|
||||
toast("Downloading…");
|
||||
toast("Descargando…");
|
||||
}
|
||||
if (onChanged != null) onChanged.run();
|
||||
return true;
|
||||
case M_CANCEL:
|
||||
DownloadService.cancel(e.id);
|
||||
toast("Download cancelled");
|
||||
toast("Descarga cancelada");
|
||||
if (onChanged != null) onChanged.run();
|
||||
return true;
|
||||
case M_DELETE:
|
||||
@@ -185,7 +196,7 @@ abstract class BaseActivity extends Activity {
|
||||
ps.stop();
|
||||
}
|
||||
Downloads.delete(this, e);
|
||||
toast("Download deleted");
|
||||
toast("Descarga eliminada");
|
||||
if (onChanged != null) onChanged.run();
|
||||
return true;
|
||||
case M_PLAYED:
|
||||
@@ -219,7 +230,7 @@ abstract class BaseActivity extends Activity {
|
||||
}
|
||||
|
||||
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) {
|
||||
|
||||
@@ -102,7 +102,7 @@ public class DownloadService extends IntentService {
|
||||
}
|
||||
} catch (Exception ex) {
|
||||
if (!e.id.equals(cancelId)) {
|
||||
lastError = "Download failed: " + Util.describe(ex);
|
||||
lastError = "Fallou a descarga: " + Util.describe(ex);
|
||||
notifyDone(e.title, lastError);
|
||||
}
|
||||
} finally {
|
||||
@@ -116,14 +116,14 @@ public class DownloadService extends IntentService {
|
||||
|
||||
private void download(Episode e) throws IOException {
|
||||
if (Prefs.get(this).wifiOnly() && !Util.onWifi(this)) {
|
||||
throw new IOException("not on Wi-Fi (Settings: Download on Wi-Fi only)");
|
||||
throw new IOException("non hai wifi (Axustes: Descargar só con wifi)");
|
||||
}
|
||||
File dir = Downloads.dir(this);
|
||||
if (Downloads.audio(this, e) != null) {
|
||||
Downloads.saved(this, e);
|
||||
return;
|
||||
}
|
||||
if (e.ino.length() == 0) throw new IOException("this episode has no audio file");
|
||||
if (e.ino.length() == 0) throw new IOException("este episodio non ten ficheiro de audio");
|
||||
File part = new File(dir, e.id + ".part");
|
||||
Notification n = progressNotification(e, 0);
|
||||
startForeground(NOTIF_ID, n);
|
||||
@@ -158,8 +158,8 @@ public class DownloadService extends IntentService {
|
||||
if (code != 404 && code != 403) break;
|
||||
}
|
||||
if (code != 200 && code != 206) {
|
||||
if (code == 401) throw new Api.AuthException("sign-in expired; log in again");
|
||||
if (code == 403) throw new IOException("your account isn't allowed to download");
|
||||
if (code == 401) throw new Api.AuthException("a sesión caducou; inicia sesión de novo");
|
||||
if (code == 403) throw new IOException("a túa conta non ten permiso para descargar");
|
||||
throw new Api.ApiException(code, "");
|
||||
}
|
||||
try {
|
||||
@@ -194,7 +194,7 @@ public class DownloadService extends IntentService {
|
||||
}
|
||||
if (e.id.equals(cancelId)) return;
|
||||
File dest = new File(dir, e.id + e.ext);
|
||||
if (!part.renameTo(dest)) throw new IOException("couldn't save the file");
|
||||
if (!part.renameTo(dest)) throw new IOException("non se puido gardar o ficheiro");
|
||||
Downloads.saved(this, e);
|
||||
percent = 100;
|
||||
}
|
||||
@@ -216,7 +216,7 @@ public class DownloadService extends IntentService {
|
||||
synchronized (queue) {
|
||||
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());
|
||||
return n;
|
||||
}
|
||||
|
||||
@@ -28,11 +28,11 @@ final class Downloads {
|
||||
|
||||
static File dir(Context c) throws IOException {
|
||||
if (!Environment.MEDIA_MOUNTED.equals(Environment.getExternalStorageState())) {
|
||||
throw new IOException("No SD card available. Insert or unmount it from USB to download.");
|
||||
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);
|
||||
if (d == null) throw new IOException("Can't use the SD card.");
|
||||
if (!d.exists() && !d.mkdirs()) throw new IOException("Can't create a folder on the SD card.");
|
||||
if (d == null) throw new IOException("Non se pode usar a tarxeta SD.");
|
||||
if (!d.exists() && !d.mkdirs()) throw new IOException("Non se pode crear un cartafol na tarxeta SD.");
|
||||
File nomedia = new File(d, ".nomedia");
|
||||
if (!nomedia.exists()) {
|
||||
try {
|
||||
|
||||
@@ -178,6 +178,12 @@ public class HomeActivity extends BaseActivity {
|
||||
empty.setVisibility(msg == null ? View.GONE : View.VISIBLE);
|
||||
}
|
||||
|
||||
private final Runnable retryRefresh = new Runnable() {
|
||||
public void run() {
|
||||
refresh();
|
||||
}
|
||||
};
|
||||
|
||||
private void refresh() {
|
||||
App.invalidate();
|
||||
Covers.clearMemory();
|
||||
@@ -196,7 +202,7 @@ public class HomeActivity extends BaseActivity {
|
||||
}
|
||||
|
||||
void done(Integer n) {
|
||||
if (n > 0) toast("Synced offline progress");
|
||||
if (n > 0) toast("Sincronizouse o progreso gardado sen conexión");
|
||||
}
|
||||
}.start();
|
||||
}
|
||||
@@ -216,7 +222,7 @@ public class HomeActivity extends BaseActivity {
|
||||
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;
|
||||
}
|
||||
String id = p.libraryId();
|
||||
@@ -267,10 +273,10 @@ public class HomeActivity extends BaseActivity {
|
||||
// older servers: just skip this section
|
||||
}
|
||||
if (!cont.isEmpty()) {
|
||||
rows.add(Rows.Row.header("CONTINUE LISTENING"));
|
||||
rows.add(Rows.Row.header("CONTINUAR ESCOITANDO"));
|
||||
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");
|
||||
int n = eps == null ? 0 : eps.length();
|
||||
@@ -295,8 +301,8 @@ public class HomeActivity extends BaseActivity {
|
||||
}
|
||||
latestPage = page;
|
||||
latestMore = hasMore;
|
||||
if (hasMore) latestCache.add(Rows.Row.more("Load more"));
|
||||
if (tab == TAB_LATEST) show(latestCache, "No episodes yet.");
|
||||
if (hasMore) latestCache.add(Rows.Row.more("Cargar máis"));
|
||||
if (tab == TAB_LATEST) show(latestCache, "Aínda non hai episodios.");
|
||||
}
|
||||
|
||||
void failed(Exception e) {
|
||||
@@ -309,12 +315,16 @@ public class HomeActivity extends BaseActivity {
|
||||
if (more) {
|
||||
toast(Util.describe(e));
|
||||
if (latestCache != null && !latestCache.isEmpty()) {
|
||||
latestCache.get(latestCache.size() - 1).text = "Load more";
|
||||
latestCache.get(latestCache.size() - 1).text = "Cargar máis";
|
||||
adapter.notifyDataSetChanged();
|
||||
}
|
||||
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);
|
||||
}
|
||||
}.start();
|
||||
@@ -358,7 +368,7 @@ public class HomeActivity extends BaseActivity {
|
||||
if (isFinishing()) return;
|
||||
updateLibraryLabel();
|
||||
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) {
|
||||
@@ -368,7 +378,11 @@ public class HomeActivity extends BaseActivity {
|
||||
showError(e);
|
||||
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);
|
||||
}
|
||||
}.start();
|
||||
@@ -377,7 +391,7 @@ public class HomeActivity extends BaseActivity {
|
||||
/** When the server can't be reached but episodes are saved, show those instead. */
|
||||
private void offlineFallback(Exception e) {
|
||||
if (!(e instanceof Api.ApiException) && !Downloads.list(this).isEmpty()) {
|
||||
toast("Can't reach the server. Showing saved episodes.");
|
||||
toast("Non se pode acceder ao servidor. Amósanse os episodios gardados.");
|
||||
selectTab(TAB_SAVED);
|
||||
} else {
|
||||
show(null, null);
|
||||
@@ -391,17 +405,17 @@ public class HomeActivity extends BaseActivity {
|
||||
savedSignature = (cur == null ? "" : cur.id) + "/" + queued.size() + "/" + DownloadService.lastError;
|
||||
if (DownloadService.lastError != null) rows.add(Rows.Row.header(DownloadService.lastError));
|
||||
if (cur != null || !queued.isEmpty()) {
|
||||
rows.add(Rows.Row.header("DOWNLOADING"));
|
||||
rows.add(Rows.Row.header("DESCARGANDO"));
|
||||
if (cur != null) rows.add(Rows.Row.episode(cur));
|
||||
for (Episode q : queued) rows.add(Rows.Row.episode(q));
|
||||
}
|
||||
List<Episode> saved = Downloads.list(this);
|
||||
if (!saved.isEmpty()) {
|
||||
rows.add(Rows.Row.header("SAVED ON SD CARD (" + saved.size() + ")"));
|
||||
rows.add(Rows.Row.header("GARDADOS NA TARXETA SD (" + saved.size() + ")"));
|
||||
for (Episode e : saved) rows.add(Rows.Row.episode(e));
|
||||
}
|
||||
if (rows.isEmpty()) {
|
||||
show(rows, "Nothing saved yet.\n\nLong-press an episode and choose Download to listen offline.");
|
||||
show(rows, "Aínda non gardaches nada.\n\nMantén premido un episodio e escolle Descargar para escoitalo sen conexión.");
|
||||
} else {
|
||||
show(rows, null);
|
||||
}
|
||||
@@ -422,7 +436,7 @@ public class HomeActivity extends BaseActivity {
|
||||
break;
|
||||
case Rows.MORE:
|
||||
if (!loadingLatest && latestMore) {
|
||||
r.text = "Loading…";
|
||||
r.text = "Cargando…";
|
||||
adapter.notifyDataSetChanged();
|
||||
loadLatest(true);
|
||||
}
|
||||
@@ -455,12 +469,12 @@ public class HomeActivity extends BaseActivity {
|
||||
|
||||
private void chooseLibrary() {
|
||||
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;
|
||||
}
|
||||
String[] names = new String[podcastLibs.size()];
|
||||
for (int i = 0; i < names.length; i++) names[i] = podcastLibs.get(i)[1];
|
||||
new AlertDialog.Builder(this).setTitle("Podcast library")
|
||||
new AlertDialog.Builder(this).setTitle("Biblioteca de podcasts")
|
||||
.setItems(names, new DialogInterface.OnClickListener() {
|
||||
public void onClick(DialogInterface d, int which) {
|
||||
String[] l = podcastLibs.get(which);
|
||||
@@ -477,10 +491,10 @@ public class HomeActivity extends BaseActivity {
|
||||
|
||||
@Override
|
||||
public boolean onCreateOptionsMenu(Menu menu) {
|
||||
menu.add(0, 1, 0, "Refresh").setIcon(android.R.drawable.ic_menu_rotate);
|
||||
menu.add(0, 2, 0, "Library").setIcon(android.R.drawable.ic_menu_agenda);
|
||||
menu.add(0, 3, 0, "Now playing").setIcon(android.R.drawable.ic_media_play);
|
||||
menu.add(0, 4, 0, "Settings").setIcon(android.R.drawable.ic_menu_preferences);
|
||||
menu.add(0, 1, 0, "Actualizar").setIcon(android.R.drawable.ic_menu_rotate);
|
||||
menu.add(0, 2, 0, "Biblioteca").setIcon(android.R.drawable.ic_menu_agenda);
|
||||
menu.add(0, 3, 0, "En reprodución").setIcon(android.R.drawable.ic_media_play);
|
||||
menu.add(0, 4, 0, "Axustes").setIcon(android.R.drawable.ic_menu_preferences);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -88,13 +88,13 @@ final class Json {
|
||||
return JSONObject.NULL;
|
||||
default:
|
||||
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 {
|
||||
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;
|
||||
}
|
||||
while (true) {
|
||||
if (c != '"') throw new IOException("Unexpected reply from server");
|
||||
if (c != '"') throw new IOException("Resposta inesperada do servidor");
|
||||
String key = string(512);
|
||||
c = skipWs();
|
||||
if (c != ':') throw new IOException("Unexpected reply from server");
|
||||
if (c != ':') throw new IOException("Resposta inesperada do servidor");
|
||||
c = skipWs();
|
||||
if (skipKey(key)) {
|
||||
skipValue(c);
|
||||
@@ -173,7 +173,7 @@ final class Json {
|
||||
depth--;
|
||||
return o;
|
||||
}
|
||||
throw new IOException("Unexpected reply from server");
|
||||
throw new IOException("Resposta inesperada do servidor");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -189,7 +189,7 @@ final class Json {
|
||||
continue;
|
||||
}
|
||||
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;
|
||||
while (d > 0) {
|
||||
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);
|
||||
else if (ch == '{' || ch == '[') d++;
|
||||
else if (ch == '}' || ch == ']') d--;
|
||||
@@ -217,7 +217,7 @@ final class Json {
|
||||
boolean cut = false;
|
||||
while (true) {
|
||||
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 == '\\') {
|
||||
c = read();
|
||||
@@ -231,14 +231,14 @@ final class Json {
|
||||
int v = 0;
|
||||
for (int i = 0; i < 4; i++) {
|
||||
int d = Character.digit(read(), 16);
|
||||
if (d < 0) throw new IOException("Unexpected reply from server");
|
||||
if (d < 0) throw new IOException("Resposta inesperada do servidor");
|
||||
v = v * 16 + d;
|
||||
}
|
||||
c = v;
|
||||
break;
|
||||
}
|
||||
case -1:
|
||||
throw new IOException("Reply was cut off");
|
||||
throw new IOException("A resposta chegou incompleta");
|
||||
default:
|
||||
break; // \" \\ \/
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ import android.widget.TextView;
|
||||
|
||||
public class LoginActivity extends Activity {
|
||||
private EditText server, user, pass;
|
||||
private CheckBox useKey, trust;
|
||||
private CheckBox useKey;
|
||||
private TextView status, userLabel, passLabel;
|
||||
private Button connect;
|
||||
|
||||
@@ -26,7 +26,6 @@ public class LoginActivity extends Activity {
|
||||
user = (EditText) findViewById(R.id.user);
|
||||
pass = (EditText) findViewById(R.id.pass);
|
||||
useKey = (CheckBox) findViewById(R.id.use_key);
|
||||
trust = (CheckBox) findViewById(R.id.trust);
|
||||
status = (TextView) findViewById(R.id.status);
|
||||
userLabel = (TextView) findViewById(R.id.user_label);
|
||||
passLabel = (TextView) findViewById(R.id.pass_label);
|
||||
@@ -35,7 +34,6 @@ public class LoginActivity extends Activity {
|
||||
Prefs p = Prefs.get(this);
|
||||
server.setText(p.server());
|
||||
user.setText(p.username());
|
||||
trust.setChecked(p.trustAll());
|
||||
String msg = getIntent().getStringExtra("message");
|
||||
if (msg != null) status.setText(msg);
|
||||
|
||||
@@ -44,7 +42,7 @@ public class LoginActivity extends Activity {
|
||||
int vis = on ? View.GONE : View.VISIBLE;
|
||||
user.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
|
||||
: InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PASSWORD);
|
||||
pass.setText("");
|
||||
@@ -71,23 +69,24 @@ public class LoginActivity extends Activity {
|
||||
final String pw = pass.getText().toString();
|
||||
final boolean key = useKey.isChecked();
|
||||
if (srv.length() == 0) {
|
||||
status.setText("Enter your server address.");
|
||||
status.setText("Escribe o enderezo do servidor.");
|
||||
return;
|
||||
}
|
||||
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;
|
||||
}
|
||||
server.setText(srv);
|
||||
((InputMethodManager) getSystemService(INPUT_METHOD_SERVICE)).hideSoftInputFromWindow(pass.getWindowToken(), 0);
|
||||
connect.setEnabled(false);
|
||||
status.setText("Connecting…");
|
||||
status.setText("Conectando…");
|
||||
|
||||
final Prefs p = Prefs.get(this);
|
||||
p.logout();
|
||||
App.clear();
|
||||
HomeActivity.clearCache();
|
||||
p.setServer(srv, trust.isChecked());
|
||||
p.setServer(srv);
|
||||
Trust.lastRejected = null;
|
||||
new Job<String>() {
|
||||
String work() throws Exception {
|
||||
String version = Api.status(LoginActivity.this, srv);
|
||||
@@ -111,6 +110,11 @@ public class LoginActivity extends Activity {
|
||||
void failed(Exception e) {
|
||||
connect.setEnabled(true);
|
||||
status.setText(Util.describe(e));
|
||||
TrustPrompt.offer(LoginActivity.this, new Runnable() {
|
||||
public void run() {
|
||||
go();
|
||||
}
|
||||
});
|
||||
}
|
||||
}.start();
|
||||
}
|
||||
|
||||
@@ -167,9 +167,9 @@ public class PlayerActivity extends BaseActivity {
|
||||
Episode e = current();
|
||||
if (e == null) {
|
||||
show.setText("");
|
||||
title.setText("Nothing playing");
|
||||
title.setText("Non se está a reproducir nada");
|
||||
date.setText("");
|
||||
status.setText("Pick an episode from the list.");
|
||||
status.setText("Escolle un episodio da lista.");
|
||||
desc.setText("");
|
||||
cover.setVisibility(View.GONE);
|
||||
setControls(false);
|
||||
@@ -181,7 +181,7 @@ public class PlayerActivity extends BaseActivity {
|
||||
title.setText(e.title);
|
||||
date.setText(Util.join(Util.date(e.published), e.duration > 0 ? Util.duration(e.duration) : ""));
|
||||
desc.setText(Util.plainText(e.desc));
|
||||
Covers.load(this, cover, e.itemId, (int) (84 * getResources().getDisplayMetrics().density));
|
||||
Covers.load(this, cover, e.itemId, getResources().getDimensionPixelSize(R.dimen.cover_player));
|
||||
}
|
||||
|
||||
PlayerService ps = PlayerService.get();
|
||||
@@ -195,8 +195,8 @@ public class PlayerActivity extends BaseActivity {
|
||||
playing = ps.isPlaying() || (ps.state() == PlayerService.LOADING);
|
||||
st = ps.status();
|
||||
long sleep = ps.sleepLeftMs();
|
||||
if (sleep == -1) st = Util.join(st, "Sleep at end");
|
||||
else if (sleep > 0) st = Util.join(st, "Sleep in " + Util.clock(sleep));
|
||||
if (sleep == -1) st = Util.join(st, "Pausa ao final do episodio");
|
||||
else if (sleep > 0) st = Util.join(st, "Pausa en " + Util.clock(sleep));
|
||||
seek.setSecondaryProgress(ps.bufferedPercent() * 10);
|
||||
setControls(true);
|
||||
} else {
|
||||
@@ -204,7 +204,7 @@ public class PlayerActivity extends BaseActivity {
|
||||
double cur = pr != null ? pr[0] : Math.max(0, App.localPosition(this, e.id));
|
||||
p = (int) (cur * 1000);
|
||||
dur = (int) (e.duration * 1000);
|
||||
st = pending != null ? "Connecting…" : (Downloads.has(this, e.id) ? "Saved on SD card" : "");
|
||||
st = pending != null ? "Conectando…" : (Downloads.has(this, e.id) ? "Gardado na tarxeta SD" : "");
|
||||
playing = pending != null;
|
||||
seek.setSecondaryProgress(0);
|
||||
setControls(true);
|
||||
@@ -234,17 +234,17 @@ public class PlayerActivity extends BaseActivity {
|
||||
if (e == null) return false;
|
||||
PlayerService ps = PlayerService.get();
|
||||
boolean active = ps != null && ps.episode() != null && ps.episode().id.equals(e.id);
|
||||
if (active) menu.add(0, 1, 0, "Sleep timer").setIcon(android.R.drawable.ic_lock_idle_alarm);
|
||||
menu.add(0, 2, 0, "Mark as played").setIcon(android.R.drawable.ic_menu_agenda);
|
||||
if (active) menu.add(0, 1, 0, "Temporizador de pausa").setIcon(android.R.drawable.ic_lock_idle_alarm);
|
||||
menu.add(0, 2, 0, "Marcar como escoitado").setIcon(android.R.drawable.ic_menu_agenda);
|
||||
if (DownloadService.busyWith(e.id)) {
|
||||
menu.add(0, 3, 0, "Cancel download").setIcon(android.R.drawable.ic_menu_close_clear_cancel);
|
||||
menu.add(0, 3, 0, "Cancelar descarga").setIcon(android.R.drawable.ic_menu_close_clear_cancel);
|
||||
} else if (Downloads.has(this, e.id)) {
|
||||
menu.add(0, 3, 0, "Delete download").setIcon(android.R.drawable.ic_menu_delete);
|
||||
menu.add(0, 3, 0, "Eliminar descarga").setIcon(android.R.drawable.ic_menu_delete);
|
||||
} 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);
|
||||
if (active) menu.add(0, 5, 0, "Stop").setIcon(android.R.drawable.ic_menu_close_clear_cancel);
|
||||
menu.add(0, 4, 0, "Ir ao programa").setIcon(android.R.drawable.ic_menu_view);
|
||||
if (active) menu.add(0, 5, 0, "Deter").setIcon(android.R.drawable.ic_menu_close_clear_cancel);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -261,7 +261,7 @@ public class PlayerActivity extends BaseActivity {
|
||||
case 2:
|
||||
if (active) {
|
||||
ps.finishAndStop();
|
||||
toast("Marked as played");
|
||||
toast("Marcado como escoitado");
|
||||
} else {
|
||||
episodeAction(M_PLAYED, e, null);
|
||||
}
|
||||
@@ -285,15 +285,15 @@ public class PlayerActivity extends BaseActivity {
|
||||
|
||||
private void sleepDialog() {
|
||||
final int[] minutes = {0, 5, 10, 15, 30, 45, 60, -1};
|
||||
String[] labels = {"Off", "5 minutes", "10 minutes", "15 minutes", "30 minutes", "45 minutes", "1 hour",
|
||||
"End of episode"};
|
||||
new AlertDialog.Builder(this).setTitle("Sleep timer")
|
||||
String[] labels = {"Desactivado", "5 minutos", "10 minutos", "15 minutos", "30 minutos", "45 minutos", "1 hora",
|
||||
"Ao final do episodio"};
|
||||
new AlertDialog.Builder(this).setTitle("Temporizador de pausa")
|
||||
.setItems(labels, new DialogInterface.OnClickListener() {
|
||||
public void onClick(DialogInterface d, int which) {
|
||||
PlayerService ps = PlayerService.get();
|
||||
if (ps == null) return;
|
||||
ps.setSleep(minutes[which]);
|
||||
toast(minutes[which] == 0 ? "Sleep timer off" : "Sleep timer set");
|
||||
toast(minutes[which] == 0 ? "Temporizador desactivado" : "Temporizador activado");
|
||||
refresh();
|
||||
}
|
||||
}).show();
|
||||
|
||||
@@ -129,6 +129,7 @@ public class PlayerService extends Service implements MediaPlayer.OnPreparedList
|
||||
if (ep != null && prepared && state != ENDED) syncNow(true);
|
||||
releasePlayer();
|
||||
net.shutdown();
|
||||
StreamProxy.shutdown();
|
||||
try {
|
||||
unregisterReceiver(noisy);
|
||||
} catch (Exception ignored) {
|
||||
@@ -255,7 +256,7 @@ public class PlayerService extends Service implements MediaPlayer.OnPreparedList
|
||||
}
|
||||
lastPosMs = positionMs();
|
||||
state = PAUSED;
|
||||
status = "Paused";
|
||||
status = "En pausa";
|
||||
pausedAt = SystemClock.elapsedRealtime();
|
||||
syncNow(false);
|
||||
wifi.release();
|
||||
@@ -344,7 +345,7 @@ public class PlayerService extends Service implements MediaPlayer.OnPreparedList
|
||||
markFinished();
|
||||
releasePlayer();
|
||||
state = ENDED;
|
||||
status = "Marked as played";
|
||||
status = "Marcado como escoitado";
|
||||
lastPosMs = dur;
|
||||
pausedAt = SystemClock.elapsedRealtime();
|
||||
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) {
|
||||
releasePlayer();
|
||||
state = LOADING;
|
||||
status = "Connecting…";
|
||||
status = "Conectando…";
|
||||
playOnPrepared = playNow;
|
||||
showNotification(true);
|
||||
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) {
|
||||
Source s = new Source();
|
||||
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);
|
||||
String cu = t == null ? "" : Util.str(t, "contentUrl");
|
||||
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")) {
|
||||
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 {
|
||||
s.contentUrl = cu;
|
||||
s.url = Api.withToken(c, Api.absolute(c, cu));
|
||||
s.url = streamUrl(c, cu);
|
||||
}
|
||||
}
|
||||
} catch (Exception ex) {
|
||||
@@ -435,7 +442,7 @@ public class PlayerService extends Service implements MediaPlayer.OnPreparedList
|
||||
local = s.local;
|
||||
resumeMs = (int) (s.start * 1000);
|
||||
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) {
|
||||
@@ -460,10 +467,10 @@ public class PlayerService extends Service implements MediaPlayer.OnPreparedList
|
||||
wifi.acquire();
|
||||
}
|
||||
state = LOADING;
|
||||
status = note != null ? note : local ? "Opening saved file…" : "Buffering…";
|
||||
status = note != null ? note : local ? "Abrindo o ficheiro gardado…" : "Cargando…";
|
||||
mp.prepareAsync();
|
||||
} 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();
|
||||
} else {
|
||||
state = PAUSED;
|
||||
status = "Paused";
|
||||
status = "En pausa";
|
||||
showNotification(false);
|
||||
}
|
||||
}
|
||||
@@ -499,12 +506,12 @@ public class PlayerService extends Service implements MediaPlayer.OnPreparedList
|
||||
mp.setVolume(1f, 1f);
|
||||
mp.start();
|
||||
} catch (Exception ex) {
|
||||
fail("Playback failed: " + Util.describe(ex));
|
||||
fail("Fallou a reprodución: " + Util.describe(ex));
|
||||
return;
|
||||
}
|
||||
if (!local) wifi.acquire();
|
||||
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();
|
||||
lastTick = 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.
|
||||
if (!local && dur > 0 && pos < dur - 30000) {
|
||||
lastPosMs = pos;
|
||||
retryStream("Connection lost, reconnecting…");
|
||||
retryStream("Perdeuse a conexión, reconectando…");
|
||||
return;
|
||||
}
|
||||
state = ENDED;
|
||||
status = "Finished";
|
||||
status = "Rematado";
|
||||
lastPosMs = dur;
|
||||
pausedAt = SystemClock.elapsedRealtime();
|
||||
wifi.release();
|
||||
@@ -566,9 +573,9 @@ public class PlayerService extends Service implements MediaPlayer.OnPreparedList
|
||||
if (p != mp) return true;
|
||||
if (prepared) lastPosMs = positionMs();
|
||||
if (!local && contentUrl != null) {
|
||||
retryStream("Connection lost, reconnecting…");
|
||||
retryStream("Perdeuse a conexión, reconectando…");
|
||||
} else {
|
||||
fail("Playback error (" + what + "/" + extra + ").");
|
||||
fail("Erro de reprodución (" + what + "/" + extra + ").");
|
||||
}
|
||||
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. */
|
||||
private void retryStream(final String msg) {
|
||||
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;
|
||||
return;
|
||||
}
|
||||
@@ -603,7 +610,11 @@ public class PlayerService extends Service implements MediaPlayer.OnPreparedList
|
||||
if (gen != generation) return;
|
||||
resumeMs = pos;
|
||||
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) {
|
||||
sleepAt = 0;
|
||||
pause();
|
||||
status = "Sleep timer: paused";
|
||||
status = "Temporizador: en pausa";
|
||||
}
|
||||
} else if (state == PAUSED || state == ENDED || state == ERROR) {
|
||||
// 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);
|
||||
String what;
|
||||
switch (state) {
|
||||
case PLAYING: what = "Playing"; break;
|
||||
case LOADING: what = "Loading…"; break;
|
||||
case ENDED: what = "Finished"; break;
|
||||
case ERROR: what = "Stopped: tap to retry"; break;
|
||||
default: what = "Paused"; break;
|
||||
case PLAYING: what = "Reproducindo"; break;
|
||||
case LOADING: what = "Cargando…"; break;
|
||||
case ENDED: what = "Rematado"; break;
|
||||
case ERROR: what = "Detido: toca para tentalo de novo"; break;
|
||||
default: what = "En pausa"; break;
|
||||
}
|
||||
n.setLatestEventInfo(this, ep.title, Util.join(what, ep.show), pi);
|
||||
if (foreground) {
|
||||
|
||||
@@ -45,7 +45,7 @@ public class PodcastActivity extends BaseActivity {
|
||||
title.setText(getIntent().getStringExtra("title"));
|
||||
subtitle.setText(getIntent().getStringExtra("author"));
|
||||
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);
|
||||
oldestFirst = p.sp.getBoolean("oldest_first", false);
|
||||
@@ -104,7 +104,7 @@ public class PodcastActivity extends BaseActivity {
|
||||
loading.setVisibility(View.GONE);
|
||||
episodes = list;
|
||||
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));
|
||||
render();
|
||||
}
|
||||
@@ -117,6 +117,11 @@ public class PodcastActivity extends BaseActivity {
|
||||
}
|
||||
empty.setText(Util.describe(e));
|
||||
empty.setVisibility(View.VISIBLE);
|
||||
offerTrust(new Runnable() {
|
||||
public void run() {
|
||||
load();
|
||||
}
|
||||
});
|
||||
}
|
||||
}.start();
|
||||
}
|
||||
@@ -140,10 +145,10 @@ public class PodcastActivity extends BaseActivity {
|
||||
}
|
||||
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);
|
||||
boolean none = rows.isEmpty();
|
||||
empty.setText(none ? "No episodes." : "");
|
||||
empty.setText(none ? "Non hai episodios." : "");
|
||||
empty.setVisibility(none ? View.VISIBLE : View.GONE);
|
||||
}
|
||||
|
||||
@@ -167,10 +172,10 @@ public class PodcastActivity extends BaseActivity {
|
||||
@Override
|
||||
public boolean onPrepareOptionsMenu(Menu menu) {
|
||||
menu.clear();
|
||||
menu.add(0, 1, 0, oldestFirst ? "Newest first" : "Oldest first").setIcon(android.R.drawable.ic_menu_sort_by_size);
|
||||
menu.add(0, 2, 0, hidePlayed ? "Show played" : "Hide played").setIcon(android.R.drawable.ic_menu_view);
|
||||
menu.add(0, 3, 0, "Refresh").setIcon(android.R.drawable.ic_menu_rotate);
|
||||
menu.add(0, 4, 0, "Now playing").setIcon(android.R.drawable.ic_media_play);
|
||||
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 ? "Amosar escoitados" : "Ocultar escoitados").setIcon(android.R.drawable.ic_menu_view);
|
||||
menu.add(0, 3, 0, "Actualizar").setIcon(android.R.drawable.ic_menu_rotate);
|
||||
menu.add(0, 4, 0, "En reprodución").setIcon(android.R.drawable.ic_media_play);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -36,7 +36,6 @@ final class Prefs {
|
||||
String token() { return s("access"); }
|
||||
String refreshToken() { return s("refresh"); }
|
||||
String mode() { return s("mode"); }
|
||||
boolean trustAll() { return sp.getBoolean("trust_all", false); }
|
||||
boolean wifiOnly() { return sp.getBoolean("wifi_only", true); }
|
||||
boolean covers() { return sp.getBoolean("covers", true); }
|
||||
String libraryId() { return s("library_id"); }
|
||||
@@ -61,8 +60,8 @@ final class Prefs {
|
||||
return MODE_JWT.equals(mode()) && refreshToken().length() > 0;
|
||||
}
|
||||
|
||||
void setServer(String server, boolean trustAll) {
|
||||
sp.edit().putString("server", server).putBoolean("trust_all", trustAll).commit();
|
||||
void setServer(String server) {
|
||||
sp.edit().putString("server", server).remove("trust_all").commit();
|
||||
}
|
||||
|
||||
synchronized void setAuth(String user, String mode, String access, String refresh) {
|
||||
|
||||
@@ -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
|
||||
"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",
|
||||
// GTS_Root_R1 sha256=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"
|
||||
+ "NQzcmRk13NfIRmPVNnGuV/u3gm3c",
|
||||
// USERTrust_RSA_Certification_Authority sha256=e793c9b02fd8aa13e21c31228accb08119643b749c898964b1746d46c3d4cbd2
|
||||
"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"
|
||||
+ "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
|
||||
"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"
|
||||
+ "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",
|
||||
};
|
||||
}
|
||||
@@ -78,7 +78,7 @@ final class Rows extends BaseAdapter {
|
||||
Rows(Activity a) {
|
||||
act = 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) {
|
||||
@@ -147,7 +147,7 @@ final class Rows extends BaseAdapter {
|
||||
|
||||
private void bindShow(View v, Show s) {
|
||||
((TextView) v.findViewById(R.id.title)).setText(s.title);
|
||||
String n = s.episodes == 1 ? "1 episode" : s.episodes + " episodes";
|
||||
String n = s.episodes == 1 ? "1 episodio" : s.episodes + " episodios";
|
||||
((TextView) v.findViewById(R.id.meta)).setText(Util.join(s.author, n));
|
||||
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);
|
||||
if (finished) {
|
||||
m = Util.join(m, "Played");
|
||||
m = Util.join(m, "Escoitado");
|
||||
} 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) {
|
||||
m = Util.join(m, Util.duration(dur));
|
||||
}
|
||||
if (DownloadService.busyWith(e.id)) {
|
||||
Episode now = DownloadService.current;
|
||||
m = Util.join(m, now != null && now.id.equals(e.id) ? "Saving " + DownloadService.percent + "%" : "Queued");
|
||||
m = Util.join(m, now != null && now.id.equals(e.id) ? "Gardando " + DownloadService.percent + "%" : "Na cola");
|
||||
} else if (Downloads.has(act, e.id)) {
|
||||
m = Util.join(m, "Saved");
|
||||
m = Util.join(m, "Gardado");
|
||||
}
|
||||
PlayerService ps = PlayerService.get();
|
||||
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);
|
||||
|
||||
|
||||
@@ -17,14 +17,14 @@ public class SettingsActivity extends PreferenceActivity {
|
||||
|
||||
Preference.OnPreferenceChangeListener skipSummary = new Preference.OnPreferenceChangeListener() {
|
||||
public boolean onPreferenceChange(Preference pref, Object value) {
|
||||
pref.setSummary(value + " seconds");
|
||||
pref.setSummary(value + " segundos");
|
||||
return true;
|
||||
}
|
||||
};
|
||||
ListPreference back = (ListPreference) findPreference("skip_back");
|
||||
ListPreference fwd = (ListPreference) findPreference("skip_fwd");
|
||||
back.setSummary(p.skipBack() + " seconds");
|
||||
fwd.setSummary(p.skipFwd() + " seconds");
|
||||
back.setSummary(p.skipBack() + " segundos");
|
||||
fwd.setSummary(p.skipFwd() + " segundos");
|
||||
back.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("delete_all").setOnPreferenceClickListener(new Preference.OnPreferenceClickListener() {
|
||||
public boolean onPreferenceClick(Preference pref) {
|
||||
new AlertDialog.Builder(SettingsActivity.this)
|
||||
.setMessage("Delete every downloaded episode from the SD card?")
|
||||
.setPositiveButton("Delete", new DialogInterface.OnClickListener() {
|
||||
.setMessage("Eliminar todos os episodios descargados da tarxeta SD?")
|
||||
.setPositiveButton("Eliminar", new DialogInterface.OnClickListener() {
|
||||
public void onClick(DialogInterface d, int w) {
|
||||
PlayerService ps = PlayerService.get();
|
||||
if (ps != null && ps.isLocal()) ps.stop();
|
||||
int n = Downloads.deleteAll(SettingsActivity.this);
|
||||
Toast.makeText(SettingsActivity.this, n + " deleted", Toast.LENGTH_SHORT).show();
|
||||
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;
|
||||
}
|
||||
});
|
||||
@@ -58,8 +78,8 @@ public class SettingsActivity extends PreferenceActivity {
|
||||
findPreference("logout").setOnPreferenceClickListener(new Preference.OnPreferenceClickListener() {
|
||||
public boolean onPreferenceClick(Preference pref) {
|
||||
new AlertDialog.Builder(SettingsActivity.this)
|
||||
.setMessage("Log out of " + p.server() + "? Downloads stay on the SD card.")
|
||||
.setPositiveButton("Log out", new DialogInterface.OnClickListener() {
|
||||
.setMessage("Pechar sesión en " + p.server() + "? As descargas quedan na tarxeta SD.")
|
||||
.setPositiveButton("Pechar sesión", new DialogInterface.OnClickListener() {
|
||||
public void onClick(DialogInterface d, int w) {
|
||||
PlayerService ps = PlayerService.get();
|
||||
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
|
||||
}
|
||||
})
|
||||
.setNegativeButton("Cancel", null).show();
|
||||
.setNegativeButton("Cancelar", null).show();
|
||||
return true;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -5,10 +5,7 @@ import java.net.ConnectException;
|
||||
import java.net.SocketException;
|
||||
import java.net.SocketTimeoutException;
|
||||
import java.net.UnknownHostException;
|
||||
import java.text.SimpleDateFormat;
|
||||
import java.util.Calendar;
|
||||
import java.util.Date;
|
||||
import java.util.Locale;
|
||||
|
||||
import javax.net.ssl.SSLException;
|
||||
|
||||
@@ -47,12 +44,24 @@ final class Util {
|
||||
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) {
|
||||
if (ms <= 0) return "";
|
||||
Calendar now = Calendar.getInstance(), c = Calendar.getInstance();
|
||||
c.setTimeInMillis(ms);
|
||||
String pattern = c.get(Calendar.YEAR) == now.get(Calendar.YEAR) ? "d MMM" : "d MMM yyyy";
|
||||
return new SimpleDateFormat(pattern, Locale.getDefault()).format(new Date(ms));
|
||||
return date(ms, c.get(Calendar.YEAR) != now.get(Calendar.YEAR));
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -87,21 +96,28 @@ final class Util {
|
||||
if (e instanceof Api.AuthException) return e.getMessage();
|
||||
if (e instanceof Api.ApiException) {
|
||||
Api.ApiException a = (Api.ApiException) e;
|
||||
if (a.code == 401) return "Not signed in. Please log in again.";
|
||||
if (a.code == 403) return "Your account isn't allowed to do that.";
|
||||
if (a.code == 404) return "Not found on the server.";
|
||||
if (a.code == 401) return "Non iniciaches sesión. Inicia sesión de novo.";
|
||||
if (a.code == 403) return "A túa conta non ten permiso para facer iso.";
|
||||
if (a.code == 404) return "Non se atopou no servidor.";
|
||||
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 SocketTimeoutException) return "The server took too long to answer.";
|
||||
if (e instanceof ConnectException) return "Can't connect to the server. Is it running and reachable?";
|
||||
if (e instanceof UnknownHostException) return "Non se atopou o servidor. Comproba o enderezo e a conexión.";
|
||||
if (e instanceof SocketTimeoutException) return "O servidor tardou demasiado en responder.";
|
||||
if (e instanceof ConnectException) return "Non se pode conectar co servidor. Está en marcha e accesible?";
|
||||
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.";
|
||||
}
|
||||
if (e instanceof SocketException) return "Connection dropped. Check your network.";
|
||||
if (e instanceof FileNotFoundException) return "Not found on the server.";
|
||||
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 "Cortouse a conexión. Comproba a rede.";
|
||||
if (e instanceof FileNotFoundException) return "Non se atopou no servidor.";
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -48,14 +48,14 @@ public class ApiTest {
|
||||
check(cut.getString("d").equals("0123456789…"), "long strings truncated with an ellipsis");
|
||||
|
||||
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(Api.status(c, p.server()).equals("2.30.0"), "status check finds the server");
|
||||
try {
|
||||
Api.login(c, p.server(), "demo", "wrong");
|
||||
check(false, "wrong password rejected");
|
||||
} catch (Api.ApiException e) {
|
||||
check(e.code == 401 && e.getMessage().contains("Wrong"), "wrong password rejected with a clear message");
|
||||
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");
|
||||
check(lib.equals("lib-pod"), "login returns default library");
|
||||
@@ -193,7 +193,7 @@ public class ApiTest {
|
||||
}
|
||||
|
||||
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"),
|
||||
"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"),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -14,6 +14,8 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
|
||||
PORT = int(sys.argv[1])
|
||||
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()
|
||||
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,
|
||||
"audioTracks": [{"index": 1, "contentUrl": "/api/items/pod1/file/ino7",
|
||||
"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":
|
||||
return self.send(200, "OK", "text/plain")
|
||||
if path.startswith("/api/me/progress/") and method == "PATCH":
|
||||
@@ -172,8 +194,19 @@ class H(BaseHTTPRequestHandler):
|
||||
def do_POST(self):
|
||||
self.handle_any("POST")
|
||||
|
||||
def do_HEAD(self):
|
||||
self.handle_any("HEAD")
|
||||
|
||||
def do_PATCH(self):
|
||||
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()
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/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
|
||||
cd "$(dirname "$0")/.."
|
||||
OUT=build/test
|
||||
@@ -7,14 +7,30 @@ JSON_JAR=/usr/share/java/com.android.json.jar
|
||||
rm -rf "$OUT" && mkdir -p "$OUT"
|
||||
S=src/com/miniabs
|
||||
javac -nowarn -encoding UTF-8 -cp "$JSON_JAR" -d "$OUT" $(find test/shim -name '*.java') \
|
||||
$S/Json.java $S/Prefs.java $S/Episode.java $S/Util.java $S/Api.java $S/App.java test/com/miniabs/ApiTest.java
|
||||
$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
|
||||
PORT=$((18000 + $(od -An -N2 -tu2 /dev/urandom | tr -d ' ') % 1000))
|
||||
python3 test/mock_abs.py $PORT $mode &
|
||||
PID=$!
|
||||
P=$(port)
|
||||
python3 test/mock_abs.py $P $mode & PIDS="$PIDS $!"
|
||||
sleep 1
|
||||
echo "=== $mode server"
|
||||
java -cp "$OUT:$JSON_JAR" com.miniabs.ApiTest $PORT $mode || { kill $PID; exit 1; }
|
||||
kill $PID
|
||||
echo "=== $mode server (http)"
|
||||
java -cp "$CP" com.miniabs.ApiTest $P $mode
|
||||
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
|
||||
@@ -1,5 +1,8 @@
|
||||
package android.util;
|
||||
public class Base64 {
|
||||
public static final int URL_SAFE = 8;
|
||||
public static byte[] decode(String s, int flags) { return java.util.Base64.getUrlDecoder().decode(s); }
|
||||
public static final int DEFAULT = 0, NO_WRAP = 2, URL_SAFE = 8;
|
||||
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); }
|
||||
}
|
||||
@@ -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))
|
||||
@@ -88,9 +88,9 @@ def refresh(px, folder):
|
||||
for folder, k in DENS.items():
|
||||
launcher(round(48 * k), folder)
|
||||
status(round(25 * k), folder)
|
||||
play(round(40 * k), folder, "ic_play", DARK)
|
||||
pause(round(40 * k), folder, "ic_pause", DARK)
|
||||
play(round(28 * k), folder, "ic_play_small", AMBER)
|
||||
pause(round(28 * k), folder, "ic_pause_small", AMBER)
|
||||
refresh(round(24 * k), folder)
|
||||
play(round(32 * k), folder, "ic_play", DARK)
|
||||
pause(round(32 * k), folder, "ic_pause", DARK)
|
||||
play(round(38 * k), folder, "ic_play_small", AMBER)
|
||||
pause(round(38 * k), folder, "ic_pause_small", AMBER)
|
||||
refresh(round(30 * k), folder)
|
||||
print("icons ok")
|
||||
@@ -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")
|
||||