Skip to content

Vcvk - #305

Merged
shareui merged 49 commits into
mainfrom
vcvk
Aug 15, 2026
Merged

Vcvk#305
shareui merged 49 commits into
mainfrom
vcvk

Conversation

@shareui

@shareui shareui commented Aug 15, 2026

Copy link
Copy Markdown
Owner

No description provided.

claude and others added 30 commits August 6, 2026 08:51
Bold survived the original send but not the translation: that path
rebuilt the message as HTML and handed it to the SDK's parse_mode,
where the description formatting was lost. Entities are what Telegram
actually stores, so both paths now produce them directly.

Extract the message construction into messageBuilder.build_plugin_message
(text + TLRPC entities, UTF-16 offsets, description blockquote, sorted
output) and use it for the inline send and the translated rebuild alike,
so a translated message is formatted by exactly the same code as the
original.

The SDK's edit_message() can only carry entities produced by its own
parse modes, so the rebuild drives the host instead:
editingMessage / editingMessageEntities on the MessageObject followed by
SendMessagesHelper.editMessage(), which is what the SDK does internally
anyway. Falls back to a plain-text edit if that fails. The now-unused
HTML serialisation (88 lines) is gone.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0171ySGWMBZa2pESv9uTT44H
… topic (0.1.1-dev.20)

Install button: cancelling the dependency sheet left the profile's
install button spinning until the screen was reopened. The sheet does
report the cancel — it calls on_cancel(False) on every path — but the
profile's _finish(ok) only forwarded to an override and otherwise did
nothing, and the spinner it had started before install_plugin() is
stopped solely by the download callback. Stop it when the install did
not go through, in both the inline and the FAB variant; the same guard
already exists in the updates screen. This covers failed installs too,
which were left spinning the same way.

Send as file: the inline button sent the plugin with send_document(),
which only takes a peer, so inside a forum the file always landed in
the General topic. Resolve the topic the message lives in and send via
send_message() with replyToTopMsg, exactly as the inline message send
already does; outside forums nothing changes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0171ySGWMBZa2pESv9uTT44H
….21)

Icons stayed blank until the plugins page was reopened. The pending
list held weakrefs to the views, but a weakref there points at the
chaquopy wrapper rather than the java view: the wrapper is collected as
soon as the caller's local goes out of scope, while the view is still
on screen. diceStickersDidLoad then fired with nothing left to bind, so
the icon only appeared on the next visit — by which time the set is
cached and binds instantly. Hold the views strongly instead, drop each
entry the moment it binds, and cap the list so a set that never loads
cannot grow it without bound.

A chat sticker shows its document's svg thumb while the media
downloads; before the set is loaded there is no document and so no
thumb, which is why nothing was drawn underneath. Paint a neutral
rounded block in the same placeholder colour until the set arrives,
then hand over to the image receiver (svg thumb first, sticker once
downloaded) and clear it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0171ySGWMBZa2pESv9uTT44H
The export sheet lists every installed plugin at once, so it asks for ~25
sticker sets in one go — and in 0.1.0-rel that menu bound icons with a
one-shot retry 2 s after opening (plus a hardcoded account 0), which loses
the race against that many parallel set loads and never tries again: the
icons stay empty for as long as the sheet is open.

The central loader had since replaced that with loadStickersByEmojiOrName +
NotificationCenter.diceStickersDidLoad, but that route has its own dead
ends: it dedups by pack name through loadingDiceStickerSets and only posts
the notification for sets that actually resolve, so a view whose load is
swallowed or whose pack never resolves has nothing left to wake it.

Resolution now copies what the host itself does for a plugin icon —
PluginCell -> MediaDataController.setPlaceholderImageByIndex ->
getStickerSet(TL_inputStickerSetShortName, 0, false, callback). The callback
belongs to one request and always answers, whether the set comes from
stickerSetsByName, from the sqlite copy or from a messages.getStickerSet
fetch, and a resolved set lands in stickerSetsByName so later views for the
same pack bind synchronously. Views carry a "packit_sticker_<pack>_<index>"
tag and a late answer whose tag no longer matches is dropped, so a recycled
row cannot show the previous row's icon — the same guard the host uses. The
notification route stays as a fallback if that API is unavailable.

Export sheet: the icon also has to exist. We only parse the first 5 KB of a
plugin file for __icon__, so a header that sits further in leaves the row
without one; missing icon, name and version now fall back to the host's own
Plugin registry, which the engine fills from the plugin metadata.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0171ySGWMBZa2pESv9uTT44H
Cards in the icon-set catalog showed their name and count with an empty
icon, as if the preview had failed to download. It had not: all ten packs
in the official catalog serve every one of their preview images (100/100
answer 200 and decode), and the initial bind sets the bitmap with no
animation at all.

