Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
69 commits
Select commit Hold shift + click to select a range
8043580
Open the mode switch from the chip, and the hub on the source in use
professorDeveloper Sep 14, 2026
6402164
Give the source hub tabs that swipe and a header that stays
professorDeveloper Sep 14, 2026
d8177ba
Make adding a source one level of one screen
professorDeveloper Sep 14, 2026
2db0a47
Separate keeping a title from doing something with it
professorDeveloper Sep 14, 2026
34fb98a
Let Home be a catalogue, and find a source for whatever it opens
professorDeveloper Sep 15, 2026
29b84a5
Give a catalogue switch the same size and the same beat as a mode switch
professorDeveloper Sep 15, 2026
e0098f4
Pick catalogue search candidates by mode, not by category word
professorDeveloper Sep 15, 2026
8e5aec4
Build an AniList title's page from AniList, and show the hand-off abo…
professorDeveloper Sep 15, 2026
c31a7ad
Put tracking on the page, and the record in a tab of its own
professorDeveloper Sep 15, 2026
174ebf1
Build a TMDB title's page from TMDB, and search inside a catalogue
professorDeveloper Sep 15, 2026
799ae8b
Ask the sources the server found dead last
professorDeveloper Sep 15, 2026
65ab548
Show a catalogue by its own mark, everywhere it is named
professorDeveloper Sep 15, 2026
28a2d4a
Give Manga and Novels a catalogue, and let a title's page bloom in
professorDeveloper Sep 15, 2026
6d3a481
Open the whole list entry from the tracking row
professorDeveloper Sep 15, 2026
430af74
Make the search tab somewhere to start from
professorDeveloper Sep 15, 2026
7188cea
Make the switcher one control, and the catalogues cards
professorDeveloper Sep 15, 2026
5da254f
Put water in the switch, and what is inside on the catalogue cards
professorDeveloper Sep 15, 2026
80f4406
Re-run dart format under the pinned Flutter
professorDeveloper Sep 16, 2026
b2088fd
Make the mark the thing that arrives, and stop offering the wrong show
professorDeveloper Sep 16, 2026
d7d717a
Show the covers, and speak Cantonese
professorDeveloper Sep 16, 2026
565401a
Make a language change reach the screens that are already built
professorDeveloper Sep 16, 2026
771ff55
Stop a row leading nowhere, and draw the edge instead of lighting it
professorDeveloper Sep 16, 2026
7490d69
Finish the reader's half, and make the desktop build compile at all
professorDeveloper Sep 17, 2026
fc06a9e
Name the search shelf ourselves, and give the player bar room
professorDeveloper Sep 17, 2026
2986261
Say nothing about a source that has not answered yet
professorDeveloper Sep 17, 2026
7c4c1b2
Keep what the viewer watched when the connection does not
professorDeveloper Sep 18, 2026
f2d0f97
Stop incognito leaking, and stop an outage settling a title forever
professorDeveloper Sep 18, 2026
1bc0263
Say when the categories did not load, and stop discarding long viewings
professorDeveloper Sep 18, 2026
b9caf7c
Give an extension search a deadline, and let a broken one say so
professorDeveloper Sep 18, 2026
ea619bb
Stop history evicting itself, and stop losing a comment that did not …
professorDeveloper Sep 18, 2026
4e6d77b
Stop the app quietly moving everyone onto VidAPI
professorDeveloper Sep 18, 2026
d0145b5
Let a JS source fall back, and tell the app when the source changes
professorDeveloper Sep 18, 2026
60d960f
Let the artwork carry the colour on the genre grids
professorDeveloper Sep 18, 2026
33fdb60
Offer a manga only the sources that can carry one, and say when Play …
professorDeveloper Sep 18, 2026
3a26ed5
Make a score look like a score, and About readable at a glance
professorDeveloper Sep 18, 2026
9401f02
Stop the Sources list fighting the keyboard, and stop giving up with …
professorDeveloper Sep 18, 2026
a869020
Make solving Cloudflare actually change what playback sends
professorDeveloper Sep 18, 2026
b713d62
Stop the all-source page rebuilding the whole world on every arriving…
professorDeveloper Sep 18, 2026
f31d7d2
Say exactly what a download is, and fetch the better copy of it
professorDeveloper Sep 19, 2026
f71bb77
Send the fetch metadata a browser sends, so a CDN stops refusing segm…
professorDeveloper Sep 19, 2026
dbbbc5d
Offer this title's other servers before offering other sources
professorDeveloper Sep 19, 2026
a1f0a8d
Stop opening a sheet to answer a three-way question
professorDeveloper Sep 19, 2026
86e071f
Keep a damaged box, and stop giving up on a title too early
professorDeveloper Sep 19, 2026
7c4c3e8
Say why a manga source failed, in words a reader can act on
professorDeveloper Sep 19, 2026
3c0c35f
Stop one nameless row taking a whole source's page with it
professorDeveloper Sep 20, 2026
d7c9472
Say which chapters are read, and open one on the source's own site
professorDeveloper Sep 20, 2026
5f92ee7
Let a novel be downloaded, and taken out again as a book
professorDeveloper Sep 20, 2026
31c8a77
Rank a source by what has actually played on it
professorDeveloper Sep 20, 2026
7a17b2b
Re-read a zoomed manga page instead of magnifying it
professorDeveloper Sep 20, 2026
80522b4
Give light novels somewhere to come from
professorDeveloper Sep 20, 2026
069f2d5
Filter sources by repository, from one menu instead of a chip row
professorDeveloper Sep 20, 2026
c78f1f4
Ask a source where its chapter lives instead of guessing
professorDeveloper Sep 21, 2026
5ce39c6
Find a protected stream's proxy by its url, not by a list index
professorDeveloper Sep 21, 2026
92e5d34
Browse what each streaming service carries where you are
professorDeveloper Sep 21, 2026
f53dc45
Cycle the aspect ratio, and let the control say which one is on
professorDeveloper Sep 21, 2026
71a3f8b
Make Home follow the selected source, and a correction open what it c…
professorDeveloper Sep 21, 2026
8e26bdb
Count down to the next episode, on a clock that is running
professorDeveloper Sep 21, 2026
c10f4e2
Let a Japanese title match itself
professorDeveloper Sep 21, 2026
3aeb168
Say what the search is showing, and land it in one move
professorDeveloper Sep 21, 2026
072ee6b
Make the flip a flip, and the source list a window
professorDeveloper Sep 21, 2026
16422e5
Fix the flip, open the sheet up, and put the services back on a screen
professorDeveloper Sep 21, 2026
559296b
Offer Home the streaming services, and make them work where you are
professorDeveloper Sep 21, 2026
56ce17e
Let the page fetch its own line-up, and make the offer an offer
professorDeveloper Sep 21, 2026
f3de491
Keep asking in debug, so the card can be looked at twice
professorDeveloper Sep 21, 2026
4fdead1
Put the offer under the banner, and give the band its marks
professorDeveloper Sep 21, 2026
bc43f1e
Put the offer next to the other ways to browse
professorDeveloper Sep 21, 2026
fe7fdea
Put the band where the offer was, and ask once again
professorDeveloper Sep 21, 2026
c6c4b60
Open paused, open your own subtitles, and wait between downloads
professorDeveloper Sep 21, 2026
41df3b3
Give the chapter list a spine and something to type into
professorDeveloper Sep 21, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
41 changes: 41 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -101,3 +101,44 @@ jobs:
# needs secrets a fork's pull request cannot have, and gating CI on them
# would mean no outside contribution could ever go green.
- run: flutter build apk --debug

