Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4da8487ef9 | ||
|
|
5cbde5e7f9 | ||
|
|
804493aee0 | ||
|
|
d64e810866 |
@@ -96,11 +96,20 @@ jobs:
|
||||
# Reuse the release if this tag was built before.
|
||||
id=$(curl -fsS -H "$auth" "$api/releases/tags/$REF_NAME" 2>/dev/null | node -p 'JSON.parse(require("fs").readFileSync(0)).id' || true)
|
||||
if [ -z "$id" ]; then
|
||||
body=$(node -e 'console.log(JSON.stringify({ tag_name: process.argv[1], name: process.argv[1], body: "Android builds. musicshare-*-foss.apk has no closed-source code (no Google ML Kit)." }))' "$REF_NAME")
|
||||
# The changelog first (the app shows the text above "---" as what's new), then a note for this page.
|
||||
body=$(node -e '
|
||||
const fs = require("fs");
|
||||
const code = require("../apps/mobile/app.json").expo.android.versionCode;
|
||||
const file = `../fastlane/metadata/android/en-US/changelogs/${code}.txt`;
|
||||
const notes = fs.existsSync(file) ? fs.readFileSync(file, "utf8").trim() : "";
|
||||
const note = "musicshare-*-foss.apk has no closed-source code (no Google ML Kit); it does not update itself.";
|
||||
const tag = process.argv[1];
|
||||
console.log(JSON.stringify({ tag_name: tag, name: tag, body: notes ? `${notes}\n\n---\n${note}` : `---\n${note}` }));
|
||||
' "$REF_NAME")
|
||||
id=$(curl -fsS -X POST -H "$auth" -H 'Content-Type: application/json' -d "$body" "$api/releases" | node -p 'JSON.parse(require("fs").readFileSync(0)).id')
|
||||
fi
|
||||
for f in *.apk SHA256SUMS; do
|
||||
old=$(curl -fsS -H "$auth" "$api/releases/$id/assets" | node -p 'JSON.parse(require("fs").readFileSync(0)).find(a => a.name === process.argv[1])?.id ?? ""' "$f")
|
||||
old=$(curl -fsS -H "$auth" "$api/releases/$id/assets" | node -p 'JSON.parse(require("fs").readFileSync(0, "utf8") || "[]").find(a => a.name === process.argv[1])?.id ?? ""' "$f")
|
||||
[ -n "$old" ] && curl -fsS -X DELETE -H "$auth" "$api/releases/$id/assets/$old"
|
||||
curl -fsS -X POST -H "$auth" -F "attachment=@$f" "$api/releases/$id/assets?name=$f" > /dev/null
|
||||
echo "Attached $f"
|
||||
|
||||
@@ -11,6 +11,7 @@ import { loadUser } from './lib/auth';
|
||||
import { HttpError } from './lib/http';
|
||||
import { redis } from './lib/redis';
|
||||
import { adminRoutes } from './routes/admin';
|
||||
import { appUpdateRoutes } from './routes/appUpdates';
|
||||
import { accountRoutes } from './routes/account';
|
||||
import { authRoutes } from './routes/auth';
|
||||
import { avatarRoutes } from './routes/avatars';
|
||||
@@ -81,6 +82,7 @@ export async function buildApp({ logger = true, rateLimits = true }: { logger?:
|
||||
await app.register(connectionRoutes);
|
||||
await app.register(deviceRoutes);
|
||||
await app.register(realtimeRoutes);
|
||||
await app.register(appUpdateRoutes);
|
||||
// registered as its own plugin so its permissive body parsers stay scoped to webhook routes
|
||||
await app.register(webhookRoutes);
|
||||
|
||||
|
||||
@@ -34,6 +34,13 @@ const schema = z.object({
|
||||
.string()
|
||||
.default('https://lrclib.net')
|
||||
.transform((v) => v.replace(/\/+$/, '')),
|
||||
/**
|
||||
* App updates: the Gitea/Forgejo API URL of the latest Android release, e.g.
|
||||
* https://gitea.example.com/api/v1/repos/owner/musicshare/releases/latest. Empty turns update checks off.
|
||||
* The token (read access to the repo) is only needed when the repo is private; downloads go through us.
|
||||
*/
|
||||
ANDROID_RELEASES_URL: z.string().default(''),
|
||||
ANDROID_RELEASES_TOKEN: optional,
|
||||
/** Apple Music (MusicKit): Team ID, MusicKit key ID, and the key's .p8 contents (newlines may be written as \n). */
|
||||
APPLE_TEAM_ID: optional,
|
||||
APPLE_MUSICKIT_KEY_ID: optional,
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { Readable } from 'node:stream';
|
||||
import type { ReadableStream as WebReadableStream } from 'node:stream/web';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import type { AppUpdateDTO } from '@musicshare/shared';
|
||||
import { HttpError } from '../lib/http';
|
||||
import { fetchFromReleaseHost, latestAndroidRelease } from '../services/appUpdates';
|
||||
|
||||
/** The Android app checks here for a newer version of itself and downloads it through us. */
|
||||
export async function appUpdateRoutes(app: FastifyInstance) {
|
||||
app.get(
|
||||
'/app/android/latest',
|
||||
{ config: { rateLimit: { max: 30, timeWindow: '1 minute' } } },
|
||||
async (req, reply): Promise<AppUpdateDTO | null> => {
|
||||
try {
|
||||
const release = await latestAndroidRelease();
|
||||
return reply.send(release?.update ?? null);
|
||||
} catch (err) {
|
||||
req.log.warn(err, 'release lookup failed');
|
||||
throw new HttpError(503, "Couldn't check for updates right now");
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/** Only the latest release's APK, so this can't be used to fetch anything else from the release host. */
|
||||
app.get<{ Params: { name: string } }>(
|
||||
'/app/android/apk/:name',
|
||||
{ config: { rateLimit: { max: 10, timeWindow: '1 hour' } } },
|
||||
async (req, reply) => {
|
||||
const release = await latestAndroidRelease().catch(() => null);
|
||||
if (!release || req.params.name !== release.apkName) throw new HttpError(404, 'Not found');
|
||||
const res = await fetchFromReleaseHost(release.assetUrl, { signal: AbortSignal.timeout(10 * 60_000) });
|
||||
if (!res.ok || !res.body) {
|
||||
req.log.warn({ status: res.status }, 'APK download from the release host failed');
|
||||
throw new HttpError(502, "Couldn't download the update right now");
|
||||
}
|
||||
reply
|
||||
.header('content-type', 'application/vnd.android.package-archive')
|
||||
.header('content-disposition', `attachment; filename="${release.apkName}"`)
|
||||
.header('cache-control', 'no-store');
|
||||
const length = res.headers.get('content-length');
|
||||
if (length) reply.header('content-length', length);
|
||||
return reply.send(Readable.fromWeb(res.body as WebReadableStream));
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import type { AppUpdateDTO } from '@musicshare/shared';
|
||||
import { parseVersion } from '@musicshare/shared';
|
||||
import { env } from '../env';
|
||||
|
||||
/** The newest release, plus where its APK really lives (served through GET /app/android/apk/:name). */
|
||||
export interface AndroidRelease {
|
||||
update: AppUpdateDTO;
|
||||
apkName: string;
|
||||
assetUrl: string;
|
||||
}
|
||||
|
||||
interface GiteaAsset {
|
||||
name: string;
|
||||
size: number;
|
||||
browser_download_url: string;
|
||||
}
|
||||
interface GiteaRelease {
|
||||
tag_name: string;
|
||||
body?: string | null;
|
||||
draft?: boolean;
|
||||
prerelease?: boolean;
|
||||
published_at?: string;
|
||||
created_at: string;
|
||||
assets?: GiteaAsset[];
|
||||
}
|
||||
|
||||
const CACHE_MS = 10 * 60_000;
|
||||
const FAILURE_CACHE_MS = 60_000;
|
||||
let cached: { at: number; ttl: number; release: AndroidRelease | null } | null = null;
|
||||
|
||||
export const resetAndroidReleaseCache = () => {
|
||||
cached = null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Credentials for the release host. Gitea takes an access token as the basic-auth username with this
|
||||
* password, which works for both its API and the release download links.
|
||||
*/
|
||||
function releaseHeaders(url: string): Record<string, string> {
|
||||
const token = env.ANDROID_RELEASES_TOKEN;
|
||||
// Only ever send the token to the host it's for (asset links could point elsewhere).
|
||||
if (!token || new URL(url).origin !== new URL(env.ANDROID_RELEASES_URL).origin) return {};
|
||||
return { authorization: `Basic ${Buffer.from(`${token}:x-oauth-basic`).toString('base64')}` };
|
||||
}
|
||||
|
||||
export function fetchFromReleaseHost(url: string, init: RequestInit = {}) {
|
||||
return fetch(url, { ...init, headers: { ...releaseHeaders(url), ...(init.headers as Record<string, string>) } });
|
||||
}
|
||||
|
||||
/** The release notes shown in the app: the text before a "---" line (what follows is for the releases page). */
|
||||
function notesFrom(body: string | null | undefined): string | null {
|
||||
const notes = (body ?? '').split(/^\s*---\s*$/m)[0]!.trim();
|
||||
return notes || null;
|
||||
}
|
||||
|
||||
async function load(): Promise<AndroidRelease | null> {
|
||||
const res = await fetchFromReleaseHost(env.ANDROID_RELEASES_URL, {
|
||||
headers: { accept: 'application/json' },
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
if (res.status === 404) return null; // no releases yet
|
||||
if (!res.ok) throw new Error(`release lookup failed: ${res.status}`);
|
||||
const release = (await res.json()) as GiteaRelease;
|
||||
if (release.draft || release.prerelease) return null;
|
||||
|
||||
const version = release.tag_name.replace(/^v/, '');
|
||||
if (!parseVersion(version)) return null;
|
||||
const apkName = `musicshare-${version}.apk`;
|
||||
const apk = release.assets?.find((a) => a.name === apkName);
|
||||
const sums = release.assets?.find((a) => a.name === 'SHA256SUMS');
|
||||
// Without a checksum the phone can't check the download, so don't offer it.
|
||||
if (!apk || !sums) return null;
|
||||
|
||||
const sumsRes = await fetchFromReleaseHost(sums.browser_download_url, { signal: AbortSignal.timeout(10_000) });
|
||||
if (!sumsRes.ok) throw new Error(`SHA256SUMS download failed: ${sumsRes.status}`);
|
||||
const sha256 = (await sumsRes.text())
|
||||
.split('\n')
|
||||
.map((line) => line.trim().split(/\s+\*?/))
|
||||
.find(([, name]) => name === apkName)?.[0]
|
||||
?.toLowerCase();
|
||||
if (!sha256 || !/^[0-9a-f]{64}$/.test(sha256)) return null;
|
||||
|
||||
return {
|
||||
apkName,
|
||||
assetUrl: apk.browser_download_url,
|
||||
update: {
|
||||
version,
|
||||
apkUrl: `${env.API_PUBLIC_URL.replace(/\/+$/, '')}/app/android/apk/${encodeURIComponent(apkName)}`,
|
||||
sha256,
|
||||
size: apk.size,
|
||||
notes: notesFrom(release.body),
|
||||
publishedAt: release.published_at ?? release.created_at,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** The newest Android release (cached for a few minutes), or null when there is none or updates are off. */
|
||||
export async function latestAndroidRelease(): Promise<AndroidRelease | null> {
|
||||
if (!env.ANDROID_RELEASES_URL) return null;
|
||||
if (cached && Date.now() - cached.at < cached.ttl) return cached.release;
|
||||
try {
|
||||
const release = await load();
|
||||
cached = { at: Date.now(), ttl: CACHE_MS, release };
|
||||
return release;
|
||||
} catch (err) {
|
||||
// Keep serving the last good answer for a bit rather than hammering the release host.
|
||||
cached = { at: Date.now(), ttl: FAILURE_CACHE_MS, release: cached?.release ?? null };
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { resetAndroidReleaseCache } from '../src/services/appUpdates';
|
||||
import { json, routeFetch, useTestApp } from './helpers';
|
||||
|
||||
const app = useTestApp();
|
||||
beforeEach(resetAndroidReleaseCache);
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
const SHA = 'ab'.repeat(32);
|
||||
const release = (over: object = {}) => ({
|
||||
tag_name: 'v1.2.0',
|
||||
body: 'Lyrics and notifications.\n\n---\nmusicshare-*-foss.apk has no closed-source code.',
|
||||
draft: false,
|
||||
prerelease: false,
|
||||
published_at: '2026-10-15T15:33:31Z',
|
||||
created_at: '2026-10-15T15:33:00Z',
|
||||
assets: [
|
||||
{ name: 'musicshare-1.2.0.apk', size: 77_000_000, browser_download_url: 'https://git.test/me/musicshare/releases/download/v1.2.0/musicshare-1.2.0.apk' },
|
||||
{ name: 'musicshare-1.2.0-foss.apk', size: 67_000_000, browser_download_url: 'https://git.test/me/musicshare/releases/download/v1.2.0/musicshare-1.2.0-foss.apk' },
|
||||
{ name: 'SHA256SUMS', size: 200, browser_download_url: 'https://git.test/me/musicshare/releases/download/v1.2.0/SHA256SUMS' },
|
||||
],
|
||||
...over,
|
||||
});
|
||||
const sums = `${'cd'.repeat(32)} musicshare-1.2.0-foss.apk\n${SHA} musicshare-1.2.0.apk\n`;
|
||||
|
||||
describe('app updates', () => {
|
||||
it('reports the latest release with its checksum and notes, asking the release host only once', async () => {
|
||||
const auth: (string | null)[] = [];
|
||||
const seen = (init?: RequestInit) => auth.push(new Headers(init?.headers).get('authorization'));
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
routeFetch([
|
||||
[/releases\/latest$/, (_url, init) => (seen(init), json(release()))],
|
||||
[/SHA256SUMS$/, (_url, init) => (seen(init), new Response(sums))],
|
||||
]),
|
||||
);
|
||||
const res = await app().inject({ url: '/app/android/latest' });
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json()).toEqual({
|
||||
version: '1.2.0',
|
||||
apkUrl: 'https://api.test/app/android/apk/musicshare-1.2.0.apk',
|
||||
sha256: SHA,
|
||||
size: 77_000_000,
|
||||
notes: 'Lyrics and notifications.',
|
||||
publishedAt: '2026-10-15T15:33:31Z',
|
||||
});
|
||||
expect(auth).toEqual(Array(2).fill(`Basic ${Buffer.from('test-release-token:x-oauth-basic').toString('base64')}`));
|
||||
|
||||
await app().inject({ url: '/app/android/latest' });
|
||||
expect(auth).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('offers nothing for drafts, pre-releases or releases without a checksum', async () => {
|
||||
for (const over of [{ prerelease: true }, { draft: true }, { assets: release().assets.slice(0, 2) }]) {
|
||||
resetAndroidReleaseCache();
|
||||
vi.stubGlobal('fetch', routeFetch([[/releases\/latest$/, () => json(release(over))]]));
|
||||
expect((await app().inject({ url: '/app/android/latest' })).json()).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it('streams only the latest APK, never sending the token to another host', async () => {
|
||||
let apkAuth: string | null = 'unset';
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
routeFetch([
|
||||
[
|
||||
/releases\/latest$/,
|
||||
() =>
|
||||
json(
|
||||
release({
|
||||
assets: [
|
||||
{ name: 'musicshare-1.2.0.apk', size: 4, browser_download_url: 'https://cdn.example/musicshare-1.2.0.apk' },
|
||||
{ name: 'SHA256SUMS', size: 200, browser_download_url: 'https://git.test/SHA256SUMS' },
|
||||
],
|
||||
}),
|
||||
),
|
||||
],
|
||||
[/SHA256SUMS$/, () => new Response(sums)],
|
||||
[/cdn\.example/, (_url, init) => ((apkAuth = new Headers(init?.headers).get('authorization')), new Response('APK!'))],
|
||||
]),
|
||||
);
|
||||
expect((await app().inject({ url: '/app/android/apk/musicshare-1.1.0.apk' })).statusCode).toBe(404);
|
||||
const res = await app().inject({ url: '/app/android/apk/musicshare-1.2.0.apk' });
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.headers['content-type']).toBe('application/vnd.android.package-archive');
|
||||
expect(res.body).toBe('APK!');
|
||||
expect(apkAuth).toBeNull();
|
||||
});
|
||||
|
||||
it('says 503 when the release host is down', async () => {
|
||||
vi.stubGlobal('fetch', routeFetch([[/releases\/latest$/, () => json({ message: 'oops' }, 500)]]));
|
||||
expect((await app().inject({ url: '/app/android/latest' })).statusCode).toBe(503);
|
||||
});
|
||||
});
|
||||
@@ -22,6 +22,8 @@ const testEnv = {
|
||||
SPOTIFY_CLIENT_ID: 'test-spotify-id',
|
||||
SPOTIFY_CLIENT_SECRET: 'test-spotify-secret',
|
||||
ODESLI_API_KEY: '',
|
||||
ANDROID_RELEASES_URL: 'https://git.test/api/v1/repos/me/musicshare/releases/latest',
|
||||
ANDROID_RELEASES_TOKEN: 'test-release-token',
|
||||
// Never send real email from tests (apps/api/.env may hold real SMTP settings).
|
||||
SMTP_HOST: '',
|
||||
APPLE_TEAM_ID: 'TEAMID1234',
|
||||
|
||||
@@ -21,6 +21,11 @@ export default ({ config }: ConfigContext): ExpoConfig => {
|
||||
return {
|
||||
...(config as ExpoConfig),
|
||||
extra: { ...config.extra, foss },
|
||||
plugins: [...plugins, ['expo-build-properties', { android: { usesCleartextTraffic: localHttp } }] satisfies Plugin],
|
||||
plugins: [
|
||||
...plugins,
|
||||
['expo-build-properties', { android: { usesCleartextTraffic: localHttp } }] satisfies Plugin,
|
||||
// F-Droid delivers updates itself; only our own builds install them (modules/updater).
|
||||
...(foss ? ['./plugins/withoutSelfUpdate'] : []),
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"expo": {
|
||||
"name": "musicshare",
|
||||
"slug": "musicshare",
|
||||
"version": "1.1.0",
|
||||
"version": "1.2.0",
|
||||
"orientation": "portrait",
|
||||
"icon": "./assets/images/icon.png",
|
||||
"scheme": "musicshare",
|
||||
@@ -23,7 +23,7 @@
|
||||
},
|
||||
"predictiveBackGestureEnabled": false,
|
||||
"package": "tech.chrisedwards.musicshare",
|
||||
"versionCode": 2
|
||||
"versionCode": 3
|
||||
},
|
||||
"plugins": [
|
||||
"expo-router",
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
plugins {
|
||||
id 'com.android.library'
|
||||
id 'expo-module-gradle-plugin'
|
||||
}
|
||||
|
||||
group = 'tech.chrisedwards.musicshare.updater'
|
||||
version = '0.1.0'
|
||||
|
||||
android {
|
||||
namespace "tech.chrisedwards.musicshare.updater"
|
||||
defaultConfig {
|
||||
versionCode 1
|
||||
versionName "0.1.0"
|
||||
}
|
||||
lintOptions {
|
||||
abortOnError false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<!-- Installing our own updates (downloaded from our server, checked against the release's SHA-256).
|
||||
The F-Droid build removes both: F-Droid delivers its updates itself (plugins/withoutSelfUpdate.js). -->
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||
<!-- Android 12+: lets updates after the first one install without a confirmation screen. -->
|
||||
<uses-permission android:name="android.permission.UPDATE_PACKAGES_WITHOUT_USER_ACTION" />
|
||||
|
||||
<application>
|
||||
<receiver android:name=".InstallResultReceiver" android:exported="false" />
|
||||
</application>
|
||||
</manifest>
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
package tech.chrisedwards.musicshare.updater
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageInstaller
|
||||
import android.os.Build
|
||||
|
||||
/** Where the system reports how an install session went. */
|
||||
class InstallResultReceiver : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
val message = intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE)
|
||||
when (intent.getIntExtra(PackageInstaller.EXTRA_STATUS, PackageInstaller.STATUS_FAILURE)) {
|
||||
PackageInstaller.STATUS_PENDING_USER_ACTION -> {
|
||||
if (intent.getBooleanExtra(EXTRA_INTERACTIVE, false)) {
|
||||
val confirm = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
intent.getParcelableExtra(Intent.EXTRA_INTENT, Intent::class.java)
|
||||
} else {
|
||||
@Suppress("DEPRECATION") intent.getParcelableExtra(Intent.EXTRA_INTENT)
|
||||
}
|
||||
if (confirm != null) {
|
||||
context.startActivity(confirm.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
|
||||
UpdaterBus.emit("waiting", null)
|
||||
return
|
||||
}
|
||||
}
|
||||
// A quiet attempt (app in the background): drop it; the app offers the update next time it's open.
|
||||
val sessionId = intent.getIntExtra(PackageInstaller.EXTRA_SESSION_ID, -1)
|
||||
if (sessionId != -1) runCatching { context.packageManager.packageInstaller.abandonSession(sessionId) }
|
||||
UpdaterBus.emit("needs_user", null)
|
||||
}
|
||||
PackageInstaller.STATUS_SUCCESS -> UpdaterBus.emit("installed", null)
|
||||
PackageInstaller.STATUS_FAILURE_ABORTED -> UpdaterBus.emit("cancelled", message)
|
||||
else -> UpdaterBus.emit("failed", message)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val EXTRA_INTERACTIVE = "tech.chrisedwards.musicshare.updater.INTERACTIVE"
|
||||
}
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
package tech.chrisedwards.musicshare.updater
|
||||
|
||||
/** Hands install results from the receiver (no JS) to the module while the app is open. */
|
||||
internal object UpdaterBus {
|
||||
interface Listener {
|
||||
fun onInstallStatus(status: String, message: String?)
|
||||
}
|
||||
|
||||
@Volatile var listener: Listener? = null
|
||||
|
||||
fun emit(status: String, message: String?) {
|
||||
listener?.onInstallStatus(status, message)
|
||||
}
|
||||
}
|
||||
+139
@@ -0,0 +1,139 @@
|
||||
package tech.chrisedwards.musicshare.updater
|
||||
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageInstaller
|
||||
import android.os.Build
|
||||
import expo.modules.kotlin.exception.CodedException
|
||||
import expo.modules.kotlin.exception.Exceptions
|
||||
import expo.modules.kotlin.modules.Module
|
||||
import expo.modules.kotlin.modules.ModuleDefinition
|
||||
import java.io.File
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import java.security.MessageDigest
|
||||
|
||||
class UpdaterModule : Module() {
|
||||
private val context: Context get() = appContext.reactContext ?: throw Exceptions.ReactContextLost()
|
||||
|
||||
private val busListener = object : UpdaterBus.Listener {
|
||||
override fun onInstallStatus(status: String, message: String?) {
|
||||
sendEvent("onInstallStatus", mapOf("status" to status, "message" to message))
|
||||
}
|
||||
}
|
||||
|
||||
override fun definition() = ModuleDefinition {
|
||||
Name("MusicshareUpdater")
|
||||
|
||||
Events("onProgress", "onInstallStatus")
|
||||
|
||||
OnStartObserving("onInstallStatus") { UpdaterBus.listener = busListener }
|
||||
OnStopObserving("onInstallStatus") { if (UpdaterBus.listener === busListener) UpdaterBus.listener = null }
|
||||
|
||||
/** Delete downloaded updates (once installed, or no longer wanted). */
|
||||
Function("clearDownloads") {
|
||||
updatesDir().deleteRecursively()
|
||||
}
|
||||
|
||||
Function("currentVersion") {
|
||||
context.packageManager.getPackageInfo(context.packageName, 0).versionName ?: "0"
|
||||
}
|
||||
|
||||
// Runs off the main thread (AsyncFunction), so plain blocking I/O is fine.
|
||||
AsyncFunction("download") { url: String, sha256: String, fileName: String ->
|
||||
if (!fileName.matches(Regex("[A-Za-z0-9._-]+\\.apk"))) throw UpdateException("Bad file name")
|
||||
val dir = updatesDir().apply { mkdirs() }
|
||||
val target = File(dir, fileName)
|
||||
// Only the newest download is worth keeping.
|
||||
dir.listFiles()?.filter { it.name != fileName }?.forEach { it.delete() }
|
||||
if (target.exists() && sha256Of(target) == sha256.lowercase()) return@AsyncFunction target.absolutePath
|
||||
|
||||
val partial = File(dir, "$fileName.part")
|
||||
val connection = (URL(url).openConnection() as HttpURLConnection).apply {
|
||||
connectTimeout = 15_000
|
||||
readTimeout = 30_000
|
||||
}
|
||||
try {
|
||||
if (connection.responseCode != 200) throw UpdateException("Download failed (HTTP ${connection.responseCode})")
|
||||
val total = connection.contentLengthLong
|
||||
val digest = MessageDigest.getInstance("SHA-256")
|
||||
var received = 0L
|
||||
var lastReported = 0L
|
||||
connection.inputStream.use { input ->
|
||||
partial.outputStream().use { output ->
|
||||
val buffer = ByteArray(64 * 1024)
|
||||
while (true) {
|
||||
val n = input.read(buffer)
|
||||
if (n < 0) break
|
||||
output.write(buffer, 0, n)
|
||||
digest.update(buffer, 0, n)
|
||||
received += n
|
||||
if (received - lastReported > 1_000_000) {
|
||||
lastReported = received
|
||||
sendEvent("onProgress", mapOf("received" to received.toDouble(), "total" to total.toDouble()))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
val actual = digest.digest().joinToString("") { "%02x".format(it) }
|
||||
if (actual != sha256.lowercase()) {
|
||||
partial.delete()
|
||||
throw UpdateException("The download didn't match its checksum")
|
||||
}
|
||||
if (!partial.renameTo(target)) throw UpdateException("Couldn't save the download")
|
||||
target.absolutePath
|
||||
} finally {
|
||||
connection.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
AsyncFunction("install") { path: String, interactive: Boolean ->
|
||||
val apk = File(path)
|
||||
if (!apk.exists() || apk.parentFile?.canonicalPath != updatesDir().canonicalPath) throw UpdateException("No downloaded update")
|
||||
val installer = context.packageManager.packageInstaller
|
||||
val params = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL).apply {
|
||||
setAppPackageName(context.packageName)
|
||||
setSize(apk.length())
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
setRequireUserAction(PackageInstaller.SessionParams.USER_ACTION_NOT_REQUIRED)
|
||||
}
|
||||
}
|
||||
val sessionId = installer.createSession(params)
|
||||
installer.openSession(sessionId).use { session ->
|
||||
apk.inputStream().use { input ->
|
||||
session.openWrite("base.apk", 0, apk.length()).use { output ->
|
||||
input.copyTo(output)
|
||||
session.fsync(output)
|
||||
}
|
||||
}
|
||||
val intent = Intent(context, InstallResultReceiver::class.java)
|
||||
.setPackage(context.packageName)
|
||||
.putExtra(InstallResultReceiver.EXTRA_INTERACTIVE, interactive)
|
||||
// Mutable: the system adds the result to it.
|
||||
val flags = PendingIntent.FLAG_UPDATE_CURRENT or
|
||||
(if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0)
|
||||
val pending = PendingIntent.getBroadcast(context, sessionId, intent, flags)
|
||||
session.commit(pending.intentSender)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Not cacheDir: Android empties caches when storage runs low, which could lose a download before it's installed.
|
||||
private fun updatesDir() = File(context.noBackupFilesDir, "updates")
|
||||
|
||||
private fun sha256Of(file: File): String {
|
||||
val digest = MessageDigest.getInstance("SHA-256")
|
||||
file.inputStream().use { input ->
|
||||
val buffer = ByteArray(64 * 1024)
|
||||
while (true) {
|
||||
val n = input.read(buffer)
|
||||
if (n < 0) break
|
||||
digest.update(buffer, 0, n)
|
||||
}
|
||||
}
|
||||
return digest.digest().joinToString("") { "%02x".format(it) }
|
||||
}
|
||||
}
|
||||
|
||||
internal class UpdateException(message: String) : CodedException(message)
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"platforms": ["android"],
|
||||
"android": {
|
||||
"modules": ["tech.chrisedwards.musicshare.updater.UpdaterModule"]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
// Android-only native module; the default export is null where it is unavailable.
|
||||
export { default } from './src/UpdaterModule';
|
||||
export type { InstallStatus } from './src/UpdaterModule';
|
||||
@@ -0,0 +1,30 @@
|
||||
import { NativeModule, requireOptionalNativeModule } from 'expo';
|
||||
|
||||
/**
|
||||
* What the system did with an install: `installed` (rarely seen: updating ourselves ends this process),
|
||||
* `needs_user` (a quiet install wasn't allowed; it has to be started from the app), `waiting` (the system's
|
||||
* confirmation screen is showing), `cancelled`, or `failed`.
|
||||
*/
|
||||
export type InstallStatus = 'installed' | 'needs_user' | 'waiting' | 'cancelled' | 'failed';
|
||||
|
||||
type Events = {
|
||||
onProgress: (event: { received: number; total: number }) => void;
|
||||
onInstallStatus: (event: { status: InstallStatus; message: string | null }) => void;
|
||||
};
|
||||
|
||||
declare class MusicshareUpdaterModule extends NativeModule<Events> {
|
||||
/** Delete downloaded updates (once installed, or no longer wanted). */
|
||||
clearDownloads(): void;
|
||||
/** This build's versionName, e.g. "1.1.0". */
|
||||
currentVersion(): string;
|
||||
/** Download to the app's cache and check the SHA-256; resolves with the file's path. Reuses an earlier complete download. */
|
||||
download(url: string, sha256: string, fileName: string): Promise<string>;
|
||||
/**
|
||||
* Hand the APK to the system installer. `interactive` shows the system's confirmation when it's needed;
|
||||
* otherwise only a quiet install is tried. The outcome arrives as onInstallStatus.
|
||||
*/
|
||||
install(path: string, interactive: boolean): Promise<void>;
|
||||
}
|
||||
|
||||
/** Android only; null elsewhere and in Expo Go. */
|
||||
export default requireOptionalNativeModule<MusicshareUpdaterModule>('MusicshareUpdater');
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@musicshare/mobile",
|
||||
"main": "expo-router/entry",
|
||||
"version": "1.1.0",
|
||||
"version": "1.2.0",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
"@expo-google-fonts/space-grotesk": "^0.4.1",
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
// F-Droid builds (MUSICSHARE_FOSS=1) get their updates from F-Droid, so they mustn't be able to
|
||||
// install packages themselves: remove the permissions the updater module (modules/updater) adds.
|
||||
const { AndroidConfig, withAndroidManifest } = require('expo/config-plugins');
|
||||
|
||||
const PERMISSIONS = ['android.permission.REQUEST_INSTALL_PACKAGES', 'android.permission.UPDATE_PACKAGES_WITHOUT_USER_ACTION'];
|
||||
|
||||
module.exports = function withoutSelfUpdate(config) {
|
||||
return withAndroidManifest(config, (cfg) => {
|
||||
const manifest = AndroidConfig.Manifest.ensureToolsAvailable(cfg.modResults).manifest;
|
||||
const existing = manifest['uses-permission'] ?? [];
|
||||
manifest['uses-permission'] = [
|
||||
...existing.filter((p) => !PERMISSIONS.includes(p.$['android:name'])),
|
||||
...PERMISSIONS.map((name) => ({ $: { 'android:name': name, 'tools:node': 'remove' } })),
|
||||
];
|
||||
return cfg;
|
||||
});
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
import { router } from 'expo-router';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Pressable, RefreshControl, ScrollView, StyleSheet, Text, View } from 'react-native';
|
||||
import Animated, { FadeInDown, LinearTransition } from 'react-native-reanimated';
|
||||
import Animated, { FadeIn, LinearTransition } from 'react-native-reanimated';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import useSWR, { useSWRConfig } from 'swr';
|
||||
import { sortFeed, type ConnectionDTO, type FeedItemDTO, type FollowListDTO, type ProfileDTO } from '@musicshare/shared';
|
||||
@@ -10,6 +10,7 @@ import { ActivityBell } from '@/components/ActivityBell';
|
||||
import { Avatar } from '@/components/Avatar';
|
||||
import { NowPlayingCard } from '@/components/NowPlayingCard';
|
||||
import { RelinkReminder } from '@/components/RelinkReminder';
|
||||
import { UpdateBanner } from '@/components/UpdateBanner';
|
||||
import { SharingControls } from '@/components/SharingControls';
|
||||
import { Body, Button, Card, Title } from '@/components/ui';
|
||||
import { api } from '@/lib/api';
|
||||
@@ -84,9 +85,9 @@ export default function FeedScreen() {
|
||||
return (
|
||||
<SafeAreaView style={styles.safe} edges={['top']}>
|
||||
<AmbientBackground />
|
||||
{/* Cards rise in on load and glide to new positions when someone pins or starts playing. */}
|
||||
{/* Cards fade in and slide to new positions when someone pins or starts playing. */}
|
||||
<Animated.FlatList
|
||||
itemLayoutAnimation={LinearTransition.springify().damping(18)}
|
||||
itemLayoutAnimation={LinearTransition.duration(200)}
|
||||
data={feed ?? []}
|
||||
keyExtractor={(item) => item.user.id}
|
||||
contentContainerStyle={styles.list}
|
||||
@@ -97,6 +98,7 @@ export default function FeedScreen() {
|
||||
<Text style={styles.brand}>musicshare</Text>
|
||||
<ActivityBell />
|
||||
</View>
|
||||
<UpdateBanner />
|
||||
<SharingControls compact />
|
||||
<RelinkReminder />
|
||||
{offerAppleMusic && (
|
||||
@@ -160,8 +162,8 @@ export default function FeedScreen() {
|
||||
</View>
|
||||
) : null
|
||||
}
|
||||
renderItem={({ item, index }) => (
|
||||
<Animated.View entering={FadeInDown.delay(Math.min(index, 8) * 60).springify().damping(16)}>
|
||||
renderItem={({ item }) => (
|
||||
<Animated.View entering={FadeIn.duration(180)}>
|
||||
<NowPlayingCard
|
||||
np={item}
|
||||
preferred={me?.preferredService}
|
||||
|
||||
@@ -26,10 +26,11 @@ import { api } from '@/lib/api';
|
||||
import { WEB_URL } from '@/lib/config';
|
||||
import { showError } from '@/lib/errors';
|
||||
import { AppleMusicPermissionError, appleMusicAvailable, connectAppleMusic } from '@/lib/appleMusic';
|
||||
import { SignInError, openSpotifyLinkPage, signInWithService } from '@/lib/connect';
|
||||
import { SignInError, openSignupPage, openSpotifyLinkPage, signInWithService } from '@/lib/connect';
|
||||
import { openReaderSettings, useReader } from '@/lib/nowPlayingReader';
|
||||
import { NTFY_URL, disablePush, enablePush, pushAvailable, usePushStatus } from '@/lib/push';
|
||||
import { useSession } from '@/lib/session';
|
||||
import { checkForUpdate, currentVersion, installUpdate, updaterAvailable, useUpdateState } from '@/lib/updates';
|
||||
import { colors, radius, space } from '@/lib/theme';
|
||||
|
||||
export default function SettingsScreen() {
|
||||
@@ -44,6 +45,7 @@ export default function SettingsScreen() {
|
||||
<ThisPhone />
|
||||
<Connections />
|
||||
<FeaturedPicker />
|
||||
<AppVersion />
|
||||
<Account />
|
||||
</Screen>
|
||||
);
|
||||
@@ -289,6 +291,7 @@ function Connections() {
|
||||
});
|
||||
|
||||
const linkSpotifyOnLastfm = () => openSpotifyLinkPage('lastfm').catch((e) => showError(e));
|
||||
const linkSpotifyOnListenBrainz = () => openSpotifyLinkPage('listenbrainz').catch((e) => showError(e));
|
||||
const spotifyLinked = byProvider.has('lastfm') || byProvider.has('listenbrainz');
|
||||
|
||||
const disconnect = (provider: NowPlayingProvider) =>
|
||||
@@ -382,8 +385,10 @@ function Connections() {
|
||||
<Button small variant="primary" title="Sign in" onPress={() => connectOAuth('lastfm')} />
|
||||
)}
|
||||
</View>
|
||||
{byProvider.has('lastfm') && (
|
||||
{byProvider.has('lastfm') ? (
|
||||
<Button small title="Link Spotify to Last.fm" onPress={linkSpotifyOnLastfm} style={{ alignSelf: 'flex-start' }} />
|
||||
) : (
|
||||
<Button small title="No account? Create one" onPress={() => openSignupPage('lastfm')} style={{ alignSelf: 'flex-start' }} />
|
||||
)}
|
||||
</View>
|
||||
|
||||
@@ -393,6 +398,7 @@ function Connections() {
|
||||
active={byProvider.has('listenbrainz')}
|
||||
onConnect={(username) => run(() => api.connectUsername('listenbrainz', username))}
|
||||
onDisconnect={() => disconnect('listenbrainz')}
|
||||
onLinkSpotify={linkSpotifyOnListenBrainz}
|
||||
/>
|
||||
|
||||
<JellyfinConnection
|
||||
@@ -417,12 +423,14 @@ function UsernameConnection({
|
||||
active,
|
||||
onConnect,
|
||||
onDisconnect,
|
||||
onLinkSpotify,
|
||||
}: {
|
||||
provider: 'lastfm' | 'listenbrainz';
|
||||
status: React.ReactNode;
|
||||
active: boolean;
|
||||
onConnect: (username: string) => Promise<void>;
|
||||
onDisconnect: () => void;
|
||||
onLinkSpotify: () => void;
|
||||
}) {
|
||||
const [username, setUsername] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
@@ -454,6 +462,18 @@ function UsernameConnection({
|
||||
/>
|
||||
</Row>
|
||||
)}
|
||||
{active ? (
|
||||
<Button small title={`Link Spotify to ${SERVICE_LABELS[provider]}`} onPress={onLinkSpotify} style={{ alignSelf: 'flex-start' }} />
|
||||
) : (
|
||||
<>
|
||||
<Button small title="No account? Create one" onPress={() => openSignupPage(provider)} style={{ alignSelf: 'flex-start' }} />
|
||||
{provider === 'listenbrainz' && (
|
||||
<Body muted style={styles.small}>
|
||||
ListenBrainz uses MusicBrainz accounts: create one there, then sign in to ListenBrainz once.
|
||||
</Body>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -514,6 +534,58 @@ function JellyfinConnection({
|
||||
);
|
||||
}
|
||||
|
||||
/** This build's version, and checking for a newer one (our own APKs; F-Droid updates its builds itself). */
|
||||
function AppVersion() {
|
||||
const state = useUpdateState();
|
||||
if (!updaterAvailable) return null;
|
||||
const status = (() => {
|
||||
switch (state.status) {
|
||||
case 'checking':
|
||||
return 'Checking for updates…';
|
||||
case 'upToDate':
|
||||
return 'Up to date.';
|
||||
case 'downloading':
|
||||
return `Downloading ${state.update.version}… ${Math.round(state.progress * 100)}%`;
|
||||
case 'ready':
|
||||
return `Version ${state.update.version} is ready to install.`;
|
||||
case 'installing':
|
||||
return `Installing ${state.update.version}…`;
|
||||
case 'error':
|
||||
return state.message;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
return (
|
||||
<Card>
|
||||
<Heading>App version {currentVersion()}</Heading>
|
||||
{status && (
|
||||
<Body muted style={styles.small}>
|
||||
{status}
|
||||
</Body>
|
||||
)}
|
||||
{state.status === 'ready' || state.status === 'installing' ? (
|
||||
<Button
|
||||
small
|
||||
variant="primary"
|
||||
title="Install update"
|
||||
disabled={state.status === 'installing'}
|
||||
onPress={() => installUpdate(true)}
|
||||
style={{ alignSelf: 'flex-start' }}
|
||||
/>
|
||||
) : (
|
||||
<Button
|
||||
small
|
||||
title="Check for updates"
|
||||
loading={state.status === 'checking' || state.status === 'downloading'}
|
||||
onPress={() => checkForUpdate({ force: true })}
|
||||
style={{ alignSelf: 'flex-start' }}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function Account() {
|
||||
const { me, setMe, signOut } = useSession();
|
||||
const resend = async () => {
|
||||
|
||||
@@ -12,6 +12,7 @@ import { appleMusicAvailable, useAppleMusicLive } from '@/lib/appleMusic';
|
||||
import { syncDevice } from '@/lib/nowPlayingReader';
|
||||
import { syncPush } from '@/lib/push';
|
||||
import { SessionProvider, useSession } from '@/lib/session';
|
||||
import { startUpdater } from '@/lib/updates';
|
||||
import { colors, space } from '@/lib/theme';
|
||||
|
||||
SplashScreen.preventAutoHideAsync();
|
||||
@@ -43,6 +44,11 @@ function AppNavigator() {
|
||||
if (ready) SplashScreen.hideAsync();
|
||||
}, [ready]);
|
||||
|
||||
// Look for a newer version of the app (our own APKs only; see lib/updates.ts).
|
||||
useEffect(() => {
|
||||
if (ready) startUpdater();
|
||||
}, [ready]);
|
||||
|
||||
// Keep the Android listener pointed at this account (no-op elsewhere).
|
||||
useEffect(() => {
|
||||
if (signedIn) syncDevice().catch(() => {});
|
||||
|
||||
@@ -5,7 +5,6 @@ import { useEffect, useRef, useState } from 'react';
|
||||
import { Linking, Pressable, StyleSheet, Text, View } from 'react-native';
|
||||
import Animated, {
|
||||
FadeIn,
|
||||
FadeInDown,
|
||||
FadeOut,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
@@ -98,7 +97,7 @@ export function NowPlayingCard({
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<Animated.View key={np.track.id} entering={FadeInDown.duration(380).springify()} exiting={FadeOut.duration(200)}>
|
||||
<Animated.View key={np.track.id} entering={FadeIn.duration(250)} exiting={FadeOut.duration(200)}>
|
||||
<TrackRow track={np.track} preferred={preferred} expanded={expanded} onToggle={toggle} />
|
||||
</Animated.View>
|
||||
<ProgressBar np={np} compact={!expanded} />
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { StyleSheet } from 'react-native';
|
||||
import { installUpdate, updaterAvailable, useUpdateState } from '@/lib/updates';
|
||||
import { Body, Button, Card, Heading } from './ui';
|
||||
|
||||
/** "Update ready" on the feed, once a newer version has downloaded (lib/updates.ts). */
|
||||
export function UpdateBanner() {
|
||||
const state = useUpdateState();
|
||||
if (!updaterAvailable || (state.status !== 'ready' && state.status !== 'installing')) return null;
|
||||
return (
|
||||
<Card highlighted>
|
||||
<Heading>musicshare {state.update.version} is ready</Heading>
|
||||
{state.update.notes ? (
|
||||
<Body muted style={styles.notes} numberOfLines={4}>
|
||||
{state.update.notes}
|
||||
</Body>
|
||||
) : null}
|
||||
<Button
|
||||
small
|
||||
variant="primary"
|
||||
title={state.status === 'installing' ? 'Installing…' : 'Install update'}
|
||||
disabled={state.status === 'installing'}
|
||||
onPress={() => installUpdate(true)}
|
||||
style={styles.button}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
notes: { fontSize: 13 },
|
||||
button: { alignSelf: 'flex-start' },
|
||||
});
|
||||
@@ -1,8 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import { Linking } from 'react-native';
|
||||
import { mutate } from 'swr';
|
||||
import { SERVICE_LABELS } from '@musicshare/shared';
|
||||
import { SignInError, signInWithService } from '@/lib/connect';
|
||||
import { SignInError, openSignupPage, signInWithService } from '@/lib/connect';
|
||||
import { Button } from '../ui';
|
||||
import { ErrorText, SetupActions, SetupHeader, type SetupProps } from './common';
|
||||
|
||||
@@ -57,7 +56,7 @@ export function ServiceSignInSetup({
|
||||
/>
|
||||
<ErrorText>{error}</ErrorText>
|
||||
<SetupActions primary="Sign in with Last.fm" loading={busy} onPrimary={run} onSkip={onDone} skipLabel={skipLabel} />
|
||||
<Button small title="New to Last.fm? Create an account" onPress={() => Linking.openURL('https://www.last.fm/join')} />
|
||||
<Button small title="New to Last.fm? Create an account" onPress={() => openSignupPage('lastfm')} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useState } from 'react';
|
||||
import { Linking, Pressable, StyleSheet, Text, View } from 'react-native';
|
||||
import { Pressable, StyleSheet, Text, View } from 'react-native';
|
||||
import useSWR, { mutate } from 'swr';
|
||||
import type { ConnectionDTO } from '@musicshare/shared';
|
||||
import { api } from '@/lib/api';
|
||||
import { SignInError, openSpotifyLinkPage, signInWithService } from '@/lib/connect';
|
||||
import { SignInError, openSignupPage, openSpotifyLinkPage, signInWithService } from '@/lib/connect';
|
||||
import { successHaptic } from '@/lib/haptics';
|
||||
import { colors, radius, space } from '@/lib/theme';
|
||||
import { Body, Button, Field } from '../ui';
|
||||
@@ -108,7 +108,7 @@ export function SpotifySetup({ onDone, skipLabel }: SetupProps) {
|
||||
onPress={() => setVia('listenbrainz')}
|
||||
title="ListenBrainz"
|
||||
badge="Open source"
|
||||
detail="Free and run by a nonprofit. Enter your ListenBrainz username."
|
||||
detail="Free and run by a nonprofit. Enter your ListenBrainz username, then approve Spotify on ListenBrainz."
|
||||
/>
|
||||
|
||||
{via === 'listenbrainz' && (
|
||||
@@ -123,9 +123,16 @@ export function SpotifySetup({ onDone, skipLabel }: SetupProps) {
|
||||
)}
|
||||
<Button title={skipLabel ?? 'Not now'} onPress={onDone} />
|
||||
</View>
|
||||
<Body muted style={styles.small} onPress={() => Linking.openURL(via === 'listenbrainz' ? 'https://listenbrainz.org/login/' : 'https://www.last.fm/join')}>
|
||||
No account yet? Create one free on {via === 'listenbrainz' ? 'ListenBrainz' : 'Last.fm'} first.
|
||||
</Body>
|
||||
{via && (
|
||||
<View style={{ gap: space.xs }}>
|
||||
<Button small title={`No account? Create one on ${via === 'listenbrainz' ? 'MusicBrainz' : 'Last.fm'}`} onPress={() => openSignupPage(via)} />
|
||||
{via === 'listenbrainz' && (
|
||||
<Body muted style={styles.small}>
|
||||
ListenBrainz uses MusicBrainz accounts. After creating one, sign in to ListenBrainz once, then come back.
|
||||
</Body>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as Linking from 'expo-linking';
|
||||
import * as WebBrowser from 'expo-web-browser';
|
||||
import { SPOTIFY_LINK_PAGES } from '@musicshare/shared';
|
||||
import { SIGNUP_PAGES, SPOTIFY_LINK_PAGES } from '@musicshare/shared';
|
||||
import { api } from './api';
|
||||
|
||||
export type SignInResult = 'connected' | 'cancelled';
|
||||
@@ -25,3 +25,6 @@ export async function signInWithService(provider: 'plex' | 'lastfm'): Promise<Si
|
||||
/** Where users approve "Spotify Scrobbling" (Last.fm) or the Spotify link (ListenBrainz). */
|
||||
export const openSpotifyLinkPage = (on: keyof typeof SPOTIFY_LINK_PAGES = 'lastfm') =>
|
||||
WebBrowser.openBrowserAsync(SPOTIFY_LINK_PAGES[on]);
|
||||
|
||||
/** Their sign-up page, in the in-app browser (ListenBrainz accounts start on MusicBrainz). */
|
||||
export const openSignupPage = (service: keyof typeof SIGNUP_PAGES) => WebBrowser.openBrowserAsync(SIGNUP_PAGES[service]);
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import { useSyncExternalStore } from 'react';
|
||||
import { AppState } from 'react-native';
|
||||
import { isNewerVersion, type AppUpdateDTO } from '@musicshare/shared';
|
||||
import Updater, { type InstallStatus } from '@/modules/updater';
|
||||
import { api } from './api';
|
||||
import { FOSS_BUILD } from './build';
|
||||
|
||||
/**
|
||||
* Self-updating for the APKs we publish (not F-Droid builds, which F-Droid updates, nor dev builds).
|
||||
*
|
||||
* When the app opens (at most every few hours) it asks the server for the newest release and, if
|
||||
* it's newer, downloads it in the background and checks its SHA-256. Then:
|
||||
* - leaving the app tries a quiet install, once per version. Android 12+ allows that once this app has
|
||||
* installed an update itself (and "install unknown apps" is allowed for it); the new version runs next
|
||||
* time it's opened. On phones with Google Play, Play Protect may still ask to scan a new version.
|
||||
* - otherwise the feed shows "Update ready", and tapping it opens Android's install screen.
|
||||
*/
|
||||
export const updaterAvailable = Updater !== null && !FOSS_BUILD && !__DEV__;
|
||||
|
||||
export type UpdateState =
|
||||
| { status: 'idle' | 'checking' | 'upToDate' }
|
||||
| { status: 'downloading'; update: AppUpdateDTO; progress: number }
|
||||
| { status: 'ready' | 'installing'; update: AppUpdateDTO; path: string }
|
||||
| { status: 'error'; message: string; update?: AppUpdateDTO };
|
||||
|
||||
const CHECK_EVERY_MS = 6 * 3600_000;
|
||||
|
||||
let state: UpdateState = { status: 'idle' };
|
||||
let lastCheck = 0;
|
||||
/** Versions we've already tried to install on the way out; after that, only when asked (no repeat prompts). */
|
||||
const autoTried = new Set<string>();
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
function set(next: UpdateState) {
|
||||
// Visible with `adb logcat -s ReactNativeJS`, for checking updates on a real phone.
|
||||
if (next.status !== state.status) console.log(`[updates] ${next.status}${'message' in next ? `: ${next.message}` : ''}`);
|
||||
state = next;
|
||||
listeners.forEach((l) => l());
|
||||
}
|
||||
|
||||
export const currentVersion = () => Updater?.currentVersion() ?? null;
|
||||
|
||||
/** Look for an update and download it. `force` skips the every-few-hours limit (the Settings button). */
|
||||
export async function checkForUpdate({ force = false } = {}): Promise<void> {
|
||||
if (!Updater || !updaterAvailable) return;
|
||||
if (['checking', 'downloading', 'installing'].includes(state.status)) return;
|
||||
if (state.status === 'ready') return; // already have it
|
||||
if (!force && Date.now() - lastCheck < CHECK_EVERY_MS) return;
|
||||
lastCheck = Date.now();
|
||||
|
||||
set({ status: 'checking' });
|
||||
let update: AppUpdateDTO | null;
|
||||
try {
|
||||
update = await api.androidUpdate();
|
||||
} catch {
|
||||
set({ status: 'error', message: "Couldn't check for updates." });
|
||||
return;
|
||||
}
|
||||
if (!update || !isNewerVersion(update.version, Updater.currentVersion())) {
|
||||
Updater.clearDownloads(); // e.g. the update we downloaded has since been installed
|
||||
set({ status: 'upToDate' });
|
||||
return;
|
||||
}
|
||||
|
||||
set({ status: 'downloading', update, progress: 0 });
|
||||
const progress = Updater.addListener('onProgress', ({ received, total }) => {
|
||||
if (state.status === 'downloading') set({ ...state, progress: total > 0 ? received / total : 0 });
|
||||
});
|
||||
try {
|
||||
const path = await Updater.download(update.apkUrl, update.sha256, `musicshare-${update.version}.apk`);
|
||||
set({ status: 'ready', update, path });
|
||||
} catch (err) {
|
||||
set({ status: 'error', message: err instanceof Error ? err.message : "Couldn't download the update.", update });
|
||||
} finally {
|
||||
progress.remove();
|
||||
}
|
||||
}
|
||||
|
||||
/** Install the downloaded update. `interactive` shows Android's confirmation screen if it needs one. */
|
||||
export async function installUpdate(interactive: boolean): Promise<void> {
|
||||
if (!Updater || state.status !== 'ready') return;
|
||||
const ready = state;
|
||||
set({ ...ready, status: 'installing' });
|
||||
try {
|
||||
await Updater.install(ready.path, interactive);
|
||||
} catch (err) {
|
||||
set({ status: 'error', message: err instanceof Error ? err.message : "Couldn't install the update.", update: ready.update });
|
||||
lastCheck = 0; // e.g. the download went missing: fetch it again next time
|
||||
}
|
||||
}
|
||||
|
||||
function onInstallStatus(status: InstallStatus, message: string | null) {
|
||||
if (state.status !== 'installing') return;
|
||||
const { update, path } = state;
|
||||
switch (status) {
|
||||
case 'waiting':
|
||||
return; // Android's screen is up; its answer comes next
|
||||
case 'needs_user':
|
||||
case 'cancelled':
|
||||
return set({ status: 'ready', update, path });
|
||||
case 'installed':
|
||||
return set({ status: 'upToDate' });
|
||||
case 'failed':
|
||||
return set({ status: 'error', message: message ?? "Couldn't install the update.", update });
|
||||
}
|
||||
}
|
||||
|
||||
let started = false;
|
||||
|
||||
/** Call once at startup: checks now, again when the app comes back, and tries a quiet install on the way out. */
|
||||
export function startUpdater() {
|
||||
if (started || !Updater || !updaterAvailable) return;
|
||||
started = true;
|
||||
Updater.addListener('onInstallStatus', ({ status, message }) => onInstallStatus(status, message));
|
||||
AppState.addEventListener('change', (appState) => {
|
||||
if (appState === 'active') checkForUpdate().catch(() => {});
|
||||
else if (appState === 'background' && state.status === 'ready' && !autoTried.has(state.update.version)) {
|
||||
autoTried.add(state.update.version);
|
||||
installUpdate(false).catch(() => {});
|
||||
}
|
||||
});
|
||||
checkForUpdate().catch(() => {});
|
||||
}
|
||||
|
||||
export function useUpdateState(): UpdateState {
|
||||
return useSyncExternalStore(
|
||||
(listener) => {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
},
|
||||
() => state,
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { Suspense, useEffect, useRef, useState } from 'react';
|
||||
import useSWR from 'swr';
|
||||
import {
|
||||
SERVICE_LABELS,
|
||||
SIGNUP_PAGES,
|
||||
SPOTIFY_LINK_PAGES,
|
||||
STREAMING_SERVICES,
|
||||
isQuietScrobbler,
|
||||
@@ -317,14 +318,21 @@ function Connections() {
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{!byProvider.has(p) && (
|
||||
<div className="muted small">
|
||||
No account?{' '}
|
||||
<a href={SIGNUP_PAGES[p]} target="_blank" rel="noreferrer">
|
||||
Create one free on {p === 'lastfm' ? 'Last.fm' : 'MusicBrainz'}
|
||||
</a>
|
||||
{p === 'listenbrainz' ? ', then sign in to ListenBrainz once (it uses MusicBrainz accounts).' : '.'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{byProvider.has(p) ? (
|
||||
<div className="row">
|
||||
{p === 'lastfm' && (
|
||||
<a className="btn" href="https://www.last.fm/settings/applications" target="_blank" rel="noreferrer">
|
||||
Link Spotify
|
||||
</a>
|
||||
)}
|
||||
<a className="btn" href={SPOTIFY_LINK_PAGES[p]} target="_blank" rel="noreferrer">
|
||||
Link Spotify
|
||||
</a>
|
||||
<button className="btn" onClick={() => disconnect(p)}>
|
||||
Disconnect
|
||||
</button>
|
||||
|
||||
@@ -43,3 +43,8 @@ VAPID_PUBLIC_KEY=
|
||||
VAPID_PRIVATE_KEY=
|
||||
# Lyrics: LRCLIB (or your own copy of it). Empty turns lyrics off.
|
||||
LRCLIB_URL=https://lrclib.net
|
||||
|
||||
# App updates: the Android app checks the latest Gitea release through the API and updates itself.
|
||||
# Empty turns it off. The token needs read access to the repo, and only if the repo is private.
|
||||
ANDROID_RELEASES_URL=https://gitea.chrisedwards.tech/api/v1/repos/chris/musicshare/releases/latest
|
||||
ANDROID_RELEASES_TOKEN=
|
||||
|
||||
@@ -70,6 +70,32 @@ that release's files.
|
||||
**Test builds:** go to **Actions > Android > Run workflow** and pick a branch. The APKs go to the
|
||||
repo's **Packages** as `musicshare-android`, versioned `<branch>-<commit>`, instead of to a release.
|
||||
|
||||
## App updates
|
||||
|
||||
The normal APK updates itself from these releases; the F-Droid build doesn't (F-Droid updates it).
|
||||
|
||||
1. The app asks our server (`GET /app/android/latest`) when it opens, at most every 6 hours, or when you
|
||||
tap **Settings > Check for updates**.
|
||||
2. The server reads the latest release from Gitea and returns its version, the APK's SHA-256 (from
|
||||
`SHA256SUMS`) and the release notes. Drafts and pre-releases are skipped.
|
||||
3. If it's newer, the app downloads the APK through our server and checks the SHA-256.
|
||||
4. The next time you leave the app, it tries a quiet install (once per version). Android 12+ allows that
|
||||
after the app has installed one update itself. Otherwise the feed shows **Update ready**, and tapping it
|
||||
opens Android's install screen; the first time, Android asks you to allow musicshare to install apps.
|
||||
On phones with Google Play, Play Protect may ask to scan each new version ("App scan recommended")
|
||||
because the app isn't from the Play Store. Choosing **Scan app** lets the update finish.
|
||||
|
||||
Set these on the API server (`deploy/production.env`):
|
||||
|
||||
```ini
|
||||
ANDROID_RELEASES_URL=https://gitea.chrisedwards.tech/api/v1/repos/chris/musicshare/releases/latest
|
||||
# Only if the repo is private: a token with read:repository (User settings > Applications).
|
||||
ANDROID_RELEASES_TOKEN=
|
||||
```
|
||||
|
||||
The release notes come from `fastlane/metadata/android/en-US/changelogs/<versionCode>.txt`: the workflow
|
||||
puts that text above a `---` line in the release, and the app shows only that part.
|
||||
|
||||
## Building by hand
|
||||
|
||||
```bash
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
The app now updates itself: new versions download in the background and install when you leave the app, or from the banner on the feed. Song cards fade in instead of bouncing. Create a Last.fm or ListenBrainz account and link Spotify to either one straight from Settings.
|
||||
Generated
+1
-1
@@ -809,7 +809,7 @@
|
||||
},
|
||||
"apps/mobile": {
|
||||
"name": "@musicshare/mobile",
|
||||
"version": "1.1.0",
|
||||
"version": "1.2.0",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
"@expo-google-fonts/space-grotesk": "^0.4.1",
|
||||
|
||||
@@ -13,6 +13,7 @@ import type {
|
||||
import type { DeviceDTO } from './devices';
|
||||
import type { LyricsDTO } from './lyrics';
|
||||
import type { NotificationListDTO } from './notifications';
|
||||
import type { AppUpdateDTO } from './updates';
|
||||
import type {
|
||||
ChangeEmailInput,
|
||||
ChangePasswordInput,
|
||||
@@ -160,6 +161,9 @@ export function createApiClient({ baseUrl, getToken, credentials }: ApiClientOpt
|
||||
request<ConnectionDTO>('/connections/jellyfin', { body: input }),
|
||||
disconnect: (provider: string) => request(`/connections/${q(provider)}`, { method: 'DELETE' }),
|
||||
|
||||
/** The newest Android release, or null when the server doesn't offer updates. */
|
||||
androidUpdate: () => request<AppUpdateDTO | null>('/app/android/latest'),
|
||||
|
||||
devices: () => request<DeviceDTO[]>('/me/devices'),
|
||||
/** The token is returned once; it can only report playback for this user. */
|
||||
registerDevice: (input: { platform: 'android' | 'ios'; name: string }) =>
|
||||
|
||||
@@ -11,3 +11,4 @@ export * from './progress';
|
||||
export * from './reactions';
|
||||
export * from './lyrics';
|
||||
export * from './notifications';
|
||||
export * from './updates';
|
||||
|
||||
@@ -103,6 +103,15 @@ export const SPOTIFY_LINK_PAGES = {
|
||||
listenbrainz: 'https://listenbrainz.org/settings/music-services/details/',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Where to make a free account (neither lets other apps create one). ListenBrainz accounts are
|
||||
* MusicBrainz accounts: register there, then sign in to ListenBrainz once to start it.
|
||||
*/
|
||||
export const SIGNUP_PAGES = {
|
||||
lastfm: 'https://www.last.fm/join',
|
||||
listenbrainz: 'https://musicbrainz.org/register',
|
||||
} as const;
|
||||
|
||||
const QUIET_AFTER_MS = 7 * 86_400_000;
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { isNewerVersion, parseVersion } from './updates';
|
||||
|
||||
describe('parseVersion', () => {
|
||||
it('reads dotted versions, with or without a leading v', () => {
|
||||
expect(parseVersion('1.10.0')).toEqual([1, 10, 0]);
|
||||
expect(parseVersion('v2.0')).toEqual([2, 0]);
|
||||
expect(parseVersion('1.2.0-beta')).toBeNull();
|
||||
expect(parseVersion('')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isNewerVersion', () => {
|
||||
it('compares numerically, part by part', () => {
|
||||
expect(isNewerVersion('1.10.0', '1.9.3')).toBe(true);
|
||||
expect(isNewerVersion('1.1.0', '1.1.0')).toBe(false);
|
||||
expect(isNewerVersion('1.1', '1.1.0')).toBe(false);
|
||||
expect(isNewerVersion('1.1.1', '1.1')).toBe(true);
|
||||
expect(isNewerVersion('1.0.9', '1.1.0')).toBe(false);
|
||||
});
|
||||
|
||||
it('never treats something unparseable as newer', () => {
|
||||
expect(isNewerVersion('nightly', '1.0.0')).toBe(false);
|
||||
expect(isNewerVersion('2.0.0', 'unknown')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
/** The newest Android release, as the server reports it (GET /app/android/latest). */
|
||||
export interface AppUpdateDTO {
|
||||
/** e.g. "1.2.0" (the release tag without its "v"). */
|
||||
version: string;
|
||||
/** Where to download the APK (through our server, so the release host can stay private). */
|
||||
apkUrl: string;
|
||||
/** Hex SHA-256 of the APK; the phone checks it before installing. */
|
||||
sha256: string;
|
||||
size: number;
|
||||
/** What's new, from the release notes. */
|
||||
notes: string | null;
|
||||
publishedAt: string;
|
||||
}
|
||||
|
||||
/** "1.10.0" → [1, 10, 0]; null for anything that isn't a plain dotted version (an optional leading "v" is fine). */
|
||||
export function parseVersion(version: string): number[] | null {
|
||||
const m = /^v?(\d+(?:\.\d+)*)$/.exec(version.trim());
|
||||
return m ? m[1]!.split('.').map(Number) : null;
|
||||
}
|
||||
|
||||
/** True when `candidate` is a later version than `current`. Unparseable versions are never newer. */
|
||||
export function isNewerVersion(candidate: string, current: string): boolean {
|
||||
const a = parseVersion(candidate);
|
||||
const b = parseVersion(current);
|
||||
if (!a || !b) return false;
|
||||
for (let i = 0; i < Math.max(a.length, b.length); i++) {
|
||||
const diff = (a[i] ?? 0) - (b[i] ?? 0);
|
||||
if (diff !== 0) return diff > 0;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
Reference in New Issue
Block a user