The only thing that writes to that image view afterwards is the swap
ticker, which crossfades a random card every two seconds: fade to alpha 0,
then set the next bitmap and fade back in from withEndAction. That end
action is not guaranteed to run — ViewPropertyAnimator's listener does
mAnimatorOnEndMap.remove(animation) in onAnimationCancel, so an interrupted
fade drops the runnable that would have restored the view. Nothing else
ever touches alpha, so the preview stays invisible for good.

Interruptions were easy to come by, because a list rebuild (search, sort)
replaced _card_registry and cleared _ticker_started without stopping the
previous ticker: it kept animating from its captured list forever, and
every rebuild added another one. Two tickers picking cards at random
eventually pick the same view and cancel each other's fades. The heartbeat
also re-posted itself on the first card's View, and postDelayed on a
detached view waits for re-attach, so the ticker could stall outright and
never heal anything.

Now: tickers carry a generation and stop when the registry is replaced, the
heartbeat runs on the main handler instead of a view, the swap is driven by
time rather than by an end action that can be dropped, and every tick
restores any card left below full alpha — so an interrupted fade costs one
frame instead of the rest of the session.

Cards also get the neutral placeholder used elsewhere until their first
preview decodes, and a pack whose previews all fail is retried once instead
of staying empty until the screen is reopened.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0171ySGWMBZa2pESv9uTT44H
The smiley on the repo button in the icon catalog came out with its rim
shaved flat on all four sides. Measuring the screenshot: the white artwork
forms a 20dp square with straight edges where the circle should curve, i.e.
it is drawn larger than its slot and clipped to it.

CatalogChromeNative.iconButton put the icon in a 20dp view with
ScaleType.CENTER, which draws a drawable at its intrinsic size and lets the
view clip whatever does not fit. The host's action icons are 24dp
(msg_smile_status is a 72x72 asset at xxhdpi), so 2dp came off every side.
msg_list survives that — its strokes sit ~3dp inside the canvas — but the
smiley draws its circle 1dp from the edge, so the crop ate the rim and only
that button looked broken.

CENTER_INSIDE keeps the drawable centered and only scales it down when it
does not fit, so every icon renders whole at the same button size. All the
buttons built by iconButton are covered: search clear and submit, the repo
and sort buttons in the icon catalog, tags and sort in the plugin catalog.

Dex rebuilt from all four kotlin sources (same 22 classes as before).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0171ySGWMBZa2pESv9uTT44H
Submitting a plugin still ended in "Failed to send", with the log naming a
file the staging step could not create:

  suggest._do_submit task error: [Errno 13] Permission denied:
    '/storage/emulated/0/Android/data/org.telegram.messenger/cache/tmp....plugin'

The directory itself is fine — the log export writes its own file into that
exact folder and succeeds, and getStagingDir had probed it with a plain
open() before handing it over. Staging then used a different call:
tempfile.NamedTemporaryFile opens with O_EXCL|O_NOFOLLOW at mode 0600 and
shutil.copy2 chmods the copy afterwards, and the FUSE-emulated external
volume refuses those where an ordinary write goes through. So the probe
passed, the real write did not, and the submit died before sending anything.

Staging now goes through paths.stageFileForUpload, which creates the file
the same way the probe does — plain open() and a byte copy, no metadata
copy — so the check and the write can no longer disagree. If the external
directory refuses it regardless, the copy falls back to the internal cache;
that path is already covered by the isInternalUri hook, so the upload
proceeds instead of failing.

paths.py also logs through logx now. It was using android_utils.log, which
never reaches latestlog.txt, so a bug report had no way to show which
directory staging picked.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0171ySGWMBZa2pESv9uTT44H
….26)

A card with four tags showed the last one as a thin sliver once the search
results narrowed the row: Chimera NFT keeps Elyx / Featured / Customization
/ Fun, and with the match badge taking the right-hand side "Fun" was laid
out a few dp wide.

The overflow listener asked each chip for getWidth(). By the time it runs,
a row that overran has already had its last chip squeezed into the space
that was left, so the widths add up to exactly the row width — the check
"do the chips fit" compared the result of not fitting against itself, found
no overflow, and left "+N" hidden with the squeezed chip on the card.

Chips are now measured unconstrained, the same way the "+N" chip already
was, so the total reflects the width they actually want. The overflowing
tag is dropped and counted in "+N" as intended, which opens the profile at
the full tag list.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0171ySGWMBZa2pESv9uTT44H
….27)

Two things went wrong once a filter was applied.

The header kept showing the unfiltered total. It asked _is_filtered()
whether anything was active, and that helper built its tag universe from
the tag names on the plugins — the untagged bucket was not among them. So
filtering by "Unsorted", which is 12 of the official repo's 43 plugins,
tested {"__unsorted__"} < {Utility, Tweaks, ...}, came back false, and the
header stayed on 43 while the list below correctly held 12. The same test
also misread a selection equal to the full set, or one carrying a name no
plugin uses any more. The count now comes from the two lists themselves —
len(filtered) against len(plugins) — which cannot drift from whatever the
filter engine did. The helper is gone rather than fixed; there is no second
source of truth to keep in sync.

