Conversation
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
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
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
No description provided.