# The same argument as build-android, applied to the desktop side: a broken
# CMakeLists, a plugin with no Linux implementation or a missing system
# library is invisible to the analyzer and only surfaces on release day, in
# the job that is supposed to be publishing.
#
# Linux only, on purpose. A windows-latest runner bills at twice the Linux
# rate and its builds are the slowest of the three, which would roughly
# triple the cost of every pull request to catch a narrow class of
# Windows-only breakage. Most desktop plugin failures are not platform
# specific, and Linux catches those for the price of the cheapest runner
# GitHub sells. If Windows-only breakage ever actually reaches a release,
# that is the evidence for adding the job — not this comment.
build-linux:
runs-on: ubuntu-latest
timeout-minutes: 35
env:
# Some transitive native dependencies still declare a cmake_minimum_required
# below 3.5, which current CMake refuses outright. Mirrors release.yml.
CMAKE_POLICY_VERSION_MINIMUM: '3.5'
steps:
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2
with:
channel: stable
cache: true

# Kept in step with the same list in release.yml's linux job; a build that
# goes green here and red there teaches nobody anything.
- name: Install Linux desktop + plugin system deps
run: |
sudo apt-get update
sudo apt-get install -y \
clang cmake ninja-build pkg-config \
libgtk-3-dev liblzma-dev libstdc++-12-dev \
libmpv-dev mpv \
libsecret-1-dev libjsoncpp-dev libsecret-1-0