Switching every chip in a section off silently turned the section back on:
the drawer refilled an empty selection with every key each time it
populated, so the filters looked like they reset themselves. Empty is now a
real state and shows an empty list (the existing "no plugins" stub), while
"never touched" is carried as None and still means no filter — a section
opens with everything selected exactly as before. The author and app
version filters also compared with a strict subset, so a selection holding
every value, or a stale one, counted as no filter; they now filter unless
the selection covers everything.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0171ySGWMBZa2pESv9uTT44H
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0171ySGWMBZa2pESv9uTT44H
…-dev.1)

The screen was a settings list: seven rows per repository, an icon picked by
name out of the host's R.drawable catalogue through a sub-fragment listing
several hundred of them, and editing done through inline inputs. It carried
no information about what a repository actually holds.

It is a fragment now, one card per repository: avatar, name, maintainer, a
switch, a status chip and — where the cached repomap happens to carry the
lists — counts. Telegram and source links sit as buttons on the card,
everything else (edit, copy link, share, delete) moved into its overflow
menu, and the six bulk actions that used to hide behind "Дополнительно" are
behind the button next to the counter. Cards fade in staggered, tapping one
flips its switch, a disabled repository goes outlined and dim.

The three fields the developer just added to repometa are what makes this
possible: rm_icon is now an image url, so the avatar is downloaded, cached
on disk and in memory, and drawn over a monogram that stands in until it
arrives — repository icons no longer come from R.drawable at all. rm_telegram
and rm_source became the two buttons. The repo=add deeplink sheet accepts
both spellings of rm_icon, since older repomaps still put a drawable name
there.

Adding and editing use the dialog the api-key screen already had: dimmed
overlay, card that springs in, outlined field, one accent button. Its
overlay, back handling, keyboard tracking and animations are imported from
AddKeyDialog rather than copied. What is new is that a bad link is answered
in place — scheme, duplicate and every error addRepositoryWithUrl can return
are localized and shown under the field with the dialog still open, instead
of dismissing first and dropping an english bulletin afterwards.

Screens no longer rebuilt by setRepositories' rebuildAllItems() call, which
only ever reached the settings list, listen through a small registry
instead, so a deeplink adding a source repaints an open screen. Every action
re-resolves its repository by id before touching it: updateAllCaches drops
entries by index on startup and the old code held indexes across that.

SettingsActivity/repos.py and icons.py are gone, along with their five dead
string keys. Adds the "retry" key the plugin catalog has been asking for in
four locales without ever finding it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0171ySGWMBZa2pESv9uTT44H
…v.2)

The share deeplink stopped working. I had started percent-encoding the whole
query when the actions moved over, so a shared repository arrived as
link=https%3A%2F%2Fraw.githubusercontent.com%2F… — the plugin's own parser
decodes that fine (urlparse + parse_qs), but the link has to survive the
telegram client first, and it no longer resolved there. The link goes back
to the plain form that worked, and only the name is escaped, for the five
characters that would otherwise end or split the query.

The url in the edit dialog also drew across the field's border. Two reasons:
the cursor was placed at the end of the text, which scrolls a long url until
its tail shows, and a scrolled single-line TextView paints over its own
padding. The cursor now stays at the start — the beginning of a url is the
part worth reading anyway — and the inset moved to a wrapper that clips to
it, so the text cannot leave the outline no matter how far it is scrolled.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0171ySGWMBZa2pESv9uTT44H
The toggle on a repository card came out square and in the wrong palette. It
is already the client's own switch, the widget every settings row draws, and
its colours are set from the theme by its constructor — but I called
setColors("key_switchTrack", …) on top of that, which hands Theme.getColor
the name of the constant rather than its value. Nothing resolved, so the
track and thumb fell back to whatever the default palette had.

Dropping the call is the whole fix: the switch now draws exactly like the
one next to any other setting in the client.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0171ySGWMBZa2pESv9uTT44H
"Нет метаданных" on the official repository, and it is not the deeplink:
that message is only reached after the file has been fetched successfully.
configs/repomap.json carries a trailing comma after rm_icon, so json.loads
throws, repometa stays None, and every path reports the file as having no
metadata. Adding the repository fails the same way, with "invalid json", and
the startup refresh quietly keeps the stale cache.

A repomap is written by hand and a trailing comma is the mistake people
make — javascript and python both take it, json does not. So parse strictly
first, and only when that fails strip trailing commas and try once more. The
stripping walks the text rather than running a regex over it, because a
pattern like ",(\s*[}\]])" also eats the comma inside a string such as
"a, }" and would silently change data; strings are skipped, and anything
still broken raises as before. When the fallback fires it is logged, so the
malformed file stays visible in a bug report.

Applied to every place a fetched repository file is parsed: the manager's
add and refresh paths, and the repo=add / update deeplinks.

