Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 37 additions & 12 deletions AGENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,16 +33,17 @@ lib/
main.dart App root: splash -> onboarding -> home, revealed from the bottom
core/ No widgets (except updates.dart's Flutter binding); safe for the background isolate
config.dart Hosts, user agent, page size
models.dart Creator, Post, MediaFile, Poll, Favorite, Follow, Community*, UpdateNotice, ... and JSON parsing; threadComments
models.dart Creator, Post, MediaFile, Poll, Favorite, Like, Follow + NotifyLevel, Community*, UpdateNotice, ... and JSON parsing; threadComments
api.dart PawchiveApi (all HTTP); scrapers for DMs, popular posts, similar artists, community chats
store.dart Store: SharedPreferencesAsync + secure storage (session cookie); AppSettings
backup.dart Backup file (follows, likes, sign-in sealed with AES-GCM + PBKDF2), mergeFollows / mergeFavorites
feeds.dart Feed (paged ChangeNotifier), OffsetFeed, PopularFeed, ForYouFeed + rankForYou, FollowingFeed (k-way merge)
feeds.dart Feed (paged ChangeNotifier), OffsetFeed, PopularFeed, FollowingFeed (k-way merge)
for_you.dart ForYouFeed: Taste (follows, likes, favorites), pickSources, takeBest, blendForYou
creators.dart CreatorDirectory: cached, throttled profile lookups (feed posts carry only ids)
updates.dart UpdateChecker, Notifier (local notifications), BackgroundUpdates (WorkManager)
updates.dart UpdateChecker, pushable, Notifier (local notifications, banner channel), BackgroundUpdates (WorkManager)
downloads.dart DownloadManager queue (2 at once, 1 s apart) writing to a SAF folder via DownloadTarget
app_update.dart AppUpdater: GitHub releases check (AppRelease.newest) and install via the pawress/updater channel
app_state.dart AppState: settings, account, follows, liked posts, notices, favorites sync, backup import/export
app_state.dart AppState: settings, account, follows + bells, likes, favorites, notices + in-app alerts, sync, backup
ui/
app_scope.dart AppScope.of(context) / AppScope.read(context) give AppState
theme.dart Palette (from assets), buildTheme(), BrandMark, SectionLabel
Expand All @@ -51,7 +52,8 @@ lib/
onboarding.dart Four parallax slides (art lags, text leads), notification permission, optional sign-in
home.dart HomeScreen (floating SliverAppBar + For You / Following / Popular tabs, Popular's bottom bar) and Sidebar (drawer)
popular.dart PopularTab: popular artists (from /creators, sorted on device) and PopularFeed posts
post_card.dart X-style post row, PostActions (comments / like), MediaGrid, openPost/openCreator/openViewer helpers
post_card.dart X-style post row, PostActions (comments / thumbs up / favorite), MediaGrid, openPost/openCreator/openViewer helpers
notify_bell.dart NotifyBell (X-style bell beside Following) and showNotifySheet
comments.dart CommentList (threaded, left-aligned) and showComments (bottom sheet)
poll.dart PollView: a Patreon poll's results as bars
autoplay.dart FeedAutoplay (picks the one playing tile) and AutoplayMedia (muted video / GIF tile)
Expand All @@ -62,14 +64,18 @@ lib/
creator_page.dart Artist profile (banner, site, links, Follow, Similar artists sheet) + tabs; FollowButton, ArtistTile
community_page.dart CommunityChatPage: a Patreon community channel as a chat log, newest at the bottom
backup.dart Export / import dialogs (SAF folder and file pickers)
favorites_page.dart, following_page.dart, notifications_page.dart, search_page.dart, settings_page.dart, login_page.dart
settings_cards.dart BooruPlus-style settings panels (SettingsGroup, SettingsSwitchCard, SettingsChoiceCard, ...)
settings_page.dart Settings, laid out like BooruPlus's but applied instantly (no save button)
notification_settings.dart X-style notification preferences and per-artist bells
favorites_page.dart, following_page.dart, notifications_page.dart, search_page.dart, login_page.dart
test/
core_test.dart Parsing, URLs, ranking, entity decoding, polls, fancards, comment threads, DM / popular / similar-artist / community scraping
for_you_test.dart Taste, source sampling, blending and ForYouFeed over fake fetches
core_test.dart Parsing, URLs, entity decoding, polls, fancards, comment threads, DM / popular / similar-artist / community scraping
backup_test.dart Backup encoding and sealing, merges, AppState likes and their account sync
update_checker_test.dart UpdateChecker against a fake Pawchive (MockClient + in-memory prefs)
downloads_test.dart DownloadManager: URL resolution, naming, failure and retry (in-memory target)
app_update_test.dart Release picking, nightly notes, AppUpdater over a mocked channel and GitHub
android/app/src/main/kotlin/.../MainActivity.kt pawress/updater channel: appVersion, installUpdate
android/app/src/main/kotlin/.../MainActivity.kt pawress/updater channel: appVersion, installUpdate, notificationSettings
.github/workflows/ ci.yml (PRs, and called by CD) and cd.yml (nightly / tagged releases)
docs/pawchive-api.md API reference and quirks
```
Expand All @@ -85,7 +91,10 @@ docs/pawchive-api.md API reference and quirks
- **Full files.** `MediaFile.fullUrl` gives `temp_url` or the file host URL,
or null when the post must be fetched again; the viewer does that.
Thumbnails exist only for images.
- **Favorites (likes).** The heart on a post (`PostActions`) stores the whole
- **Likes (thumbs up).** The thumbs up on a post stores a `Like` (post key,
creator, tags, time) on the device only; Pawchive has no likes. It only
feeds For You, and Settings can reset them. They go into backups.
- **Favorites.** The heart on a post (`PostActions`) stores the whole
`Post` locally (`Favorite`, without `temp_url`s, which expire) and, when
signed in, syncs with the account's favorite posts the same way follows do
(`Favorite.synced`). The Favorites page in the sidebar lists them with no
Expand All @@ -108,6 +117,15 @@ docs/pawchive-api.md API reference and quirks
with Pawchive favorite creators (`AppState.syncFavorites`): `Follow.synced`
marks follows already seen on the account, so a missing synced follow means
it was removed on the website.
- **Notification levels.** Each `Follow` has a `NotifyLevel` (the bell on
the profile and in Following): `push` (Android notification, on the
`artist_updates_banner` high-importance channel when
`AppSettings.notifyBanners` is on, otherwise `artist_updates`), `inApp`
(Notifications page, badge and an in-app alert snackbar) or `off` (not
checked, no requests). New follows start at `AppSettings.defaultNotify`.
Turning a bell on from off clears `seenPostIds` so the next check only
records a baseline. HomeScreen polls the store each minute while
resumed (`AppState.pollNotices`, no network) to raise in-app alerts.
- **Notifications.** `UpdateChecker.check()` polls each follow's profile
`updated` stamp, fetches the first page only when it changed, and treats
post ids not in `Follow.seenPostIds` as new. A creator's first check only
Expand All @@ -116,9 +134,15 @@ docs/pawchive-api.md API reference and quirks
minimum). The background isolate builds its own `Store` and `PawchiveApi`,
so keep `core/` free of UI dependencies. The app reloads follows and notices
from the store on resume.
- **For You.** The API has no recommendations. Each page of `/posts` is
reordered by `rankForYou` (recency, plus boosts for followed creators and
sites, without long runs from one creator).
- **For You.** The API has no recommendations; `ForYouFeed` builds them.
`Taste` weighs creators, sites and tags from follows, thumbs-up likes
(half-life 30 days) and favorites (60 days). Each page takes
`ForYouMix.followingShare` (Settings) of its 30 posts from followed and
liked artists, whose first pages are fetched a few at a time
(`pickSources`: weighted random by affinity and recent activity; at most
6 creator requests per page), and the rest from `/posts`. Both pools are
ranked by `Taste.score` (freshness + taste + jitter), capped per creator,
blended and spread so no artist fills consecutive rows.
- **Popular.** There's no API for it. Popular posts are scraped from
`/posts/popular` (`parsePopularPage`); those posts only carry a thumbnail
path, so their file is marked preview-only and the viewer and downloads
Expand Down Expand Up @@ -208,6 +232,7 @@ scheduled.

- iOS and other platforms (only `android/` exists).
- Blocked tags and a light theme.
- A "not interested" signal for For You (only positive likes exist).
- Downloads run only while the app is open (no foreground service or
download notification).
- Older popular ranges (the page's "prev" links are parsed into
Expand Down
21 changes: 20 additions & 1 deletion android/app/src/main/kotlin/com/example/pawress/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ package com.example.pawress
import android.content.Intent
import android.content.pm.PackageInfo
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.provider.Settings
import androidx.core.content.FileProvider
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
Expand All @@ -19,7 +21,8 @@ import java.util.concurrent.Executors
* Hosts the `pawress/updater` channel used by lib/core/app_update.dart: the
* installed version, and downloading a GitHub release APK into the package
* installer once it is checked to be a newer build of this app signed with
* the same key.
* the same key. It also opens the app's page in Android's notification
* settings for lib/core/updates.dart.
*/
class MainActivity : FlutterActivity() {
private val executor = Executors.newSingleThreadExecutor()
Expand All @@ -45,10 +48,26 @@ class MainActivity : FlutterActivity() {
result.success(mapOf("name" to (info.versionName ?: ""), "build" to versionCode(info)))
}
"installUpdate" -> installUpdate(call, result)
"notificationSettings" -> result.success(openNotificationSettings())
else -> result.notImplemented()
}
}

private fun openNotificationSettings(): Boolean {
val intent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS)
.putExtra(Settings.EXTRA_APP_PACKAGE, packageName)
} else {
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.parse("package:$packageName"))
}
return try {
startActivity(intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
true
} catch (_: Exception) {
false
}
}

private fun installUpdate(call: MethodCall, result: MethodChannel.Result) {
val url = call.argument<String>("url")
if (url == null || !isUpdateUrl(url)) {
Expand Down
124 changes: 116 additions & 8 deletions lib/core/app_state.dart
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import 'app_update.dart';
import 'backup.dart';
import 'creators.dart';
import 'downloads.dart';
import 'for_you.dart';
import 'models.dart';
import 'store.dart';
import 'updates.dart';
Expand Down Expand Up @@ -41,14 +42,22 @@ class AppState extends ChangeNotifier {
List<Follow> follows = [];
List<Favorite> favorites = [];
List<UpdateNotice> notices = [];

/// Thumbs-up likes, newest first; they only feed For You.
List<Like> likes = [];
Set<String> _favoriteKeys = {};
Set<String> _likeKeys = {};

/// A foreground update check is running.
bool checking = false;

/// Shown once by the home screen, e.g. after a session expired.
String? message;

/// New posts the background check found while the app was open, shown
/// once by the home screen as an in-app alert.
List<UpdateNotice>? _alert;

Future<List<Creator>>? _allCreators;

bool get signedIn => api.session != null;
Expand All @@ -61,6 +70,7 @@ class AppState extends ChangeNotifier {
accountName = await store.accountName();
follows = await store.follows();
_setFavorites(await store.favorites());
_setLikes(await store.likes());
notices = await store.notices();
directory.remember(follows.map((f) => f.creator));
initialized = true;
Expand All @@ -69,13 +79,45 @@ class AppState extends ChangeNotifier {
if (signedIn) unawaited(syncFavorites());
}

/// Picks up follows and notices the background check wrote.
Future<void> reload() async {
/// Picks up follows and notices the background check wrote. With
/// [alert], notices that are new since the last load become an in-app
/// alert when that is turned on.
Future<void> reload({bool alert = false}) async {
final known = {for (final notice in notices) notice.id};
follows = await store.follows();
notices = await store.notices();
if (alert && settings.notificationsEnabled && settings.inAppAlerts) {
final levels = {for (final follow in follows) follow.key: follow.notify};
final fresh = [
for (final notice in notices)
if (!notice.read &&
!known.contains(notice.id) &&
(levels[notice.creator.key] ?? NotifyLevel.off) !=
NotifyLevel.off)
notice,
];
if (fresh.isNotEmpty) _alert = fresh;
}
notifyListeners();
}

/// Reloads only when the stored notices changed, so the open app can look
/// for the background check's results cheaply.
Future<void> pollNotices() async {
final stored = await store.notices();
if (stored.length == notices.length &&
stored.firstOrNull?.id == notices.firstOrNull?.id) {
return;
}
await reload(alert: true);
}

List<UpdateNotice>? takeAlert() {
final alert = _alert;
_alert = null;
return alert;
}

Future<void> completeOnboarding() async {
onboarded = true;
await store.setOnboarded(true);
Expand All @@ -97,7 +139,10 @@ class AppState extends ChangeNotifier {

Future<void> _schedule() async {
try {
await BackgroundUpdates.apply(settings, hasFollows: follows.isNotEmpty);
await BackgroundUpdates.apply(
settings,
hasFollows: follows.any((f) => f.notify != NotifyLevel.off),
);
} catch (e) {
debugPrint('Could not schedule background updates: $e');
}
Expand All @@ -113,7 +158,11 @@ class AppState extends ChangeNotifier {
Future<void> follow(Creator creator) async {
if (isFollowing(creator.key)) return;
follows = [
Follow(creator: creator, followedAt: DateTime.now().toUtc()),
Follow(
creator: creator,
followedAt: DateTime.now().toUtc(),
notify: settings.defaultNotify,
),
...follows,
];
await _saveFollows();
Expand Down Expand Up @@ -141,6 +190,27 @@ class AppState extends ChangeNotifier {
}
}

/// Sets the bell for a followed creator. Coming back from off, posts
/// made meanwhile weren't checked, so the next check only records a new
/// baseline instead of reporting them all.
Future<void> setNotify(String key, NotifyLevel level) async {
final follow = followOf(key);
if (follow == null || follow.notify == level) return;
_replace(key, (f) => _withNotify(f, level));
await _saveFollows();
}

/// Sets every follow's bell at once.
Future<void> setNotifyAll(NotifyLevel level) async {
follows = [for (final f in follows) _withNotify(f, level)];
await _saveFollows();
}

static Follow _withNotify(Follow follow, NotifyLevel level) =>
follow.notify == NotifyLevel.off && level != NotifyLevel.off
? follow.copyWith(notify: level, seenPostIds: const [])
: follow.copyWith(notify: level);

/// Records a new follow's current posts so only later ones notify.
Future<void> _baseline(String key) async {
final follow = followOf(key);
Expand Down Expand Up @@ -222,7 +292,7 @@ class AppState extends ChangeNotifier {
await _schedule();
}

// Favorites (liked posts).
// Favorites (the heart), synced with the account's favorite posts.

bool isFavorite(Post post) => _favoriteKeys.contains(post.key);

Expand All @@ -245,7 +315,7 @@ class AppState extends ChangeNotifier {
]);
await _saveFavorites();
} on ApiException catch (e) {
_say('Liked here, but not on your Pawchive account: ${e.message}');
_say('Favorited here, but not on your Pawchive account: ${e.message}');
}
}
}
Expand All @@ -257,7 +327,9 @@ class AppState extends ChangeNotifier {
try {
await api.removeFavoritePost(post);
} on ApiException catch (e) {
_say('Unliked here, but not on your Pawchive account: ${e.message}');
_say(
'Removed here, but not from your Pawchive favorites: ${e.message}',
);
}
}
}
Expand All @@ -272,13 +344,47 @@ class AppState extends ChangeNotifier {
await store.saveFavorites(favorites);
}

// Likes (thumbs up).

bool isLiked(Post post) => _likeKeys.contains(post.key);

Future<void> toggleLike(Post post) async {
_setLikes(
isLiked(post)
? likes.where((like) => like.key != post.key).toList()
: [Like.of(post, DateTime.now().toUtc()), ...likes],
);
notifyListeners();
await store.saveLikes(likes);
}

Future<void> clearLikes() async {
_setLikes([]);
notifyListeners();
await store.saveLikes(likes);
}

void _setLikes(List<Like> value) {
likes = value;
_likeKeys = {for (final like in value) like.key};
}

/// What For You learns from, read for each page.
ForYouSignals forYouSignals() => (
follows: follows,
likes: likes,
favorites: favorites,
followingShare: settings.forYouMix.followingShare,
);

// Backup.

/// Follows and likes, plus the sign-in sealed with [password] when one is
/// given and the user is signed in.
Future<Backup> exportBackup({String? password}) async => Backup(
follows: follows,
favorites: favorites,
likes: likes,
credentials: password == null || password.isEmpty || !signedIn
? null
: await SealedCredentials.seal(
Expand All @@ -299,6 +405,8 @@ class AppState extends ChangeNotifier {
final favoriteCount = favorites.length;
follows = mergeFollows(follows, backup.follows);
_setFavorites(mergeFavorites(favorites, backup.favorites));
_setLikes(mergeLikes(likes, backup.likes));
await store.saveLikes(likes);
directory.remember(follows.map((f) => f.creator));
await _saveFollows();
await _saveFavorites();
Expand Down Expand Up @@ -414,7 +522,7 @@ class AppState extends ChangeNotifier {
]);
await _saveFavorites();
} on ApiException catch (e) {
if (!e.unauthorized) _say('Could not sync likes: ${e.message}');
if (!e.unauthorized) _say('Could not sync favorites: ${e.message}');
}
}

Expand Down
Loading
Loading