- run: touch .env
- run: flutter pub get
- run: flutter build linux --debug
40 changes: 38 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -579,10 +579,23 @@ jobs:
- run: flutter --version
- run: flutter pub get
- run: flutter build windows --release
# /DMyAppVersion, because the .iss cannot read pubspec.yaml. Without it the
# installer falls back to its own placeholder and every release would show
# up under the same version in Add/Remove Programs.
- name: Build installer (Inno Setup)
shell: pwsh
env:
NAME: ${{ needs.version.outputs.name }}
run: |
choco install innosetup --no-progress -y
& "C:\Program Files (x86)\Inno Setup 6\ISCC.exe" "windows\installer\Sozo.iss"
# /D emulates `#define public <name> <value>`, and the value is handed
# to the preprocessor as source: a bare 3.0.4 is not something ISPP can
# parse, so the version has to arrive already wrapped in literal double
# quotes to be unambiguously a string. PowerShell re-quotes arguments on
# their way to a native command and would eat those quotes, so --% hands
# the rest of the line to ISCC verbatim — which also means the version
# has to come in as cmd-style %NAME%, the one expansion --% still does.
& "C:\Program Files (x86)\Inno Setup 6\ISCC.exe" --% /DMyAppVersion="%NAME%" windows\installer\Sozo.iss
- name: Also zip the raw build (portable)
shell: pwsh
run: |
Expand Down Expand Up @@ -648,9 +661,32 @@ jobs:
- run: flutter --version
- run: flutter pub get
- run: flutter build linux --release
# The bundle on its own is a binary in a folder: no menu entry, no icon,
# and nothing claiming the sozo:// scheme, so a Linux user got a strictly
# worse app than the Windows and macOS ones from the same release. The
# three files from linux/packaging travel with it and install.sh wires
# them into the user's session without root — which is the only kind of
# install a tarball can honestly offer.
- name: Package (tar.gz)
run: |
cd build/linux/x64/release/bundle
set -euo pipefail
BUNDLE=build/linux/x64/release/bundle
# Copied one at a time, and only when present. A missing packaging file
# costs the desktop integration; it does not make the build unusable,
# and under `set -e` a single `cp` of all three would turn that into a
# failed release. ::warning:: surfaces it on the run summary instead, so
# a file that never got committed is visible rather than fatal.
for f in sozo.desktop sozo.png install.sh; do
if [ -f "linux/packaging/$f" ]; then
cp "linux/packaging/$f" "$BUNDLE/"
else
echo "::warning::linux/packaging/$f is missing from the checkout — the Linux tarball ships without it."
fi
done
if [ -f "$BUNDLE/install.sh" ]; then
chmod +x "$BUNDLE/install.sh"
fi
cd "$BUNDLE"
tar -czf "$GITHUB_WORKSPACE/Sozo-Linux-x64.tar.gz" .
- uses: actions/upload-artifact@v4
with:
Expand Down
9 changes: 9 additions & 0 deletions .metadata
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,21 @@ migration:
- platform: root
create_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
base_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
- platform: android
create_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
base_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
- platform: ios
create_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
base_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
- platform: linux
create_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
base_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
- platform: macos
create_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
base_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
- platform: windows
create_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
base_revision: ff37bef603469fb030f2b72995ab929ccfc227f0

# User provided section

Expand Down
72 changes: 72 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
# Security policy

## Reporting a vulnerability

**Do not open a public issue for a security problem.** An issue is visible to
everyone the moment it is filed, including to people who would use it before it
is fixed.

Report it privately through GitHub's own channel:

> **[Report a vulnerability](https://github.com/professorDeveloper/sozo/security/advisories/new)**

That opens a draft advisory only the maintainers and you can read. If you cannot
use it for any reason, say so in a normal issue **without any detail** — just
"I would like to report a security issue privately" — and you will be contacted.

### What helps

- What an attacker can do, in one sentence. That is what decides how fast this
moves.
- The version — the one on the About screen, or a commit hash.
- The platform and OS version.
- The smallest reproduction you have. A log with the secret parts removed is
worth more than a description of a log.

You do not need a proof-of-concept exploit, and you do not need to write the
fix. A clear description of the flaw is enough.

### What to expect

- An acknowledgement within a few days.
- An assessment, and the reasoning behind it, once the report has been read
properly — including when the answer is that it is not a vulnerability.
- Credit in the advisory and the release notes, under whatever name you choose,
unless you ask not to be named.

Please give a fix a reasonable chance to ship before disclosing publicly. If a
report goes unanswered, that is a failure on this end and not a reason to keep
waiting indefinitely.

## What is in scope

Anything in this repository, and anything the app does on a user's device:

- The app itself, on every platform it ships to.
- The stored data. The session and tracker tokens and the PIN-hidden private
list are AES-encrypted at rest with a key held in the platform keystore; a way
to read any of it without the keystore is a vulnerability.
- The app lock, and any way around it.
- The deep-link handlers (`sozo://`, `https://`) and the "Open with" paths.
- Anything that lets a source, an extension or a page it loads reach further
than the content it is meant to serve — the file system, the keystore, another
source's cookies, or the app's own JavaScript runtime.
- Secrets in the repository or in a shipped artifact.

## What is not

- **Third-party sources.** Sozo does not host content; a source's site being
hostile, wrong or down is that site's business. If a source can escape the
sandbox it runs in, that IS in scope — report it.
- A vulnerability in a dependency that Sozo does not actually reach. Report it
upstream; mention it here if you believe our usage makes it exploitable.
- Reports that only say a scanner flagged something, with no path to an effect.
- Anything requiring a rooted or jailbroken device and physical access, unless
it defeats the app lock or the encryption at rest, which are the two things
that exist for exactly that case.

## Supported versions

The latest release. Fixes go into the next release rather than into patches of
older ones — there is no long-term support branch, and asking users to stay on
an old version is not a security answer.
55 changes: 55 additions & 0 deletions android/app/src/main/kotlin/com/soplay/sozo/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ class MainActivity : FlutterFragmentActivity() {
private val cloudstreamChannelName = "soplay/cloudstream"
private var cloudstreamChannel: MethodChannel? = null
private var previewChannel: MethodChannel? = null
private var tilesChannel: MethodChannel? = null
private val cloudstreamScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val pluginHost by lazy {
// CloudflareKiller is constructed by plugins with no arguments, so it
Expand Down Expand Up @@ -171,6 +172,12 @@ class MainActivity : FlutterFragmentActivity() {
platformChannel?.setMethodCallHandler { call, result ->
when (call.method) {
"isTv" -> result.success(isLeanbackDevice())
"isEmulator" -> result.success(
Build.HARDWARE == "goldfish" || Build.HARDWARE == "ranchu" ||
Build.MODEL.startsWith("sdk_gphone") ||
Build.MODEL.contains("Android SDK built for") ||
Build.PRODUCT.startsWith("sdk_gphone")
)
"openExternalVideo" -> {
val url = call.argument<String>("url").orEmpty()
val title = call.argument<String>("title").orEmpty()
Expand Down Expand Up @@ -741,6 +748,54 @@ class MainActivity : FlutterFragmentActivity() {
}
}

// Sharp zoom on a manga page. See PageTiles: the whole point is that
// nothing here ever holds a full-size bitmap, so every call is off the
// platform thread — a region of a large JPEG is tens of milliseconds
// and the reader is being panned while it runs.
tilesChannel = MethodChannel(
flutterEngine.dartExecutor.binaryMessenger,
"soplay/tiles",
)
tilesChannel?.setMethodCallHandler { call, result ->
when (call.method) {
"open" -> {
val path = call.argument<String>("path").orEmpty()
cloudstreamScope.launch {
val opened = com.soplay.sozo.tiles.PageTiles.open(path)
withContext(Dispatchers.Main) { result.success(opened) }
}
}
"region" -> {
val handle = (call.argument<Number>("handle") ?: 0).toLong()
val left = (call.argument<Number>("left") ?: 0).toInt()
val top = (call.argument<Number>("top") ?: 0).toInt()
val right = (call.argument<Number>("right") ?: 0).toInt()
val bottom = (call.argument<Number>("bottom") ?: 0).toInt()
val sample = (call.argument<Number>("sampleSize") ?: 1).toInt()
cloudstreamScope.launch {
val bytes = com.soplay.sozo.tiles.PageTiles.region(
handle, left, top, right, bottom, sample,
)
withContext(Dispatchers.Main) { result.success(bytes) }
}
}
"close" -> {
val handle = (call.argument<Number>("handle") ?: 0).toLong()
cloudstreamScope.launch {
com.soplay.sozo.tiles.PageTiles.close(handle)
withContext(Dispatchers.Main) { result.success(true) }
}
}
"closeAll" -> {
cloudstreamScope.launch {
com.soplay.sozo.tiles.PageTiles.closeAll()
withContext(Dispatchers.Main) { result.success(true) }
}
}
else -> result.notImplemented()
}
}

setupBridgeChannel(flutterEngine)
// Restart the bridge if the user had "share sources to desktop" on.
if (bridgePrefs().getBoolean("enabled", false)) startBridgeServer()
Expand Down
75 changes: 72 additions & 3 deletions android/app/src/main/kotlin/com/soplay/sozo/aniyomi/AniyomiHost.kt
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import eu.kanade.tachiyomi.animesource.model.AnimesPage
import eu.kanade.tachiyomi.animesource.model.SAnime
import eu.kanade.tachiyomi.animesource.model.SAnimeImpl
import eu.kanade.tachiyomi.animesource.model.Hoster
import eu.kanade.tachiyomi.animesource.model.SEpisode
import eu.kanade.tachiyomi.animesource.model.SEpisodeImpl
import eu.kanade.tachiyomi.animesource.model.Video
import eu.kanade.tachiyomi.animesource.online.AnimeHttpSource
Expand Down Expand Up @@ -429,13 +430,80 @@ class AniyomiHost(private val context: Context) {
private fun failureReason(id: String): String =
AniyomiRuntime.lastError ?: lastError ?: "source unavailable: an:$id"

/**
* A browse or search row.
*
* [SAnimeImpl.title] is `lateinit`, so an entry whose source never set one
* does not read back as empty — the getter throws
* `UninitializedPropertyAccessException`, and one such entry anywhere in a
* page took the whole page with it. The log line below already knew this
* and guarded its own read; the card that the reader actually sees did not,
* and the details path guards it separately.
*
* A row with no name falls back to the slug the site itself uses. It is
* legible and, more to the point, it still opens.
*/
/**
* A url a browser can open, out of the path an extension stores.
*
* An Aniyomi source keeps `SAnime.url` and `SEpisode.url` as paths relative
* to its own `baseUrl`, so the app held nothing it could hand to a browser.
* The source object knows the base; this is the only place that has both.
*
* Empty when there is nothing openable, and the caller omits the field
* entirely then — an action that cannot work should not be on screen.
*/
private fun webUrl(src: Any?, path: String?): String {
val p = path?.trim().orEmpty()
if (p.isEmpty()) return ""
if (p.startsWith("http://") || p.startsWith("https://")) return p
val base = (src as? AnimeHttpSource)?.baseUrl?.trimEnd('/').orEmpty()
if (base.isEmpty()) return ""
return if (p.startsWith("/")) base + p else "$base/$p"
}

/**
* An episode's page, asked of the source rather than assembled here.
*
* `SEpisode.url` is not always a path — for some sources it is a key the
* source turns into the real address, which is why
* [AnimeHttpSource.getEpisodeUrl] exists and why joining baseUrl to the
* stored string 404s on those. Its default IS the join, so a source that
* does not override it loses nothing. Same three fallbacks as the manga
* host: not an AnimeHttpSource, the source threw, or the answer is not
* http(s).
*/
private fun episodeWebUrl(src: Any?, episode: SEpisode): String {
val http = src as? AnimeHttpSource ?: return webUrl(src, episode.url)
val asked = try {
http.getEpisodeUrl(episode).trim()
} catch (_: Throwable) {
""
}
if (asked.startsWith("http://") || asked.startsWith("https://")) return asked
if (asked.isNotEmpty() && asked != episode.url) return webUrl(src, asked)
return webUrl(src, episode.url)
}

private fun titleOf(a: SAnime): String {
val given = try { a.title } catch (_: Throwable) { "" }
if (given.isNotBlank()) return given
return a.url.trimEnd('/')
.substringAfterLast('/')
.substringBefore('?')
.replace('-', ' ')
.replace('_', ' ')
.trim()
.ifEmpty { "Untitled" }
}

private fun cardJson(a: SAnime, id: String) = JSONObject().apply {
put("provider", "an:$id")
put("externalId", a.url)
put("title", a.title)
put("title", titleOf(a))
put("slug", a.url)
put("contentUrl", a.url)
put("thumbnail", a.thumbnail_url)
put("thumbnail", try { a.thumbnail_url } catch (_: Throwable) { null })
put("type", "Anime")
}

Expand Down Expand Up @@ -627,12 +695,13 @@ class AniyomiHost(private val context: Context) {
put("episode", num)
put("label", label)
put("mediaRef", e.url)
episodeWebUrl(src, e).takeIf { it.isNotEmpty() }?.let { put("webUrl", it) }
})
}
if (eps.isEmpty() && failure == null) {
failure = "no episodes returned for this title"
}
val title = try { details.title } catch (_: Throwable) { "" }
val title = titleOf(details)
val author = try { details.author } catch (_: Throwable) { null }
val status = statusLabel(try { details.status } catch (_: Throwable) { 0 })
val desc = buildString {
Expand Down
Loading
Loading