The real fix is still one character in the repository itself.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0171ySGWMBZa2pESv9uTT44H
The switch still drew as a square. It is the client's own widget and its
draw is fixed: Switch.onDraw centres a 31x14dp rounded track and a 20dp
thumb inside whatever size the view was measured at. Nothing about it can
become rectangular — unless the view is narrower than the track, in which
case the pill is clipped by the view bounds and what is left has square
ends. That is what was happening.

The cause is an overload. LayoutHelper.createLinear(37, 20, gravity, l, t,
r, b) has a twin taking a float weight in the third position, and the call
was resolving to it: the switch got weight 16 (the value of
Gravity.CENTER_VERTICAL) instead of a gravity. In a row whose text column is
already weighted, the overflow is then shared out by weight and the switch
— holding almost all of it — is squeezed below its 31dp track.

Every fixed-size child in a weighted row now builds its LayoutParams
explicitly, so no call can pick the weight overload: the switch, the avatar,
the telegram/source/overflow buttons, the actions button in the header and
the icon inside the add button. The avatar was one measurement away from the
same fate.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0171ySGWMBZa2pESv9uTT44H
…v.6)

PluginCell, the card the client draws for every installed plugin, builds its
toggle like this:

    Switch s = new Switch(context);
    s.setColors(Theme.key_switchTrack, Theme.key_switchTrackChecked,
                Theme.key_windowBackgroundWhite, Theme.key_windowBackgroundWhite);
    s.setFocusable(false);
    addView(s, LayoutHelper.createFrame(37, 40, ...));

Two things I had wrong. The colours: the thumb is key_windowBackgroundWhite
for both states, which is what gives the knob its usual look — I had left it
to the constructor's defaults. And the box is 37x40, not 37x20: onDraw
centres a 14dp track and a 20dp thumb circle in the view, so at 20dp tall
the circle fills the height exactly and its top and bottom are shaved off by
the view bounds. Square edges, in other words — the same flattening the
width squeeze was causing at the sides.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0171ySGWMBZa2pESv9uTT44H
The client carries MonetUtils for this. On Android 12+ it pulls a colour
towards the system palette — MaterialColors.harmonize against
system_accent1_600 — and it is how the client keeps its own fixed colours
from clashing with a Monet theme; Theme.refreshMonetColors() re-runs it when
the user changes the wallpaper overlay.

The repository avatar picks its colour deterministically from the id, so on
a Monet theme those seven fixed hues were the one thing on the screen not
following the wallpaper. They go through MonetUtils.harmonize now, cached
per colour, and fall back to the raw value below Android 12 or when the
class is absent.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0171ySGWMBZa2pESv9uTT44H
The spinner was drawn on top of the label, so "Добавить репозиторий" stayed
readable underneath it — my own overlay of a FrameLayout, a TextView and a
CircularProgressDrawable stacked in the same box.

The client already has the button for this. ButtonWithCounterView.setLoading
animates the label out and the spinner in over 320ms on an EASE_OUT_QUINT
curve, and it is what the plugin install sheet and the repo=add deeplink
sheet in this very plugin already use. The dialog builds one, calls
setRound() and hands the loading state straight to it. The hand-rolled
button stays as a fallback for a client without the class, minus the
overlay: there the label just dims.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0171ySGWMBZa2pESv9uTT44H
restoreDefaultRepository appended the official repository unconditionally,
so pressing it with the official repository already in the list left two
identical entries, then three. The screen is new, the behaviour is not — the
settings list did the same, its only guard being the ten-repository cap.

It now looks for the entry first, by rm_rid and by url. If it is there the
entry is repaired in place — id and url reset to the official ones, enabled
turned back on, an empty name refilled — which is what someone reaching for
"restore" after disabling or renaming it is after. Only a genuinely missing
repository is appended.

The action says which of the two happened: the existing "restored" bulletin,
or a new line saying it was already there. The callback also means the
bulletin now waits for the fetch instead of firing while it is still in
flight.

Existing duplicates are not left behind either: updateAllCaches already
drops repositories whose rm_rid it has seen, so the copies go on the next
refresh.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0171ySGWMBZa2pESv9uTT44H
Sharing a repository to a chat answered "Уже в буфере обмена!". The link did
go to the chat — only the bulletin was wrong. ShareAlert calls didShare()
after sending and didCopy() when the user copies instead, and both were
reporting the clipboard message, which the settings screen had done too.

didShare now says the link was sent, in a new string, and didCopy still
returns false so ShareAlert keeps reporting the copy itself.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0171ySGWMBZa2pESv9uTT44H
The status chip on a repository card read the mtime of its cached
repomap, so a source that had just been switched off still claimed
"up to date" — the one thing the chip sits next to is the switch, and
it was describing something else entirely. It now says enabled or
disabled, and keeps calling out a source whose repomap never
downloaded, since that one is on and still contributes nothing.

Cache age was never worth a chip anyway: every start refreshes the
caches, so the reading only ever meant the user had been offline.

Also brings the round telegram/source buttons down from 36 to 32dp
with a 16dp glyph, which is the size the row wanted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0171ySGWMBZa2pESv9uTT44H
Flipping a switch wrote the list back through RepositoryManager, which
notified the screen, which threw every card away and built it again. A
fresh card starts with an empty avatar and fills it from a worker
thread, so every icon on screen dropped to its monogram for two frames
and faded back in — the blink.

Cards now hand back an update() that repaints them in place, and the
screen only rebuilds when the sources themselves changed: same ids in
the same order means the same cards, with new values. The avatar is
kept across a repaint and only swapped when its url actually changes,
which takes an updated repomap.

Two supporting changes. The icon loader answers from its memory cache
on the calling thread, so an icon that has been decoded once is on the
view before it is measured, with no fade — the pool hop was itself a
visible gap on every rebuild. And the cache key carries the decode
size, since the card and the deeplink sheet ask for different ones and
the smaller decode looked soft blown up.

Cards also get their repo dict from the repaint rather than the one
they were built with, so the overflow menu prefills current values
after a rename.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0171ySGWMBZa2pESv9uTT44H
rm_name comes out of a remote repomap and had no bound at all: whatever
the file said went into settings and onto every screen that draws a
repository — the source card, both pickers, the plugin list. A name
long enough to matter pushed the switch off the card.

The limit lives on the storage boundary rather than on each screen, so
there is one rule and no way past it: getRepositories and
setRepositories both clamp, which covers the add sheet, the edit
dialog, the repo=add deeplink and the startup cache refresh, and also
fixes names already on disk from before the limit. Whitespace is
collapsed on the way through, since a name with a newline in it is
only ever a way to break a single-line layout.

Thirty-two is about what a card fits at 17sp on a narrow phone and
twelve more than the longest real name so far. The edit field stops
accepting characters at the same count instead of dropping them on
save, and the card's name and subtitle now ellipsize rather than
clipping mid-glyph when they still do not fit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0171ySGWMBZa2pESv9uTT44H
exteraGram has a second switch style, and with it on the toggle is an
md3 pill wider than the 37dp box it is laid out in — Switch centres it
there, so it overhangs both ends on purpose. Switch.getOverlayPadding
says as much: five dp with the new style, zero with the old, and every
cell in the client that hosts one turns off child clipping to let it
paint. PluginCell, which this card took its switch from, does both
setClipChildren(false) and setClipToPadding(false) on itself; the box
and the colours came across and that did not, so the card sheared the
ends off the pill.

The card's own 16dp padding is more than the overhang needs, so the
switch stays well inside the card outline.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0171ySGWMBZa2pESv9uTT44H
Clearing the name in the edit dialog made both catalogues refuse to
open: the plugin list and the icon list each collected sources with
`if name and url`, so a renamed-to-nothing source silently dropped out
of the list, and with none left the screen answered "no repositories".
Nothing downstream needs the name — it is a label, and both picker
sheets already print "unnamed" when it is missing. They now gate on the
url alone.

That was the half that broke; the other half is that the state was
reachable at all. The edit dialog rejects an empty name the way it
already rejects an empty link, and updateAllCaches — which parses
repometa on every start anyway — puts rm_name back on any source that
is sitting there without one, so installs that already hit this heal
themselves on the next launch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0171ySGWMBZa2pESv9uTT44H
Four things on the sources screen.

The outline is gone. It was there to tell these cards apart from the
plugin ones, but a 1dp divider-coloured stroke reads as a stray line
rather than as a container; the fill against the gray window is what
the client's own cards do.

Turning a source on and off is now the switch's job alone. Tapping the
card used to flip it, which left the two controls doing the same thing
in the same place — the card opens the source's sheet instead. The
switch takes its own taps, in a 56x48 box so the target covers the
whole pill the new switch style draws, and drives its press ripple the
way the client's cells do, off setDrawRipple. The card keeps its press
scale.

The sheet itself is a placeholder: a handle, the avatar, the name and
the maintainer. The shell is real so the content can go under the
header later.

Back was broken, gesture and button both. UniversalFragment negates
the delegate's answer — it does `return !delegate.onBackPressed()` and
only finishes on true — so returning True swallowed the press. Every
other fragment in the plugin returns False; this one does now too.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0171ySGWMBZa2pESv9uTT44H
The count and the round button above the list were a loose grey caption
next to a floating circle — not a control strip. The plugin catalogue
already has this exact row and it reads right, so this one is built the
same way (listView.py): a 44dp frame, the count centred in a
16dp-radius pill on the card surface, the bulk-actions button the same
shape beside it.

The on/off chip is gone. It spelled out in words what the switch an
inch away says by being on or off. What stays is the case the switch
cannot show: a source that is switched on but whose repomap never
downloaded. With nothing left to put in it the chip row usually has no
children at all, so it goes GONE and takes its top margin with it
instead of leaving a gap.

The maintainer line runs through LocaleUtils.fullyFormatText now, the
way the plugin screens format theirs — rm_maintainer is free text and
reads like a message. On the card it is formatted but not tappable: a
movement method makes a TextView clickable, and this one sits in a card
whose tap opens the sheet. In the sheet the mention is a real link,
since nothing there competes for the touch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0171ySGWMBZa2pESv9uTT44H
The cards were mostly empty and the page under them entirely so. The
counts the card was designed around never appeared, because repomap
does not carry any: repomap.plugins and repomap.icons are urls, and the
screen refuses to download a file that runs to hundreds of kilobytes
just to print a number beside a name.

It does not have to. Both catalogues download exactly that file every
time they open, so they now leave the count behind in the repository's
cache and the sources screen reads it for free — a source nobody has
opened has no number, which is honest rather than blank. The installer
already keeps a per-repository index of what came from where, so how
much of a source is actually installed costs one more file read.

That fills the cards. The footer row, which for a source declaring
neither a channel nor a repository was one overflow button adrift on an
empty line, now carries when the repomap was last fetched — the thing
the counts above it were read from. The wording is LocaleController's
own: it already says "just now / N minutes ago / today at" in every
language the client ships, for location updates, and a cache time is
the same kind of fact.

Under the list is the grey caption the client puts at the end of every
settings section, saying what a source is and how much of what is
installed came through one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0171ySGWMBZa2pESv9uTT44H
claude and others added 19 commits August 12, 2026 23:11
Switch draws itself to the size of the view it is given, so last
build's 56x48 box came out half again as large as the pill the client
draws everywhere else. It gets 37x40 back — the box PluginCell uses —
and the bigger touch target moves to a wrapper around it, which also
drives the press ripple now.

The per-repository accent is gone. Picking a colour out of the avatar
palette by hashing the id gave every source a stable look, which is
worth nothing on a theme built from a single accent: a violet or an
orange dropped into a Monet palette is simply the wrong colour on the
screen. Monograms, link buttons and chips all take the theme accent,
and the one chip that does not is the failure case, which has a colour
of its own.

Those fills are opaque now. Accent at an eighth alpha takes its colour
from whatever is behind it, which on a card that scales under a press
is not one thing, and two of them overlapping stack. They are mixed
against the card surface instead, for the same look out of one solid
value. The overflow button keeps its translucent grey — it is meant to
sit back.

The maintainer line is coloured the way the catalogue colours its
author line, windowBackgroundWhiteBlueText with a movement method; left
to itself the formatter had been painting mentions a teal that appears
nowhere else.

The caption under the list is gone. Empty space reads better than it
did.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0171ySGWMBZa2pESv9uTT44H
The installed chip moves to the right of its row and the fetch time
comes up beside it, which leaves the card two rows under the header
that are anchored alike: what it knows on the left, what it offers on
the right. Before, the numbers sat left and the buttons right on a row
of their own, so the eye started somewhere different on each line, and
a source with no channel and no repository ended on a row holding one
overflow button and nothing else.

Sizes follow from that. Every pill on the card is 32dp now — md3's
assist chip height, and already the size of the round buttons — so a
chip beside a button lines up instead of nearly lining up, and both
rows are the same height. Chip labels go to 12sp with 12dp of side
padding and a full radius, matching the buttons they sit with.

The switch is right-aligned inside its touch target rather than
centred. The target is 19dp wider than the switch purely to be easier
to hit, and centring spent half that pushing the pill in from the
card's content edge, out of line with the overflow button directly
below it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0171ySGWMBZa2pESv9uTT44H
Splitting the fetch time onto its own row set a line of content against
a line of nothing, twice: below the avatar the left half of the card
was empty for its entire height, and a source with no channel and no
repository ended on a row carrying a single overflow button.

It is all one row now — the label takes the slack on the left, the
installed pill and the buttons sit at the end of it. The label is the
only thing there that can afford to give up room, so it is the weighted
one: a long pill shortens the text rather than pushing a button off the
card.

The plugin and icon counts keep a line of their own, because they are
the one thing that can run to three pills at once and no single line
survives that. That line is absent unless a catalogue has actually
counted something, which for most sources is never.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0171ySGWMBZa2pESv9uTT44H
The count sat centred over a list whose cards all start at the left
edge, so it lined up with nothing. It moves to that edge. The button
opposite it gets its word back: an icon alone says nothing about what
is behind it, and the row has room.

Cards lose two dp of air top and bottom and two off each row gap. The
rows below the header end in 32dp circles that carry their own ring of
empty pixels, so a square 16dp all round measured as more than it
looked like it needed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0171ySGWMBZa2pESv9uTT44H
The timestamp was the wrong thing to give half a row to. The pill next
to it kept squeezing it down to "обновлена т…", and nobody opens a list
of sources to read one. It moves into the sheet the card already opens,
where a detail has room to be read whole.

That leaves the row reading left to right the way the rest of the card
does: what this source has given you, then what you can do with it. The
pill is always drawn, zero included — a source you have installed
nothing from is worth saying out loud, and one that appears and
disappears makes the row shift as the numbers change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0171ySGWMBZa2pESv9uTT44H
The picture at the top of the repo=add sheet was an R.drawable tinted
with the accent — the same folder for every repository in existence,
which told the reader nothing about the one they were about to add. It
is the repomap's own rm_icon now, drawn by the same loader the sources
screen uses, so a source without a picture falls back to its monogram
instead of to a generic icon.

The link's icon argument is gone from the reading side entirely. It
named an R.drawable, and a repository is not a glyph out of the
client's sheet. It stays in the accepted set so that links already sent
are tolerated rather than rejected, and it is no longer minted into new
ones or written into storage.

The rest of the sheet follows: the repository's name as the title,
since the question is what the buttons are for and the name is what is
being decided about; the maintainer under it with the mention live; the
plugin count and the host as pills, the same ones the cards use, rather
than as clauses inside a paragraph. What is left of the disclaimer is
the sentence that was actually a disclaimer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0171ySGWMBZa2pESv9uTT44H
"PackIt is not responsible for what a source contains. Its owner is."
was a sentence written to fit a legal shape rather than to be read. It
also never said the thing a reader actually needs at that moment: that
nobody has looked inside this repository.

Now it does — third-party source, unchecked, owner's responsibility —
in one sentence, and without the pronoun tangle the Russian had.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0171ySGWMBZa2pESv9uTT44H
Two sentences to say one thing. Both facts a reader needs — that this
came from someone else and that nobody looked inside — fit in five
words.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0171ySGWMBZa2pESv9uTT44H
The maintainer and the disclaimer were both centred grey paragraphs a
few dp apart at nearly the same size, so they read as one block of
small print rather than as a subtitle attached to the name and a
warning attached to the button. The maintainer takes medium weight and
a size up; the disclaimer keeps the plain weight and stays the smallest
thing on the sheet, which is now what tells them apart.

The block above it grows with the room it has: a 76dp picture and a
23dp name, where the old 64 and 21 left the sheet looking emptier than
it needed to. The disclaimer gains a dp and a wider gap of its own,
having been small enough to skip.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0171ySGWMBZa2pESv9uTT44H
The sheet's text was in dp, which ignores the font size the user set
for their device. Everything on it that is prose — the name, the
maintainer, the disclaimer — is in sp now and follows that setting. The
pills stay in dp: their height is fixed, so a label that grew would sit
in a box that did not.

Android also breaks a paragraph greedily, filling the first line to the
margin and dropping the remainder on the second, which for a centred
two-line sentence stranded one word under a full line. The two
paragraphs ask for BALANCED breaking instead, so the lines come out
roughly even.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0171ySGWMBZa2pESv9uTT44H
Every screen that wanted a repository's plugin list wrote the same
twenty lines: open reposCache/{rm_rid}.json, walk to repomap.plugins,
fall back to the stored url, GET it, and unpack a "plugins" value that
is an object in some repositories and an array in others. Nine copies
of that walk existed and no two were alike. Some used json.loads and
choked on the trailing comma the official repomap has shipped with more
than once, while others used the lenient parser. Some sent the plugin's
User-Agent, most sent python-requests'. The timeout for the same file
ranged from 10 to 20 seconds depending on which screen asked. Three
places re-implemented "fetch a repomap and validate it", and only one
of them had the full table of http reasons the add dialog localises.

network/Storage.py is now the only thing that reads a repository. Two
layers, and a caller can tell which it is using from the name: read_*
comes off disk and is safe anywhere, fetch_* goes to the network and
must not run on the ui thread. Between them they cover the repomap and
its cache, the fields inside it — maintainer, links, icon, report
reasons, suggestion config — the plugin and icon lists, and the avatar
bitmaps, which move out of the sources screen's icon view and leave it
as the view it was meant to be.

Two things fall out of having one implementation. Every request now
carries the same User-Agent and the same timeout for the same kind of
file, and every repomap is parsed leniently, so a repository that one
screen could read is no longer unreadable to another.

addRepositoryWithUrl loses its staging dance as well: it downloaded to
packitTemp and moved the file into the cache once it validated, which
Storage makes unnecessary by validating what it parsed before anything
is written.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0171ySGWMBZa2pESv9uTT44H
Storage had grown two jobs. Reading reposCache/{rm_rid}.json has
nothing to do with the network — it is the file every screen consults
before deciding whether it needs the network at all — so it moves to
utils/cachedRepos: the file itself (read, write, forget, mtime,
all_cached) and everything read out of it (repometa, the plugin and
icon list urls, the avatar url, report reasons, suggestion config).

network/Storage keeps what actually goes out: headers, timeouts, the
http reason table, fetch_json/repomap/plugins/icons, the shape
normaliser, and the avatar bitmaps — which live in a different cache
directory and stay with the code that downloads them. Neither module
imports the other.

One more copy turned up while moving: hashBottomSheet fetched a plugin
list itself and walked it as a list only, so in a repository that keys
its plugins by id it matched nothing at all. Both of its fetches go
through Storage now and it handles either shape.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0171ySGWMBZa2pESv9uTT44H
171 files move. Directories go to lowercase, which is what PEP 8 asks
of a package and what the import lines have to spell out on every use;
modules go to PascalCase. BasePlugin.py keeps its name — refmap.yml and
the builder's compilationIgnore both point at that path — and
__init__.py keeps its, being Python's.

Imports were rewritten against the syntax tree rather than by
search-and-replace, which matters for the thirty-five modules that are
imported by name: `from . import cachedRepos` has to become
`from . import CachedRepos` and take its every use with it, without
touching a local variable that happens to share the name. Where the new
name was already spoken for in the file it is aliased instead —
BasePlugin.py imports the module as `main` because `Main` is the class
the loader looks for.

Eight relative imports turned out to have been broken all along, with
one dot too many or too few, each inside an except-ImportError fallback
that would have thrown a second ImportError had it ever run. The rename
surfaced them and they are corrected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0171ySGWMBZa2pESv9uTT44H
The tree had grown by accretion. Six modules sat loose at the root
beside the entry point, ten more sat loose in ui/ beside the screen
packages, a folder called other/ held whatever had not belonged
anywhere, and the packages were named after the client's screens
whether or not that is what they touched.

  core/          installing plugins, loading the dexes and the native
                 libraries, and the repository list
  network/       what goes over the wire
  utils/         helpers, including where things live on disk
  ui/            the plugin's own screens, one package each, plus
                 components/ for the pieces they are built from and
                 dialogs/ for the sheets that belong to no one screen
  integrations/  everything that reaches into a screen the client owns:
                 chat/, chatlist/, hooks/, decorations/
  deeplinks/, scl/  unchanged

settingsactivity moved under ui/ because it is the plugin's own
settings, not the client's — the hook into the client's settings screen
is a different file and stays in integrations/hooks. The screen
packages lose the "activity" suffix they were carrying from the class
names they mirror: pluginlistactivity is ui/plugins, reposactivity is
ui/repos.

Relative imports cannot be patched by substitution when the importing
file is itself moving, since `..utils` means a different thing at a new
depth. Each was resolved to the path it pointed at, put through the
move table, and written out again as seen from the destination. All 659
of them resolve, and all 798 names they import exist where they now
point.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0171ySGWMBZa2pESv9uTT44H
The tree was just rearranged and nothing said why, so the next person
to add a file would have had to guess from what was already there —
which is how it got disorganised the first time.

CONTRIBUTING.md now opens with the layout, a table of "I am adding X,
where does it go", the two filenames that must not be renamed and why,
and the one rule about repository access that is easy to get wrong:
CachedRepos reads the disk and is safe anywhere, Storage goes to the
network and must not run on the UI thread.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0171ySGWMBZa2pESv9uTT44H
src/ now holds one folder per language the plugin is written in, with
the whole package under src/python. Nothing inside it changes: the tree
moved as a unit, so every relative import still points where it did —
all 659 of them resolve unchanged.

Two files outside it had to follow, both of which name the package root
by path: refmap.yml's main, and the builder's source and
compilationIgnore. The artifact comes out the same shape as before, one
directory deeper — the package root with its __init__ at
packit/src/python, BasePlugin.py uncompiled beside it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0171ySGWMBZa2pESv9uTT44H
kotlin/ joins the python tree at packit/src/kotlin, so src/ holds a
folder per language rather than one of them sitting at the repo root.

The four dexes were one dex. R8 emits a single classes.dex from all of
the sources, and kotlin-build.sh copied that same file out four times
under four names — four byte-identical 55K blobs shipped, and
DexLoader built a separate InMemoryDexClassLoader over each, so the
same bytecode was resident four times over. There is now one
packit/dex/packit.dex holding all of kawaii.packetik, one class loader
for it, and classes are resolved out of it by name. packit.dex is
badges.dex renamed: the bytes are unchanged, which their matching
sha256 says plainly.

kotlin-build.sh needed its paths fixed for more than the move: its
REPO_ROOT was one directory short, resolving to scripts/, so every path
it derived pointed at scripts/kotlin/src and the script could not have
run as written. It also now refuses to continue if R8 splits the output
across several dex files, since only the first would be loaded.

Two things in the builder config, both about what ships. The kotlin
sources have no business in the artifact now that they live under
packit/ — and the pattern that was supposed to keep packit/docs out
never worked either: ignoreAll is fnmatch against each file's full
path, so a bare directory matches nothing. Both entries take a
trailing /* now, and docs stops shipping too, which the line always
intended. The artifact loses 80K.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0171ySGWMBZa2pESv9uTT44H
@shareui
shareui merged commit e1d7949 into main Aug 15, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants