diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f0f2485f..3c431684 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,5 +1,114 @@ # Contributing +## Where things live + +``` +packit/ + meta.yml plugin name, id, version, minimum client and SDK + locales/ strings_{en,ru,de,be}.json — the only place UI text belongs + res/ fonts, sounds, drawables shipped with the plugin + dex/ packit.dex — all of kawaii.packetik, built from src/kotlin + native/ .so files per ABI + src/ + python/ the plugin itself, everything below + kotlin/ src/ and the compile-only Xposed stubs + +scripts/ one-off tooling; yours goes in scripts/{username}/ + linux/kotlin-build.sh Kotlin -> packit/dex/packit.dex + linux/build-native.sh C -> packit/native// +``` + +`src/` holds one folder per language the plugin is written in, and `src/python` +is the package root — the path `refmap.yml` and the builder's `source:` both +point at. Move it and those two have to move with it. + +There is one dex, not one per Kotlin package. R8 emits a single `classes.dex` +from all of the sources, so splitting it by name only ever shipped the same +bytes several times over. Add a class under `kawaii.packetik.*`, rerun +`kotlin-build.sh`, and reach it from `core/DexLoader.py` by its fully qualified +name — nothing else needs to change. + +It is laid out by what a module *is*, not by which client screen it happens to +touch: + +``` +src/python/ + BasePlugin.py the entry point — the class the loader looks for + Main.py startup, hooks, lifecycle + + core/ installing and removing plugins, loading dexes and native + libraries, the repository list itself + network/ everything that goes over the wire to a repository + utils/ helpers with no UI of their own, including where files + live on disk + scl/ the TOML parser (native-backed) used for .afp files and + plugin export + + ui/ the plugin's own screens + MainActivity.py builds the plugin's settings root + components/ pieces screens are assembled from — never a screen + dialogs/ sheets and dialogs that belong to no single screen + settings/ the plugin's settings pages + plugins/ plugin/ icons/ repos/ updates/ files/ + achievements/ contributors/ suggest/ + + integrations/ code that reaches into a screen the *client* owns + chat/ the chat screen: import sheets, inline mode, security + chatlist/ the dialogs list: buttons, widgets, update sheet + hooks/ hooks into the client's own settings and fragments + decorations/ badges and title icons drawn into client UI + + deeplinks/ one module per tg://packit?… route, dispatched by + DeepHandler +``` + +### Where do I put a new file? + +| What you are adding | Where it goes | +|---|---| +| A screen of the plugin's own | `ui//Fragment.py`, with its sheets and helpers beside it | +| A dialog or sheet used by one screen | that screen's package | +| A dialog or sheet used by several | `ui/dialogs/` | +| A reusable widget, or a view helper | `ui/components/` | +| A page in the plugin's settings | `ui/settings/subsettings/` and register it in `ui/settings/Settings.py` | +| Something drawn into the client's chat, chat list or profile | `integrations//` | +| A hook into a client class | `integrations/hooks/` | +| A new `tg://packit?…` route | `deeplinks/.py`, registered in `deeplinks/DeepHandler.py` | +| A pure helper — parsing, hashing, paths, formatting | `utils/` | +| Anything that downloads from a repository | `network/Storage.py` — do not add a second one | +| Anything that reads `reposCache/{rm_rid}.json` | `utils/CachedRepos.py` — same rule | +| User-visible text | `packit/locales/strings_*.json`, all four in lockstep | + +If a file does not obviously belong anywhere, that is usually a sign it does +two things. Split it before inventing a folder for it. + +### Naming + +- **Folders are lowercase**, no separators: `ui/plugins`, `integrations/chatlist`. +- **Modules are PascalCase**: `CachedRepos.py`, `AddSheet.py`, `EnterView.py`. +- Two names are fixed and must not be renamed: `BasePlugin.py`, which + `refmap.yml` and the builder's `compilationIgnore` both point at by path, and + `__init__.py`, which is Python's. + +### Imports + +All imports inside `src/python` are relative — `from ..utils import Paths`, +never an absolute path from the package root. When you move a file, remember that +the number of dots changes with its depth. + +Two modules are the only way to reach a repository, and which one you want is +readable from the call: + +```python +from ..utils import CachedRepos # off disk, no network, safe anywhere +from ..network import Storage # over the network, never on the UI thread + +url = CachedRepos.plugins_url(repo) +plugins, error = Storage.fetch_plugins(url) +``` + +--- + ## Logging **Always use `logx` from `packutil` for all logs. Never use `log` from `android_utils` directly.** diff --git a/packit/.elyxbuilder/config.yml b/packit/.elyxbuilder/config.yml index 4d57b279..2e49dd55 100644 --- a/packit/.elyxbuilder/config.yml +++ b/packit/.elyxbuilder/config.yml @@ -1,12 +1,16 @@ zipFormat: eaf -source: packit/src +source: packit/src/python buildNameUncompiled: '{name}-{version}' buildNameCompiled: '{name}-{version}-3.11' +# fnmatch against each file's full path in the archive, so a bare directory +# never matches anything — the trailing /* is what does the work ignoreAll: - packit/.elyxbuilder/cache/* -- packit/docs/ +- packit/docs/* +# the kotlin sources build into packit/dex/packit.dex; only the dex ships +- packit/src/kotlin/* compilationIgnore: -- packit/src/BasePlugin.py +- packit/src/python/BasePlugin.py obfuscationConfig: stripDocstrings: true removeLogs: false diff --git a/packit/dex/catalog.dex b/packit/dex/catalog.dex deleted file mode 100644 index 24de1cc5..00000000 Binary files a/packit/dex/catalog.dex and /dev/null differ diff --git a/packit/dex/openfile.dex b/packit/dex/openfile.dex deleted file mode 100644 index 24de1cc5..00000000 Binary files a/packit/dex/openfile.dex and /dev/null differ diff --git a/packit/dex/badges.dex b/packit/dex/packit.dex similarity index 51% rename from packit/dex/badges.dex rename to packit/dex/packit.dex index 24de1cc5..27c77d65 100644 Binary files a/packit/dex/badges.dex and b/packit/dex/packit.dex differ diff --git a/packit/dex/sfx.dex b/packit/dex/sfx.dex deleted file mode 100644 index 24de1cc5..00000000 Binary files a/packit/dex/sfx.dex and /dev/null differ diff --git a/packit/locales/strings_be.json b/packit/locales/strings_be.json index 1647c182..14aa9eda 100644 --- a/packit/locales/strings_be.json +++ b/packit/locales/strings_be.json @@ -19,9 +19,7 @@ "contributors": "Удзельнікі", "max_repositories_allowed": "Максімум 10 рэпазіторыяў. Расслабься.", "max_buttons_allowed": "Максімум 4 кнопкі. Расслабься.", - "fill_previous_repository": "Спачатку запоўні папярэдні рэпазіторый. Сур'ёзна.", "add_repository": "Дадаць рэпазіторый", - "repository_form": "Рэпазіторый #{0}", "delete_repository_title": "Выдаліць", "delete_repository_message": "Ты ўпэўнены, што хочаш выдаліць гэты рэпазіторый? Назад шляху няма.", "delete_button": "Выдаліць", @@ -33,9 +31,6 @@ "achiev_unlocked": "Дасягненне разблакавана!", "repo_link_copied": "Скапіявана ў буфер! Магія.", "failed_to_copy": "Не атрымалася скапіяваць :(", - "repo_icon_text": "Іконка: {0}", - "repo_icon_not_selected": "Іконка: не выбрана", - "repo_enabled": "Уключана", "repo_name": "Назва", "repo_url": "URL канфігурацыі", "invalid_repository": "Няправільны рэпазіторый", @@ -99,7 +94,6 @@ "file_not_found": "Файл не знойдзены :(", "error_opening_settings": "Памылка адкрыцця налад: {0}", "error_generic": "Памылка: {0}", - "icon_selected": "Іконка \"{0}\" выбрана! Крута.", "arrows_navigation": "Стрэлкі і навігацыя", "attachments_media": "Укладанні і медыя", "avatar_profile": "Аватар і профіль", @@ -175,6 +169,7 @@ "repo_add_fetching": "Загрузка дадзеных рэпазіторыя...", "repo_add_fetch_failed": "Не атрымалася загрузіць дадзеныя рэпазіторыя.", "repo_add_disclaimer": "PackIt не нясе адказнасці за змест {0}. Усе правы і адказнасць належаць уладальніку, {1}.\\n\\nКолькасць плагінаў: {2}", + "repo_add_disclaimer_short": "PackIt не адказвае за змест старонніх крыніц.", "repo_add_no_repometa": "Рэпазіторый не мае метададзеных. Дадаць усё роўна?\\n\\nКолькасць плагінаў: {0}", "sort_title": "Сартаваць плагіны", "sort_title_icons": "Сартаванне набораў іконак", @@ -827,8 +822,8 @@ "achiev_hint_secret_utils_rule": "кстати цябе врадли выложат у utilits. Ты па факту паўтарыў kpm. А як бы ў utils правіла другі варыянт нельга выкладваць", "achiev_title_secret_aytist": "Ты знайшоў аметыст!", "achiev_hint_secret_aytist": "Ты сапраўды знайшоў аметыст, цяпер табе не трэба працаваць да канца жыцця...", - "achiev_title_secret_opsec": "opsec усталяваны", - "achiev_hint_secret_opsec": "sudo packit install opsec — гатова. Інкогніта актывавана, акуляры надзеты, ніхто нічога не бачыў.", + "achiev_title_secret_opsec": "Аперацыйная бяспека", + "achiev_hint_secret_opsec": "Ты ўсталяваў opsec прывілеяваным спосабам: sudo packit install opsec.", "tags_section_title": "Тэгі", "apply_button": "Ужыць", "authors_section_title": "Аўтары", @@ -1136,8 +1131,6 @@ "role_founder": "Заснавальнік", "achiev_title_secret_connect_is_bullshit": "Усе мы ненавідзім Connect", "achiev_hint_secret_connect_is_bullshit": "Малююць багіню Умі Асанагі, не ведаючы анатомію чалавека... Гарэць ім у пекле.", - "achiev_title_secret_opsec": "Аперацыйная бяспека", - "achiev_hint_secret_opsec": "Ты ўсталяваў opsec прывілеяваным спосабам: sudo packit install opsec.", "pp_closed_source_moderated": "Закрыты зыходны код, але з мадэрацыяй, таму вы ў бяспецы.", "beta_build_title": "Бэта-зборка", "beta_build_msg": "Вы не тэсціроўшчык, вам не варта было ўсталёўваць PackIt.", @@ -1149,10 +1142,6 @@ "pp_no_license": "Няма ліцэнзіі", "pp_languages": "Мовы", "pp_clients": "Кліенты", - "pp_closed_source_moderated": "Закрыты зыходны код, але з мадэрацыяй, таму вы ў бяспецы.", - "beta_build_title": "Бэта-зборка", - "beta_build_msg": "Вы не тэсціроўшчык, вам не варта было ўсталёўваць PackIt.", - "delete_packit": "Выдаліць PackIt", "debug_menu": "Меню адладкі", "debug_menu_desc": "Меню для адладкі", "debug_logs": "Логі адладкі", @@ -1183,5 +1172,41 @@ "bi_root_no": "Не", "bi_app_version": "Версія праграмы", "bi_app_package": "Пакет праграмы", - "plus_sponsor": "+ Спонсар" + "plus_sponsor": "+ Спонсар", + "repo_card_status_missing": "Не загружаны", + "repo_card_plugins": "{0} плагінаў", + "repo_card_icons": "{0} набораў", + "repo_card_installed": "Усталявана: {0}", + "repo_edit": "Змяніць", + "repo_copy_link": "Скапіяваць спасылку", + "repos_updating": "Абнаўленне крыніц…", + "repo_add_sheet_title": "Новая крыніца", + "repo_add_sheet_subtitle": "Устаўце спасылку на repomap.json", + "repo_add_field_url": "Спасылка", + "repo_sheet_edit_title": "Змяніць крыніцу", + "repo_sheet_edit_sub": "Назва і спасылка", + "repo_err_empty": "Увядзіце спасылку", + "repo_err_name_empty": "Увядзіце назву", + "repo_err_scheme": "Спасылка мусіць пачынацца з https://", + "repo_err_duplicate": "Гэтая крыніца ўжо дададзена", + "repo_err_not_found": "Файл не знойдзены", + "repo_err_forbidden": "Доступ забаронены", + "repo_err_rate_limited": "Занадта шмат запытаў, паспрабуйце пазней", + "repo_err_redirect": "Спасылка перанакіроўвае — патрэбна простая спасылка на файл", + "repo_err_timeout": "Час чакання скончыўся", + "repo_err_server": "Памылка на баку сервера", + "repo_err_http": "Сервер адказаў: {0}", + "repo_err_json": "Файл не з'яўляецца карэктным JSON", + "repo_err_meta": "У файле няма блока repometa", + "repo_err_rid": "У repometa няма rm_rid", + "repo_err_name": "У repometa няма rm_name", + "repo_err_cache": "Не атрымалася захаваць кэш", + "repo_err_network": "Няма злучэння", + "repo_err_unknown": "Невядомая памылка: {0}", + "repos_empty_title": "Пакуль пуста", + "repos_empty_text": "Дадайце крыніцу, каб ставіць плагіны", + "repos_manage": "Кіраванне", + "retry": "Паўтарыць", + "repo_default_already": "Стандартная крыніца ўжо на месцы", + "repo_link_shared": "Спасылка на крыніцу адпраўлена" } diff --git a/packit/locales/strings_de.json b/packit/locales/strings_de.json index b1b0cd45..21b717d3 100644 --- a/packit/locales/strings_de.json +++ b/packit/locales/strings_de.json @@ -19,9 +19,7 @@ "contributors": "Mitwirkende", "max_repositories_allowed": "Maximal 10 Repositorys zulässig. Kühlen.", "max_buttons_allowed": "Maximal 4 Tasten zulässig. Kühlen.", - "fill_previous_repository": "Füllen Sie zuerst das vorherige Repository aus. Ja, wirklich.", "add_repository": "Repository hinzufügen", - "repository_form": "Repository Nr.{0}", "delete_repository_title": "Löschen", "delete_repository_message": "Sind Sie sicher, dass Sie dieses Repository löschen möchten? Keine Rücknahme.", "delete_button": "Löschen", @@ -33,9 +31,6 @@ "achiev_unlocked": "Erfolg freigeschaltet!", "repo_link_copied": "In die Zwischenablage kopiert! Magie.", "failed_to_copy": "Kopieren fehlgeschlagen :(", - "repo_icon_text": "Symbol: {0}", - "repo_icon_not_selected": "Symbol: nicht ausgewählt", - "repo_enabled": "Ermöglicht", "repo_name": "Name", "repo_url": "Konfigurations-URL", "invalid_repository": "Ungültiges Repository", @@ -99,7 +94,6 @@ "file_not_found": "Datei nicht gefunden :(", "error_opening_settings": "Fehler beim Öffnen der Einstellungen: {0}", "error_generic": "Fehler: {0}", - "icon_selected": "Symbol „{0}“ ausgewählt! Schick.", "arrows_navigation": "Pfeile und Navigation", "attachments_media": "Anhänge und Medien", "avatar_profile": "Avatar & Profil", @@ -175,6 +169,7 @@ "repo_add_fetching": "Repository-Daten werden abgerufen...", "repo_add_fetch_failed": "Das Abrufen der Repository-Daten ist fehlgeschlagen.", "repo_add_disclaimer": "PackIt ist nicht für den Inhalt von {0} verantwortlich. Alle Rechte und Pflichten liegen beim Eigentümer {1}.\n\nPlugin-Anzahl: {2}", + "repo_add_disclaimer_short": "PackIt ist nicht für den Inhalt fremder Quellen verantwortlich.", "repo_add_no_repometa": "Das Repository verfügt über keine Metadaten. Trotzdem hinzufügen?\n\nPlugin-Anzahl: {0}", "sort_title": "Plugins sortieren", "sort_title_icons": "Symbolpakete sortieren", @@ -1177,5 +1172,41 @@ "bi_root_no": "Nein", "bi_app_version": "App-Version", "bi_app_package": "App-Paket", - "plus_sponsor": "+ Sponsor" + "plus_sponsor": "+ Sponsor", + "repo_card_status_missing": "Nicht geladen", + "repo_card_plugins": "{0} Plugins", + "repo_card_icons": "{0} Icon-Sets", + "repo_card_installed": "{0} installiert", + "repo_edit": "Bearbeiten", + "repo_copy_link": "Link kopieren", + "repos_updating": "Quellen werden aktualisiert…", + "repo_add_sheet_title": "Neue Quelle", + "repo_add_sheet_subtitle": "Link zur repomap.json einfügen", + "repo_add_field_url": "Link", + "repo_sheet_edit_title": "Quelle bearbeiten", + "repo_sheet_edit_sub": "Name und Link", + "repo_err_empty": "Link eingeben", + "repo_err_name_empty": "Namen eingeben", + "repo_err_scheme": "Der Link muss mit https:// beginnen", + "repo_err_duplicate": "Diese Quelle ist bereits hinzugefügt", + "repo_err_not_found": "Datei nicht gefunden", + "repo_err_forbidden": "Zugriff verweigert", + "repo_err_rate_limited": "Zu viele Anfragen, später erneut versuchen", + "repo_err_redirect": "Der Link leitet weiter — direkter Dateilink nötig", + "repo_err_timeout": "Zeitüberschreitung", + "repo_err_server": "Serverfehler", + "repo_err_http": "Server antwortete: {0}", + "repo_err_json": "Die Datei ist kein gültiges JSON", + "repo_err_meta": "Die Datei hat keinen repometa-Block", + "repo_err_rid": "repometa hat kein rm_rid", + "repo_err_name": "repometa hat kein rm_name", + "repo_err_cache": "Cache konnte nicht gespeichert werden", + "repo_err_network": "Keine Verbindung", + "repo_err_unknown": "Unbekannter Fehler: {0}", + "repos_empty_title": "Noch nichts da", + "repos_empty_text": "Füge eine Quelle hinzu, um Plugins zu installieren", + "repos_manage": "Verwalten", + "retry": "Erneut versuchen", + "repo_default_already": "Die Standardquelle ist bereits vorhanden", + "repo_link_shared": "Link zur Quelle gesendet" } diff --git a/packit/locales/strings_en.json b/packit/locales/strings_en.json index 04eafdf7..a5df9d9b 100644 --- a/packit/locales/strings_en.json +++ b/packit/locales/strings_en.json @@ -19,9 +19,7 @@ "contributors": "Contributors", "max_repositories_allowed": "Maximum of 10 repositories allowed. Chill.", "max_buttons_allowed": "Maximum of 4 buttons allowed. Chill.", - "fill_previous_repository": "Fill in the previous repository first. Yes, really.", "add_repository": "Add Repository", - "repository_form": "Repository #{0}", "delete_repository_title": "Delete", "delete_repository_message": "Are you sure you want to delete this repository? No takebacks.", "delete_button": "Delete", @@ -33,9 +31,6 @@ "achiev_unlocked": "Achievement Unlocked!", "repo_link_copied": "Copied to clipboard! Magic.", "failed_to_copy": "Failed to copy :(", - "repo_icon_text": "Icon: {0}", - "repo_icon_not_selected": "Icon: not selected", - "repo_enabled": "Enabled", "repo_name": "Name", "repo_url": "Config URL", "invalid_repository": "Invalid repository", @@ -99,7 +94,6 @@ "file_not_found": "File not found :(", "error_opening_settings": "Error opening settings: {0}", "error_generic": "Error: {0}", - "icon_selected": "Icon \"{0}\" selected! Fancy.", "arrows_navigation": "Arrows & Navigation", "attachments_media": "Attachments & Media", "avatar_profile": "Avatar & Profile", @@ -175,6 +169,7 @@ "repo_add_fetching": "Fetching repository data...", "repo_add_fetch_failed": "Failed to fetch repository data.", "repo_add_disclaimer": "PackIt is not responsible for the content of {0}. All rights and responsibility belong to the owner, {1}.\n\nPlugin count: {2}", + "repo_add_disclaimer_short": "PackIt is not responsible for the content of third-party sources.", "repo_add_no_repometa": "Repository has no metadata. Add anyway?\n\nPlugin count: {0}", "sort_title": "Sort Plugins", "sort_title_icons": "Sort Icons", @@ -827,8 +822,8 @@ "achiev_hint_secret_utils_rule": "кстати тебя врядли выложат в utilits. Ты пофакту, повторил kpm. А как бы в utils правило второй вариант нельзя выкладыватьб", "achiev_title_secret_aytist": "You found an amethyst!", "achiev_hint_secret_aytist": "You really found an amethyst, now you don't have to work for the rest of your life...", - "achiev_title_secret_opsec": "opsec installed", - "achiev_hint_secret_opsec": "sudo packit install opsec — done. Incognito activated, glasses on, nobody saw anything.", + "achiev_title_secret_opsec": "Operational security", + "achiev_hint_secret_opsec": "You installed opsec the privileged way: sudo packit install opsec.", "tags_section_title": "Tags", "apply_button": "Apply", "authors_section_title": "Authors", @@ -1136,8 +1131,6 @@ "role_founder": "Founder", "achiev_title_secret_connect_is_bullshit": "We all hate Connect", "achiev_hint_secret_connect_is_bullshit": "Drawing goddess Umi Asanagi without knowing human anatomy... May they burn in hell.", - "achiev_title_secret_opsec": "Operational security", - "achiev_hint_secret_opsec": "You installed opsec the privileged way: sudo packit install opsec.", "pp_section_about": "About the project", "pp_source_code": "Source code", "pp_not_provided": "Not provided", @@ -1179,5 +1172,41 @@ "bi_root_no": "No", "bi_app_version": "App version", "bi_app_package": "App package", - "plus_sponsor": "+ Sponsor" + "plus_sponsor": "+ Sponsor", + "repo_card_status_missing": "Not loaded", + "repo_card_plugins": "{0} plugins", + "repo_card_icons": "{0} icon packs", + "repo_card_installed": "{0} installed", + "repo_edit": "Edit", + "repo_copy_link": "Copy link", + "repos_updating": "Refreshing repositories…", + "repo_add_sheet_title": "New source", + "repo_add_sheet_subtitle": "Paste a link to repomap.json", + "repo_add_field_url": "Link", + "repo_sheet_edit_title": "Edit source", + "repo_sheet_edit_sub": "Name and link", + "repo_err_empty": "Enter a link", + "repo_err_name_empty": "Enter a name", + "repo_err_scheme": "The link must start with https://", + "repo_err_duplicate": "This source is already added", + "repo_err_not_found": "File not found", + "repo_err_forbidden": "Access denied", + "repo_err_rate_limited": "Too many requests, try again later", + "repo_err_redirect": "The link redirects — use a direct file link", + "repo_err_timeout": "Request timed out", + "repo_err_server": "Server error", + "repo_err_http": "The server answered: {0}", + "repo_err_json": "The file is not valid JSON", + "repo_err_meta": "The file has no repometa block", + "repo_err_rid": "repometa has no rm_rid", + "repo_err_name": "repometa has no rm_name", + "repo_err_cache": "Could not save the cache", + "repo_err_network": "No connection", + "repo_err_unknown": "Unknown error: {0}", + "repos_empty_title": "Nothing here yet", + "repos_empty_text": "Add a source to install plugins", + "repos_manage": "Manage", + "retry": "Retry", + "repo_default_already": "The default repository is already there", + "repo_link_shared": "Repository link sent" } diff --git a/packit/locales/strings_ru.json b/packit/locales/strings_ru.json index aa0d7bbb..2f575457 100644 --- a/packit/locales/strings_ru.json +++ b/packit/locales/strings_ru.json @@ -19,9 +19,7 @@ "contributors": "Участники", "max_repositories_allowed": "Максимум 10 репозиториев разрешено :(", "max_buttons_allowed": "Максимум 4 кнопки. Полегче, этого достаточно.", - "fill_previous_repository": "Сначала заполните предыдущий репозиторий. Пожалуйста", "add_repository": "Добавить репозиторий", - "repository_form": "Репозиторий №{0}", "delete_repository_title": "Удалить репозиторий", "delete_repository_message": "Вы уверены, что хотите удалить репозиторий?", "delete_button": "Удалить", @@ -33,9 +31,6 @@ "achiev_unlocked": "Достижение разблокировано!", "repo_link_copied": "Уже в буфер обмене!", "failed_to_copy": "Буфер отказался… ну что ж, бывает", - "repo_icon_text": "Иконка: {0}", - "repo_icon_not_selected": "Иконка: не выбрана", - "repo_enabled": "Включено", "repo_name": "Название", "repo_url": "Ссылка", "invalid_repository": "Неверный репозиторий", @@ -99,7 +94,6 @@ "file_not_found": "Файл не найден :(", "error_opening_settings": "Ошибка открытия настроек: {0}", "error_generic": "Ошибка: {0}", - "icon_selected": "Иконка \"{0}\" выбрана!", "arrows_navigation": "Стрелки и навигация", "attachments_media": "Вложения и медиа", "avatar_profile": "Аватар и профиль", @@ -175,6 +169,7 @@ "repo_add_fetching": "Получение данных...", "repo_add_fetch_failed": "Не удалось получить данные репозитория.", "repo_add_disclaimer": "PackIt не отвечает за содержимое репозитория {0}. Все права и ответственность за него несёт владелец, то есть {1}.\n\nКоличество плагинов: {2}", + "repo_add_disclaimer_short": "PackIt не отвечает за содержимое сторонних источников.", "repo_add_no_repometa": "Метаданные репозитория потерялись...( Добавить всё равно?\n\nКоличество плагинов: {0}", "sort_title": "Сортировка плагинов", "sort_title_icons": "Сортировка наборов иконок", @@ -827,8 +822,8 @@ "achiev_hint_secret_utils_rule": "кстати тебя врядли выложат в utilits. Ты пофакту, повторил kpm. А как бы в utils правило второй вариант нельзя выкладыватьб", "achiev_title_secret_aytist": "Ты нашёл аметист!", "achiev_hint_secret_aytist": "Ты и правда нашёл аметист, теперь можешь не работать до конца жизни...", - "achiev_title_secret_opsec": "opsec установлен", - "achiev_hint_secret_opsec": "sudo packit install opsec — готово. Инкогнито активировано, очки надеты, никто ничего не видел.", + "achiev_title_secret_opsec": "Операционная безопасность", + "achiev_hint_secret_opsec": "Ты установил opsec привилегированным способом: sudo packit install opsec.", "tags_section_title": "Теги", "apply_button": "Применить", "authors_section_title": "Авторы", @@ -1136,8 +1131,6 @@ "role_founder": "Основатель", "achiev_title_secret_connect_is_bullshit": "Все мы ненавидим Connect", "achiev_hint_secret_connect_is_bullshit": "Рисуют богиню Уми Асанаги не зная анатомию человека... Гореть им в аду.", - "achiev_title_secret_opsec": "Операционная безопасность", - "achiev_hint_secret_opsec": "Ты установил opsec привилегированным способом: sudo packit install opsec.", "pp_closed_source_moderated": "Закрытый исходный код, но с модерацией, поэтому вы в безопасности.", "beta_build_title": "Бета-сборка", "beta_build_msg": "Вы не тестировщик, вам не следовало устанавливать PackIt.", @@ -1149,10 +1142,6 @@ "pp_no_license": "Нет лицензии", "pp_languages": "Языки", "pp_clients": "Клиенты", - "pp_closed_source_moderated": "Закрытый исходный код, но с модерацией, поэтому вы в безопасности.", - "beta_build_title": "Бета-сборка", - "beta_build_msg": "Вы не тестировщик, вам не следовало устанавливать PackIt.", - "delete_packit": "Удалить PackIt", "debug_menu": "Меню отладки", "debug_menu_desc": "Меню для отладки", "debug_logs": "Логи отладки", @@ -1183,5 +1172,41 @@ "bi_root_no": "Нет", "bi_app_version": "Версия приложения", "bi_app_package": "Пакет приложения", - "plus_sponsor": "+ Спонсор" + "plus_sponsor": "+ Спонсор", + "repo_card_status_missing": "Не загружен", + "repo_card_plugins": "{0} плагинов", + "repo_card_icons": "{0} наборов", + "repo_card_installed": "Установлено: {0}", + "repo_edit": "Изменить", + "repo_copy_link": "Копировать ссылку", + "repos_updating": "Обновление источников…", + "repo_add_sheet_title": "Новый источник", + "repo_add_sheet_subtitle": "Вставьте ссылку на repomap.json", + "repo_add_field_url": "Ссылка", + "repo_sheet_edit_title": "Изменить источник", + "repo_sheet_edit_sub": "Название и ссылка", + "repo_err_empty": "Введите ссылку", + "repo_err_name_empty": "Введите название", + "repo_err_scheme": "Ссылка должна начинаться с https://", + "repo_err_duplicate": "Этот источник уже добавлен", + "repo_err_not_found": "Файл не найден", + "repo_err_forbidden": "Доступ запрещён", + "repo_err_rate_limited": "Слишком много запросов, попробуйте позже", + "repo_err_redirect": "Ссылка перенаправляет — нужна прямая ссылка на файл", + "repo_err_timeout": "Истекло время ожидания", + "repo_err_server": "Ошибка на стороне сервера", + "repo_err_http": "Сервер ответил: {0}", + "repo_err_json": "Файл не является корректным JSON", + "repo_err_meta": "В файле нет блока repometa", + "repo_err_rid": "В repometa нет rm_rid", + "repo_err_name": "В repometa нет rm_name", + "repo_err_cache": "Не удалось сохранить кэш", + "repo_err_network": "Нет соединения", + "repo_err_unknown": "Неизвестная ошибка: {0}", + "repos_empty_title": "Пока пусто", + "repos_empty_text": "Добавьте источник, чтобы ставить плагины", + "repos_manage": "Управление", + "retry": "Повторить", + "repo_default_already": "Стандартный источник уже на месте", + "repo_link_shared": "Ссылка на источник отправлена" } diff --git a/packit/meta.yml b/packit/meta.yml index df934091..629de02d 100644 --- a/packit/meta.yml +++ b/packit/meta.yml @@ -1,7 +1,7 @@ name: PackIt description: "{plugin_description} [shareui/packit-source](https://github.com/shareui/packit-source)" id: shareui_packit -version: "0.1.1-dev.20" +version: "0.1.2-dev.37" author: "@packitX" app_version: ">=12.9.0" sdk_version: ">=1.4.5.0" diff --git a/packit/src/ChatActivity/SecurityBottomSheets/__init__.py b/packit/src/ChatActivity/SecurityBottomSheets/__init__.py deleted file mode 100644 index 094fb538..00000000 --- a/packit/src/ChatActivity/SecurityBottomSheets/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -# pyright: reportMissingImports=false -# SPDX-License-Identifier: GPL-3.0-or-later - -from .signaturesBottomSheet import setup_policy_button_hook -from .hashBottomSheet import setup_hash_button_hook \ No newline at end of file diff --git a/packit/src/SettingsActivity/icons.py b/packit/src/SettingsActivity/icons.py deleted file mode 100644 index 31e4d465..00000000 --- a/packit/src/SettingsActivity/icons.py +++ /dev/null @@ -1,233 +0,0 @@ -# pyright: reportMissingImports=false -# SPDX-License-Identifier: GPL-3.0-or-later - -from ui.settings import Header, Text, Divider -from ui.bulletin import BulletinHelper -try: - from org.telegram.messenger import AndroidUtilities -except Exception as e: - import android_utils as _au; _au.log(f"import org.telegram.messenger import AndroidUtilities failed: {e}") - from ..utils.importFailed import showImportFailedAlert as _sifa; _sifa() -from android_utils import run_on_ui_thread -from hook_utils import find_class -from client_utils import get_last_fragment -try: - from elyx import strings -except Exception as e: - import android_utils as _au; _au.log(f"import elyx import strings failed: {e}") - from ..utils.importFailed import showImportFailedAlert as _sifa; _sifa() - - -class IconSelector: - def __init__(self, repoManager, on_icon_selected_callback): - self.repoManager = repoManager - self.on_icon_selected_callback = on_icon_selected_callback - - def _refresh_settings_page(self): - def action(): - fragment = get_last_fragment() - if fragment and hasattr(fragment, "rebuildAllFragments"): - fragment.rebuildAllFragments(True) - run_on_ui_thread(action) - - def _copy_text(self, text_to_copy: str, message: str): - if AndroidUtilities.addToClipboard(text_to_copy): - BulletinHelper.show_success(message) - - def _select_icon(self, icon_name: str): - self.on_icon_selected_callback(icon_name) - BulletinHelper.show_success(strings.icon_selected.format(icon_name)) - def close_fragment(): - fragment = get_last_fragment() - if fragment and hasattr(fragment, "finishFragment"): - fragment.finishFragment() - run_on_ui_thread(close_fragment) - - def build(self): - try: - icon_categories = { - strings.arrows_navigation: [ - "arrow_more_solar", "msg_go", "msg_go_up", "preview_arrow_left", "preview_arrow_right", - "ic_ab_back", "ic_ab_other", "ic_ab_search", "ic_go" - ], - strings.attachments_media: [ - "attach_audio", "attach_contact", "attach_file", "attach_gallery", "attach_gif", - "attach_location", "attach_poll", "attach_send_solar", "attach_video", "attach_voice", - "msg_attach", "msg_audio", "msg_video", "msg_photo", "msg_gallery", "msg_gif", - "msg_file", "msg_doc", "msg_image", "msg_media" - ], - strings.avatar_profile: [ - "avatar_add", "avatar_delete", "avatar_edit", "msg_photo_add", "msg_photo_crop", - "msg_photo_curve", "msg_photo_text_regular", "msg_photo_text2", "profile_calls", - "profile_newmsg", "profile_settings", "profile_share", "profile_video" - ], - strings.calls_voice: [ - "calls_accept", "calls_decline", "calls_flip", "calls_mute", "calls_videocall", - "msg_call", "msg_videocall", "msg_videoplay", "msg_voice", "msg_voip", - "voip_accept", "voip_decline", "voip_group_add_user", "voip_group_invite", - "voip_group_leave", "voip_group_link", "voip_speaker" - ], - strings.chat_actions: [ - "chats_archive", "chats_delete", "chats_delivered", "chats_error", "chats_markread", - "chats_mute", "chats_pin", "chats_read", "chats_sending", "chats_sent", - "chats_unarchive", "chats_unmute", "chats_unpin", "msg_chat", "msg_group", - "msg_channel", "msg_bot", "msg_user", "msg_contacts" - ], - strings.data_network: [ - "data_network", "data_roaming", "data_wifi", "msg_filled_data_calls", - "msg_filled_data_files", "msg_filled_datausage", "msg_filled_storageusage" - ], - strings.dialog_creation: [ - "dialogs_add", "dialogs_bot", "dialogs_broadcast", "dialogs_contacts", - "dialogs_group", "dialogs_newchannel", "dialogs_newgroup", "dialogs_newsecret", - "dialogs_proxy", "dialogs_search", "dialogs_settings" - ], - strings.fab_actions: [ - "fab_add", "fab_camera", "fab_compose_small_solar", "fab_done", "fab_edit", - "msg_add_file", "msg_addbot", "msg_addfolder", "msg_addphoto" - ], - strings.files_storage: [ - "files_storage", "gift_unpack", "msg_file", "msg_doc", "msg_folders_bots", - "msg_folders_channels", "msg_folders_groups", "msg_folders_requests", - "msg_folder", "msg_removefolder" - ], - strings.input_interface: [ - "ic_attach_document", "ic_attach_gallery", "ic_attach_location", "ic_attach_music", - "ic_attach_poll", "ic_attach_video", "input_bot1", "input_bot1_remix", - "input_clear", "input_emoji", "input_schedule_solar", "input_send", "input_sticker", - "msg_input", "msg_instant", "msg_select", "msg_select_between" - ], - strings.location_maps: [ - "location_current", "location_send", "location_zoom_in", "location_zoom_out", - "msg_location", "msg_map" - ], - strings.menu_items: [ - "menu_account", "menu_add", "menu_archive", "menu_attach", "menu_back", - "menu_block", "menu_broadcast", "menu_calls", "menu_camera", "menu_cancel", - "menu_channel", "menu_chat", "menu_clear", "menu_close", "menu_contacts", - "menu_copy", "menu_create", "menu_crop", "menu_delete", "menu_done", - "menu_download", "menu_edit", "menu_emoji", "menu_end", "menu_exit", - "menu_export", "menu_fave", "menu_feature_premium", "menu_file", "menu_filter", - "menu_flag", "menu_folder", "menu_forward", "menu_gallery", "menu_gif", - "menu_group", "menu_help", "menu_hide", "menu_home", "menu_import", - "menu_info", "menu_intro_solar", "menu_invite", "menu_join", "menu_leave", - "menu_link", "menu_location", "menu_lock", "menu_logout", "menu_love", - "menu_map", "menu_mic", "menu_more", "menu_mute", "menu_new", "menu_next", - "menu_night", "menu_notifications", "menu_open", "menu_pause", "menu_phone", - "menu_photo", "menu_pin", "menu_play", "menu_plus", "menu_poll", - "menu_premium", "menu_premium_clock", "menu_premium_location", "menu_premium_star", - "menu_preview", "menu_previous", "menu_privacy", "menu_profile", "menu_qr", - "menu_question", "menu_quiz", "menu_read", "menu_redo", "menu_refresh", - "menu_remove", "menu_reorder", "menu_repeat", "menu_reply", "menu_report", - "menu_restart", "menu_restore", "menu_rotate", "menu_save", "menu_scan", - "menu_search", "menu_security", "menu_select_quote_solar", "menu_send", - "menu_settings", "menu_share", "menu_silent", "menu_skip", "menu_sort", - "menu_spam", "menu_star", "menu_stats", "menu_sticker", "menu_stop", - "menu_storage", "menu_stories", "menu_switch", "menu_sync", "menu_theme", - "menu_undo", "menu_unmute", "menu_unpin", "menu_unlock", "menu_unread", - "menu_update", "menu_upload", "menu_user", "menu_video", "menu_voice", - "menu_wallet", "menu_warning", "menu_zoom" - ], - strings.message_content: [ - "msg_archive", "msg_autodelete_1d", "msg_autodelete_1m", "msg_autodelete_1w", - "msg_autodelete_badge2", "msg_block", "msg_broadcast", "msg_calendar", - "msg_calendar2", "msg_clock", "msg_code", "msg_colors", "msg_comment", - "msg_copy_filled", "msg_day", "msg_discussion", "msg_draft", "msg_draw", - "msg_font", "msg_games", "msg_gift_premium", "msg_header_draw", - "msg_header_share", "msg_help", "msg_history", "msg_info", "msg_invite", - "msg_join", "msg_leave", "msg_level", "msg_link2", "msg_link_1", "msg_link_2", - "msg_list", "msg_live", "msg_log", "msg_love", "msg_mention", "msg_menu", - "msg_message2", "msg_month", "msg_move", "msg_msgbubble2", "msg_music", - "msg_name", "msg_new", "msg_new_group", "msg_new_private", "msg_new_secret", - "msg_newphone", "msg_news", "msg_panel_forward", "msg_panel_reply", - "msg_payment_provider", "msg_phone", "msg_players", "msg_plugins", - "msg_poll", "msg_premium", "msg_preview", "msg_question", "msg_quote", - "msg_quiz", "msg_rate_down", "msg_reactions_filled", "msg_recent", - "msg_recents", "msg_record", "msg_saved", "msg_scheduled", "msg_secret", - "msg_send", "msg_separated", "msg_settings_art", "msg_settings_ny", - "msg_share", "msg_share_filled", "msg_shareout", "msg_sound", "msg_spam", - "msg_speed", "msg_start", "msg_stats", "msg_sticker", "msg_stories", - "msg_stories_add", "msg_stories_archive", "msg_stories_closefriends", - "msg_stories_my", "msg_stories_stealth", "msg_ton", "msg_topic_create", - "msg_translate", "msg_unarchive", "msg_user", "msg_video", "msg_view", - "msg_wallpaper", "msg_watch", "msg_wave", "msg_work" - ], - strings.message_controls: [ - "msg_check", "msg_check2", "msg_clear", "msg_clear_recent", "msg_close", - "msg_customize", "msg_delete", "msg_delete_solar", "msg_done", "msg_download", - "msg_download_settings", "msg_edit", "msg_emoji", "msg_empty", "msg_error", - "msg_fave", "msg_favorite", "msg_filled_blocked_solar", "msg_filled_menu_channels", - "msg_filled_menu_groups", "msg_filled_menu_users", "msg_filled_shareout", - "msg_filled_storageusage", "msg_filter", "msg_flag", "msg_flash", "msg_flip", - "msg_folder", "msg_folders_bots", "msg_folders_channels", "msg_folders_groups", - "msg_folders_requests", "msg_forward", "msg_gift_premium", "msg_hide", - "msg_image", "msg_input", "msg_instant", "msg_location", "msg_lock", - "msg_map", "msg_media", "msg_mini_autodelete_empty", "msg_mini_customize", - "msg_mute", "msg_night", "msg_night_auto", "msg_no_sound", "msg_notifications", - "msg_online", "msg_open", "msg_openin", "msg_openprofile", "msg_pause", - "msg_pin", "msg_pin_mini", "msg_play", "msg_privacy", "msg_profile", - "msg_proxy", "msg_qrcode", "msg_qrcode_mini", "msg_read", "msg_rear_camera", - "msg_redo", "msg_refresh", "msg_remix", "msg_remove", "msg_removefolder", - "msg_replace", "msg_reply", "msg_reply_small", "msg_report", "msg_restore", - "msg_retry", "msg_rotate", "msg_save", "msg_search", "msg_security", - "msg_select", "msg_select_between", "msg_settings", "msg_silent", - "msg_speed", "msg_star", "msg_status_edit", "msg_status_set", "msg_stop", - "msg_stopwatch", "msg_unmute", "msg_unpin", "msg_unlock", "msg_unvote", - "msg_update", "msg_upload", "msg_videocall", "msg_videoplay", "msg_voice", - "msg_voip", "msg_warning", "msg_zoom" - ], - strings.passcode_security: [ - "passcode_delete", "passcode_fingerprint", "passcode_logo", "ic_block_user", - "ic_lock", "ic_lock_white", "ic_unblock_user" - ], - strings.player_controls: [ - "player_next", "player_pause", "player_play", "player_prev", "player_repeat", - "player_shuffle", "preview_play", "msg_play", "msg_pause", "msg_stop" - ], - strings.stickers: [ - "stickers_add", "stickers_check", "stickers_delete", "stickers_fave", - "stickers_menu", "msg_sticker", "input_sticker" - ], - strings.stories: [ - "stories_circle", "stories_seen", "stories_unseen", "msg_stories", - "msg_stories_add", "msg_stories_archive", "msg_stories_closefriends", - "msg_stories_my", "msg_stories_stealth", "menu_stories" - ], - strings.themes: [ - "theme_auto", "theme_dark", "theme_day", "theme_light", "theme_night", - "msg_night", "msg_night_auto", "msg_brightness_high", "msg_brightness_low" - ], - strings.ui_elements: [ - "tooltip_arrow", "undo_redo", "undo_undo", "window_close", "ic_comment", - "ic_delete", "ic_done", "ic_menu_more", "ic_mute", "ic_notifications", - "ic_pin", "ic_send", "ic_unmute" - ] - } - - try: - R_drawable = find_class("org.telegram.messenger.R$drawable") - def filter_icons(icon_list): - return [icon for icon in icon_list if getattr(R_drawable, icon, 0) != 0] - except Exception: - def filter_icons(icon_list): - return icon_list - - settings_list = [] - - for category_name, category_icons in icon_categories.items(): - filtered_icons = filter_icons(category_icons) - if filtered_icons: - settings_list.append(Header(text=category_name)) - for icon_name in filtered_icons: - settings_list.append(Text( - text=icon_name, - icon=icon_name, - on_click=lambda view, name=icon_name: self._select_icon(name) - )) - settings_list.append(Divider()) - - settings_list.append(Divider()) - return settings_list - - except Exception as e: - return [Header(text=strings.error_header), Text(text=strings.failed_to_load_icons.format(e))] \ No newline at end of file diff --git a/packit/src/SettingsActivity/repos.py b/packit/src/SettingsActivity/repos.py deleted file mode 100644 index 2a7e8469..00000000 --- a/packit/src/SettingsActivity/repos.py +++ /dev/null @@ -1,735 +0,0 @@ -# pyright: reportMissingImports=false -# SPDX-License-Identifier: GPL-3.0-or-later - -from packutil import logx -from ..utils.bulletins import factory as _pbf -from ui.settings import Header, Input, Divider, Switch, Text -try: - from elyx import strings, settings -except Exception as e: - import android_utils as _au; _au.log(f"import elyx import strings, settings failed: {e}") - from ..utils.importFailed import showImportFailedAlert as _sifa; _sifa() -from client_utils import get_last_fragment -from ui.bulletin import BulletinHelper -from ui.alert import AlertDialogBuilder -from .icons import IconSelector - -from hook_utils import find_class -try: - from org.telegram.messenger import R as R_tg -except Exception as e: - import android_utils as _au; _au.log(f"import org.telegram.messenger import R as R_tg failed: {e}") - from ..utils.importFailed import showImportFailedAlert as _sifa; _sifa() - -BulletinFactory = find_class("org.telegram.ui.Components.BulletinFactory") - - -def _showAddRepoDialog(context, repoManager): - try: - from android.text import InputType - from android.content import DialogInterface - from android.view import View - from android.widget import ScrollView, LinearLayout, TextView, FrameLayout, ImageView - from android.view import Gravity - from android.util import TypedValue - from java import dynamic_proxy - from org.telegram.ui.ActionBar import AlertDialog, Theme - from org.telegram.ui.Components import EditTextBoldCursor, OutlineTextContainerView, RLottieImageView, LayoutHelper, CircularProgressDrawable - from org.telegram.messenger import AndroidUtilities - from client_utils import run_on_queue - from android_utils import run_on_ui_thread, OnClickListener - - dp = AndroidUtilities.dp - - builder = AlertDialog.Builder(context) - - frameLayout = FrameLayout(context) - builder.setView(frameLayout) - - scrollView = ScrollView(context) - scrollView.setFillViewport(True) - frameLayout.addView(scrollView, LayoutHelper.createFrame(-1, -1)) - - linear = LinearLayout(context) - linear.setOrientation(LinearLayout.VERTICAL) - linear.setGravity(Gravity.CENTER_HORIZONTAL) - scrollView.addView(linear, LayoutHelper.createFrame(-1, -2, Gravity.TOP)) - - try: - anim = RLottieImageView(context) - anim.setAnimation(R_tg.raw.shared_link_enter, 100, 100) - anim.playAnimation() - linear.addView(anim, LayoutHelper.createLinear(100, 100, Gravity.CENTER_HORIZONTAL, 0, 16, 0, 0)) - except Exception as e: - logx(f"repos: add repo dialog anim error: {e}", False) - - titleView = TextView(context) - titleView.setTextColor(Theme.getColor(Theme.key_windowBackgroundWhiteBlackText)) - titleView.setTextSize(TypedValue.COMPLEX_UNIT_DIP, 18) - titleView.setGravity(Gravity.CENTER_HORIZONTAL) - titleView.setTypeface(AndroidUtilities.bold()) - titleView.setText(str(strings.add_repository)) - linear.addView(titleView, LayoutHelper.createFrame(-2, -2, Gravity.CENTER_HORIZONTAL, 24, 8, 24, 0)) - - outlineView = OutlineTextContainerView(context) - outlineView.setText(str(strings.repo_url)) - outlineView.animateSelection(1, False) - linear.addView(outlineView, LayoutHelper.createLinear(-1, -2, Gravity.CENTER_HORIZONTAL, 24, 24, 24, 16)) - - inputField = EditTextBoldCursor(context) - inputField.setTextSize(TypedValue.COMPLEX_UNIT_DIP, 18) - inputField.setTextColor(Theme.getColor(Theme.key_windowBackgroundWhiteBlackText)) - inputField.setHintTextColor(Theme.getColor(Theme.key_windowBackgroundWhiteHintText)) - inputField.setBackground(None) - inputField.setSingleLine(True) - inputField.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_URI) - inputField.setCursorColor(Theme.getColor(Theme.key_windowBackgroundWhiteInputFieldActivated)) - inputField.setCursorWidth(1.5) - padding = dp(16) - inputField.setPadding(padding, padding, padding, padding) - outlineView.addView(inputField, LayoutHelper.createFrame(-1, -2)) - outlineView.attachEditText(inputField) - - class _FocusListener(dynamic_proxy(View.OnFocusChangeListener)): - def onFocusChange(self, v, hasFocus): - outlineView.animateSelection(1 if hasFocus else 0) - - inputField.setOnFocusChangeListener(_FocusListener()) - - # button: LinearLayout with TextView inside, same pattern as installUi details button - doneBtn = LinearLayout(context) - doneBtn.setOrientation(LinearLayout.HORIZONTAL) - doneBtn.setGravity(Gravity.CENTER) - doneBtn.setBackground(Theme.createSimpleSelectorRoundRectDrawable( - dp(6), - Theme.getColor(Theme.key_featuredStickers_addButton), - Theme.getColor(Theme.key_featuredStickers_addButtonPressed) - )) - doneBtn.setClickable(True) - doneBtn.setFocusable(True) - - btnLabel = TextView(context) - btnLabel.setText(str(strings.add_repository)) - btnLabel.setTextSize(TypedValue.COMPLEX_UNIT_DIP, 16) - btnLabel.setGravity(Gravity.CENTER) - btnLabel.setTextColor(Theme.getColor(Theme.key_featuredStickers_buttonText)) - doneBtn.addView(btnLabel, LayoutHelper.createLinear(-2, -2, Gravity.CENTER)) - - linear.addView(doneBtn, LayoutHelper.createFrame(-1, 44, Gravity.TOP, 30, 0, 30, 16)) - - dialog = builder.create() - - def _setLoading(isLoading): - try: - doneBtn.setEnabled(not isLoading) - doneBtn.removeAllViews() - if isLoading: - color = Theme.getColor(Theme.key_featuredStickers_buttonText) - spinnerDrawable = CircularProgressDrawable(color) - try: - spinnerDrawable.size = float(dp(20)) - spinnerDrawable.thickness = float(dp(2)) - except Exception: - pass - spinnerView = ImageView(context) - spinnerView.setImageDrawable(spinnerDrawable) - spinnerView.setScaleType(ImageView.ScaleType.CENTER) - doneBtn.addView(spinnerView, LayoutHelper.createLinear(20, 20, Gravity.CENTER)) - else: - doneBtn.addView(btnLabel, LayoutHelper.createLinear(-2, -2, Gravity.CENTER)) - except Exception as e: - logx(f"repos: _setLoading error: {e}", False) - - def onAdd(): - url = str(inputField.getText()).strip() - if not url: - return - AndroidUtilities.hideKeyboard(inputField) - run_on_ui_thread(lambda: _setLoading(True)) - - def task(): - repometa, reason = repoManager.addRepositoryWithUrl(url) - - def onDone(): - dialog.dismiss() - if reason is not None: - try: - BulletinHelper.show_error(f"{strings.invalid_repository}: {reason}") - except Exception as e: - logx(f"repos: add repo error bulletin error: {e}", False) - else: - try: - frag = get_last_fragment() - container = frag.getParentActivity().getWindow().getDecorView() - resourceProvider = frag.getResourceProvider() - _pbf(container, resourceProvider).createSimpleBulletin( - R_tg.raw.shared_link_enter, - str(strings.repository_added) - ).show() - if frag and hasattr(frag, "rebuildAllItems"): - frag.rebuildAllItems() - except Exception as e: - logx(f"repos: add repo success bulletin error: {e}", False) - - run_on_ui_thread(onDone) - - run_on_queue(task) - - doneBtn.setOnClickListener(OnClickListener(lambda v: onAdd())) - - class _DismissListener(dynamic_proxy(DialogInterface.OnDismissListener)): - def onDismiss(self, d): - AndroidUtilities.hideKeyboard(inputField) - - class _ShowListener(dynamic_proxy(DialogInterface.OnShowListener)): - def onShow(self, d): - inputField.requestFocus() - AndroidUtilities.showKeyboard(inputField) - - dialog.setOnDismissListener(_DismissListener()) - dialog.setOnShowListener(_ShowListener()) - dialog.show() - except Exception as e: - logx(f"repos: _showAddRepoDialog error: {e}", False) - - -class RepositoriesSettings: - def __init__(self, repoManager): - self.repoManager = repoManager - - def build(self): - repos = self.repoManager.getRepositories() - - if not repos: - self.repoManager.addRepository(isFirst=True) - repos = self.repoManager.getRepositories() - try: - fragment = get_last_fragment() - if fragment and hasattr(fragment, "rebuildAllItems"): - fragment.rebuildAllItems() - except Exception as e: - logx(f"{e}", False) - - def add_new_repository(view): - repos = self.repoManager.getRepositories() - if len(repos) >= 10: - try: - BulletinHelper.show_error(strings.max_repositories_allowed) - except Exception as e: - logx(f"{e}", False) - return - - try: - frag = get_last_fragment() - ctx = frag.getParentActivity() if frag else None - if not ctx: - return - _showAddRepoDialog(ctx, self.repoManager) - except Exception as e: - logx(f"repos: add_new_repository error: {e}", False) - - def restore_default_repository(view): - repos = self.repoManager.getRepositories() - if len(repos) >= 10: - try: - logx("Default repository restore failed: max limit reached (10)", True) - BulletinHelper.show_error(strings.max_repositories_allowed) - except Exception as e: - logx(f"{e}", False) - return - - self.repoManager.restoreDefaultRepository() - try: - BulletinHelper.show_success(strings.default_repo_restored) - except Exception as e: - logx(f"{e}", False) - - def reset_repositories(view): - repos = self.repoManager.getRepositories() - if len(repos) <= 1: - try: - frag = get_last_fragment() - act = frag.getParentActivity() if frag else None - if not act: - return - - builder = AlertDialogBuilder(act) - builder.set_title(strings.easter_egg_title) - builder.set_message(strings.easter_egg_reset_message) - builder.set_positive_button(strings.close_button, lambda b, w: b.dismiss()) - builder.show() - except Exception as e: - logx(f"{e}", False) - return - - try: - frag = get_last_fragment() - act = frag.getParentActivity() if frag else None - if not act: - return - - builder = AlertDialogBuilder(act) - builder.set_title(strings.reset_repositories_title) - builder.set_message(strings.reset_repositories_message) - - def on_yes(b, w): - self.repoManager.resetRepositories() - try: - frag = get_last_fragment() - container = frag.getParentActivity().getWindow().getDecorView() - resourceProvider = frag.getResourceProvider() - _pbf(container, resourceProvider).createSimpleBulletin(R_tg.raw.group_pip_delete_icon, strings.repositories_reset).show() - except Exception as e: - logx(f"{e}", False) - - builder.set_positive_button(strings.reset_button, on_yes) - builder.set_negative_button(strings.close_button, lambda b, w: b.dismiss()) - try: - builder.make_button_red(AlertDialogBuilder.BUTTON_POSITIVE) - except Exception as e: - logx(f"{e}", False) - builder.show() - except Exception as e: - logx(f"{e}", False) - - def clear_all_except_first(view): - repos = self.repoManager.getRepositories() - if len(repos) <= 1: - try: - frag = get_last_fragment() - act = frag.getParentActivity() if frag else None - if not act: - return - - builder = AlertDialogBuilder(act) - builder.set_title(strings.easter_egg_title) - builder.set_message(strings.easter_egg_clear_message) - builder.set_positive_button(strings.close_button, lambda b, w: b.dismiss()) - builder.show() - except Exception as e: - logx(f"{e}", False) - return - - try: - frag = get_last_fragment() - act = frag.getParentActivity() if frag else None - if not act: - return - - builder = AlertDialogBuilder(act) - builder.set_title(strings.clear_all_title) - builder.set_message(strings.clear_all_message) - - def on_yes(b, w): - self.repoManager.clearAllExceptFirst() - try: - frag = get_last_fragment() - container = frag.getParentActivity().getWindow().getDecorView() - resourceProvider = frag.getResourceProvider() - _pbf(container, resourceProvider).createSimpleBulletin(R_tg.raw.utyan_cache, strings.repositories_cleared).show() - except Exception as e: - logx(f"{e}", False) - - builder.set_positive_button(strings.clear_button, on_yes) - builder.set_negative_button(strings.close_button, lambda b, w: b.dismiss()) - try: - builder.make_button_red(AlertDialogBuilder.BUTTON_POSITIVE) - except Exception as e: - logx(f"{e}", False) - builder.show() - except Exception as e: - logx(f"{e}", False) - - def update_repositories(view): - from client_utils import run_on_queue - from android_utils import run_on_ui_thread - import requests - import json - import os - try: - from org.telegram.messenger import ApplicationLoader - except Exception as e: - import android_utils as _au; _au.log(f"import org.telegram.messenger import ApplicationLoader failed: {e}") - from ..utils.importFailed import showImportFailedAlert as _sifa; _sifa() - - def task(): - try: - repos = self.repoManager.getRepositories() - from ..utils.paths import getReposCacheDir - cache_dir = getReposCacheDir() - os.makedirs(cache_dir, exist_ok=True) - changed = False - to_remove = [] - seen_rids = set() - - for i, repo in enumerate(repos): - url = (repo.get("url") or "").strip() - if not url: - continue - try: - r = requests.get(url, timeout=10) - if r.status_code != 200: - logx(f"update_repositories: HTTP {r.status_code} for {url}", True) - continue - data = r.json() - repometa = data.get("repometa") - rm_rid = repometa.get("rm_rid") if repometa else None - - if not repometa or not rm_rid: - logx(f"update_repositories: no repometa for '{url}', removing repo", True) - to_remove.append(i) - changed = True - continue - - if rm_rid in seen_rids: - logx(f"update_repositories: duplicate rm_rid='{rm_rid}', removing repo", True) - to_remove.append(i) - changed = True - continue - seen_rids.add(rm_rid) - - if repo.get("id") != rm_rid: - repos[i]["id"] = rm_rid - changed = True - logx(f"update_repositories: set id='{rm_rid}' for repo '{repo.get('name')}'", True) - - cache_path = os.path.join(cache_dir, f"{rm_rid}.json") - with open(cache_path, "w", encoding="utf-8") as f: - json.dump(data, f, indent=2, ensure_ascii=False) - logx(f"update_repositories: updated cache for '{rm_rid}'", True) - except Exception as e: - logx(f"update_repositories: error for {url}: {e}", False) - - for i in sorted(to_remove, reverse=True): - repos.pop(i) - - if changed: - self.repoManager.setRepositories(repos) - - run_on_ui_thread(lambda: BulletinHelper.show_success(strings.update_repos_success)) - except Exception as e: - logx(f"update_repositories: task error: {e}", False) - - run_on_queue(task) - - isActionsCollapsed = settings.get("actions_collapsed", True) - actionsCollapseIcon = "msg_go_up" if not isActionsCollapsed else "arrow_more_solar" - - def toggle_actions_collapsed(view): - current = settings.get("actions_collapsed", True) - settings.set("actions_collapsed", not current, reload_settings=True) - - def export_repositories(view): - repos = self.repoManager.getRepositories() - links = [] - for repo in repos: - name = repo.get('name', '').strip() - url = repo.get('url', '').strip() - icon = repo.get('icon', '').strip() - if not url: - continue - links.append(f"tg://packit?repo=add&name={name}&link={url}&icon={icon}") - - if not links: - BulletinHelper.show_error(strings.no_repositories_to_export) - return - - share_text = "\n\n".join(links) - - try: - from java import jclass, dynamic_proxy - from hook_utils import find_class - from android_utils import run_on_ui_thread - - frag = get_last_fragment() - if not frag: - return - act = frag.getParentActivity() - if not act: - return - - ShareAlert = find_class("org.telegram.ui.Components.ShareAlert") - ShareDelegateClass = jclass("org.telegram.ui.Components.ShareAlert$ShareAlertDelegate") - _fragment = frag - - class ShareDelegate(dynamic_proxy(ShareDelegateClass)): - def __init__(self): - super().__init__() - - def didShare(self): - def _show_bulletin(): - try: - from org.telegram.messenger import R as R_tg - BulletinFactory = find_class("org.telegram.ui.Components.BulletinFactory") - container = _fragment.getParentActivity().getWindow().getDecorView() - rp = _fragment.getResourceProvider() - _pbf(container, rp).createSimpleBulletin(R_tg.raw.voip_invite, strings.repositories_exported).show() - except Exception as _be: - logx(f"repos.export_repositories.ShareDelegate.didShare: {_be}", True) - run_on_ui_thread(_show_bulletin) - - def didCopy(self): - return False - - share_alert = ShareAlert( - act, - None, - share_text, - True, - share_text, - False - ) - share_alert.setDelegate(ShareDelegate()) - frag.showDialog(share_alert) - except Exception as e: - logx(f"Export failed: {e}", False) - BulletinHelper.show_error(strings.failed_to_copy) - - def toggle_all_repositories(view): - repos = self.repoManager.getRepositories() - anyEnabled = any(r.get('enabled', True) for r in repos) - for repo in repos: - repo['enabled'] = not anyEnabled - self.repoManager.setRepositories(repos) - - anyEnabled = any(r.get('enabled', True) for r in repos) - toggleAllText = strings.disable_all_repositories if anyEnabled else strings.enable_all_repositories - - actionItems = [ - Text( - text=strings.update_repositories, - icon="msg_retry", - on_click=update_repositories, - link_alias="update_repos" - ), - Text( - text=strings.export_repositories, - icon="msg_share", - on_click=export_repositories, - link_alias="export_repos" - ), - Text( - text=toggleAllText, - icon="msg_customize", - on_click=toggle_all_repositories, - link_alias="toggle_all_repos" - ), - Text( - text=strings.restore_default_repository, - icon="msg_reset", - on_click=restore_default_repository, - link_alias="restore_repo" - ), - Text( - text=strings.clear_all_except_first, - icon="msg_clear", - red=True, - on_click=clear_all_except_first, - link_alias="clear_all" - ), - Text( - text=strings.reset_repositories, - icon="msg_delete", - red=True, - on_click=reset_repositories, - link_alias="reset_repo" - ), - ] - - settingsList = [ - Header(text=strings.repositories), - Text( - text=strings.add_repository, - icon="msg_add", - accent=True, - on_click=add_new_repository, - link_alias="new_repo" - ), - Text( - text=strings.additional_actions, - icon=actionsCollapseIcon, - accent=True, - on_click=toggle_actions_collapsed - ), - *(actionItems if not isActionsCollapsed else []), - Divider() - ] - - def makeOnChange(field, i): - return lambda value: self.repoManager.updateRepoField(i, field, value) - - def makeOnRemove(i): - def show_confirm_dialog(view): - try: - frag = get_last_fragment() - act = frag.getParentActivity() if frag else None - if not act: - return - - builder = AlertDialogBuilder(act) - builder.set_title(strings.delete_repository_title) - builder.set_message(strings.delete_repository_message) - - def on_yes(b, w): - self.repoManager.removeRepository(i) - - builder.set_positive_button(strings.delete_button, on_yes) - builder.set_negative_button(strings.close_button, lambda b, w: b.dismiss()) - try: - builder.make_button_red(AlertDialogBuilder.BUTTON_POSITIVE) - except Exception as e: - logx(f"{e}", False) - builder.show() - except Exception as e: - logx(f"{e}", False) - self.repoManager.removeRepository(i) - - return show_confirm_dialog - - def makeOnShare(i): - def share_repository(view): - current_repos = self.repoManager.getRepositories() - if i >= len(current_repos): - BulletinHelper.show_error(strings.failed_to_copy) - return - repo = current_repos[i] - name = repo.get('name', '').strip() - url = repo.get('url', '').strip() - icon = repo.get('icon', '').strip() - - share_url = f"tg://packit?repo=add&name={name}&link={url}&icon={icon}" - - try: - from java import jclass, dynamic_proxy - from android_utils import run_on_ui_thread - - frag = get_last_fragment() - if not frag: - return - act = frag.getParentActivity() - if not act: - return - - ShareAlert = find_class("org.telegram.ui.Components.ShareAlert") - ShareDelegateClass = jclass("org.telegram.ui.Components.ShareAlert$ShareAlertDelegate") - _fragment = frag - - class ShareDelegate(dynamic_proxy(ShareDelegateClass)): - def __init__(self): - super().__init__() - - def didShare(self): - def _show_bulletin(): - try: - from org.telegram.messenger import R as R_tg - BulletinFactory = find_class("org.telegram.ui.Components.BulletinFactory") - container = _fragment.getParentActivity().getWindow().getDecorView() - rp = _fragment.getResourceProvider() - _pbf(container, rp).createSimpleBulletin(R_tg.raw.voip_invite, strings.repo_link_copied).show() - except Exception as _be: - logx(f"repos.ShareDelegate.didShare: {_be}", True) - run_on_ui_thread(_show_bulletin) - - def didCopy(self): - return False - - share_alert = ShareAlert( - act, - None, - share_url, - True, - share_url, - False - ) - share_alert.setDelegate(ShareDelegate()) - frag.showDialog(share_alert) - except Exception as e: - logx(f"Share failed: {e}", False) - BulletinHelper.show_error(strings.failed_to_copy) - - return share_repository - - def makeOnToggleCollapse(i): - def toggle(view): - repos = self.repoManager.getRepositories() - if i < len(repos): - repos[i]['collapsed'] = not repos[i].get('collapsed', False) - self.repoManager.setRepositories(repos) - return toggle - - def makeOnSelectIcon(i): - def open_icon_selector(): - def on_icon_selected(icon_name): - self.repoManager.updateRepoField(i, 'icon', icon_name) - - icon_selector = IconSelector(self.repoManager, on_icon_selected) - settings_list = icon_selector.build() - return settings_list - - return open_icon_selector - - for idx, repo in enumerate(repos): - isCollapsed = repo.get("collapsed", False) - isEnabled = repo.get("enabled", True) - collapseIcon = "msg_go_up" if not isCollapsed else "arrow_more_solar" - headerText = strings.repository_form.format(idx + 1) - settingsList.append(Text( - text=headerText, - icon=collapseIcon, - accent=isEnabled, - on_click=makeOnToggleCollapse(idx) - )) - - if not isCollapsed: - current_icon = repo.get('icon', '') - icon_text = strings.repo_icon_text.format(current_icon) if current_icon else strings.repo_icon_not_selected - key_suffix = repo['id'] if repo.get('id') else f"idx_{idx}" - settingsList.extend([ - Switch( - key=f"repo_enabled_{key_suffix}", - text=strings.repo_enabled, - default=repo.get("enabled", True), - icon="msg_customize", - on_change=makeOnChange("enabled", idx) - ), - Input( - key=f"repo_name_{key_suffix}", - text=strings.repo_name, - default=repo.get("name", ""), - icon="msg_edit", - on_change=makeOnChange("name", idx) - ), - Input( - key=f"repo_url_{key_suffix}", - text=strings.repo_url, - default=repo.get("url", ""), - icon="msg_link", - on_change=makeOnChange("url", idx) - ), - Text( - text=icon_text, - icon="msg_folders", - create_sub_fragment=makeOnSelectIcon(idx) - ) - ]) - - settingsList.extend([ - Text( - text=strings.share_repository, - icon="msg_share", - accent=True, - on_click=makeOnShare(idx) - ) - ]) - - if len(repos) > 1: - settingsList.append(Text( - text=strings.remove_repository, - icon="msg_filled_blocked_solar", - red=True, - on_click=makeOnRemove(idx) - )) - - settingsList.append(Divider()) - - return settingsList \ No newline at end of file diff --git a/kotlin/src/kawaii/packetik/badges/BadgesNative.kt b/packit/src/kotlin/src/kawaii/packetik/badges/BadgesNative.kt similarity index 100% rename from kotlin/src/kawaii/packetik/badges/BadgesNative.kt rename to packit/src/kotlin/src/kawaii/packetik/badges/BadgesNative.kt diff --git a/kotlin/src/kawaii/packetik/catalog/CatalogChromeNative.kt b/packit/src/kotlin/src/kawaii/packetik/catalog/CatalogChromeNative.kt similarity index 96% rename from kotlin/src/kawaii/packetik/catalog/CatalogChromeNative.kt rename to packit/src/kotlin/src/kawaii/packetik/catalog/CatalogChromeNative.kt index 25e22c65..6cc08456 100644 --- a/kotlin/src/kawaii/packetik/catalog/CatalogChromeNative.kt +++ b/packit/src/kotlin/src/kawaii/packetik/catalog/CatalogChromeNative.kt @@ -86,7 +86,12 @@ object CatalogChromeNative { val icon = ImageView(ctx) if (iconRes != 0) icon.setImageResource(iconRes) icon.setColorFilter(iconTint) - icon.scaleType = ImageView.ScaleType.CENTER + // CENTER draws the drawable at its intrinsic size and clips it to the + // view: the host's action icons are 24dp and this slot is 20dp, so 2dp + // was shaved off every side. msg_list survives it (its strokes sit well + // inside the canvas) but msg_smile_status draws its circle 1dp from the + // edge, and the rim came out flattened on all four sides. + icon.scaleType = ImageView.ScaleType.CENTER_INSIDE btn.addView(icon, FrameLayout.LayoutParams(dp(ctx, 20f), dp(ctx, 20f), Gravity.CENTER)) return btn } diff --git a/kotlin/src/kawaii/packetik/openfile/OpenFileNative.kt b/packit/src/kotlin/src/kawaii/packetik/openfile/OpenFileNative.kt similarity index 100% rename from kotlin/src/kawaii/packetik/openfile/OpenFileNative.kt rename to packit/src/kotlin/src/kawaii/packetik/openfile/OpenFileNative.kt diff --git a/kotlin/src/kawaii/packetik/sfx/SfxNative.kt b/packit/src/kotlin/src/kawaii/packetik/sfx/SfxNative.kt similarity index 100% rename from kotlin/src/kawaii/packetik/sfx/SfxNative.kt rename to packit/src/kotlin/src/kawaii/packetik/sfx/SfxNative.kt diff --git a/kotlin/stubs/de/robv/android/xposed/XC_MethodHook.java b/packit/src/kotlin/stubs/de/robv/android/xposed/XC_MethodHook.java similarity index 100% rename from kotlin/stubs/de/robv/android/xposed/XC_MethodHook.java rename to packit/src/kotlin/stubs/de/robv/android/xposed/XC_MethodHook.java diff --git a/kotlin/stubs/de/robv/android/xposed/XposedBridge.java b/packit/src/kotlin/stubs/de/robv/android/xposed/XposedBridge.java similarity index 100% rename from kotlin/stubs/de/robv/android/xposed/XposedBridge.java rename to packit/src/kotlin/stubs/de/robv/android/xposed/XposedBridge.java diff --git a/packit/src/BasePlugin.py b/packit/src/python/BasePlugin.py similarity index 94% rename from packit/src/BasePlugin.py rename to packit/src/python/BasePlugin.py index d603b550..0028f329 100644 --- a/packit/src/BasePlugin.py +++ b/packit/src/python/BasePlugin.py @@ -3,13 +3,13 @@ from typing import List, Any from base_plugin import BasePlugin -from . import main +from . import Main as main import time _launch_start = time.time() -# the launch logic has been delegated to main.py +# the launch logic has been delegated to Main.py # there shouldn't be anything extra in this file (if it is not required) class Main(BasePlugin): def __init__(self): diff --git a/packit/src/main.py b/packit/src/python/Main.py similarity index 83% rename from packit/src/main.py rename to packit/src/python/Main.py index 45987712..22b62e51 100644 --- a/packit/src/main.py +++ b/packit/src/python/Main.py @@ -2,11 +2,11 @@ # SPDX-License-Identifier: GPL-3.0-or-later from packutil import logx -from .utils.bulletins import factory as _pbf +from .utils.Bulletins import factory as _pbf import time from typing import Any -from .other import text as _text -from .ChatActivity.inline.enterView import ( +from .integrations.decorations import Text as _text +from .integrations.chat.inline.EnterView import ( _packit_get_class, _packit_hook_enter_view_constructor, _packit_attach_text_watcher, @@ -26,7 +26,7 @@ def _clearLatestLog(): try: - from .utils.paths import getCacheRoot, getPluginsDir + from .utils.Paths import getCacheRoot, getPluginsDir import os, json settingsPath = getPluginsDir() + "/plugin_settings.json" if os.path.exists(settingsPath): @@ -44,14 +44,14 @@ def _clearLatestLog(): def startInit(plugin, launchStart): _clearLatestLog() - from .nativeLoader import detectArch + from .core.NativeLoader import detectArch detectArch() - from .RepositoryManager import RepositoryManager - from .core import PackItCore - from .MainActivity import SettingsBuilder - from .DialogsActivity.button import ChatButton - from .other.badges import BadgeManager + from .core.RepositoryManager import RepositoryManager + from .core.Core import PackItCore + from .ui.MainActivity import SettingsBuilder + from .integrations.chatlist.Button import ChatButton + from .integrations.decorations.Badges import BadgeManager plugin._launch_start = launchStart plugin.repoManager = RepositoryManager() @@ -79,7 +79,7 @@ def startInit(plugin, launchStart): def _migrate_packitcache(): import os try: - from .utils.paths import _filesDir + from .utils.Paths import _filesDir base = _filesDir() old = base + "/packitCache" new = base + "/packit" @@ -92,7 +92,7 @@ def _migrate_packitcache(): def _check_paths(): try: - from .utils.paths import ( + from .utils.Paths import ( getCacheRoot, getConfigsDir, getReposCacheDir, getTempDir, getPluginsDir, getElyxArchivesDir, getBitHashSoPath, ) @@ -120,31 +120,31 @@ def loadPlugin(plugin): logx(f"PackIt: import settings failed: {e}", False) return - from .ui.PluginActivity.fragment import process_start + from .ui.plugin.Fragment import process_start process_start() if RENAME_PACKITCACHE: _migrate_packitcache() if CHECK_PATHS: _check_paths() - from .nativeLoader import CHECK_SO_PATHS, checkSoPaths + from .core.NativeLoader import CHECK_SO_PATHS, checkSoPaths if CHECK_SO_PATHS: checkSoPaths() - from .utils.localConfig import LocalConfig + from .utils.LocalConfig import LocalConfig LocalConfig.init() - from .DialogsActivity.buildNotCorrect import setup_build_not_correct_check + from .integrations.chatlist.BuildNotCorrect import setup_build_not_correct_check setup_build_not_correct_check() try: - from .utils.installIndex import purge_missing + from .utils.InstallIndex import purge_missing purge_missing() except Exception as e: logx(f"PackIt: installIndex purge error: {e}", False) - from .other import isBeta - from .other import everyone as _everyone - isBeta.init() + from .integrations.decorations import IsBeta + from .integrations.decorations import Everyone as _everyone + IsBeta.init() _everyone.init() try: - from .ui.AchievementsActivity.service.AchivementsEngine import sync_accounts, sync_completed, _load_account, _save_account + from .ui.achievements.service.AchivementsEngine import sync_accounts, sync_completed, _load_account, _save_account sync_accounts() loaded, load_ok = _load_account() data, _ = sync_completed(loaded) @@ -165,36 +165,36 @@ def loadPlugin(plugin): plugin.deeplink_hook_ref = setup_deeplink_hook(plugin) plugin.chatUI.initialize_chat_menu() plugin.badgeManager.setup_hooks() - from .ChatActivity.SecurityBottomSheets import setup_policy_button_hook, setup_hash_button_hook + from .integrations.chat.securitybottomsheets import setup_policy_button_hook, setup_hash_button_hook plugin.policy_button_hook_ref = setup_policy_button_hook(plugin) plugin.hash_button_hook_ref = setup_hash_button_hook(plugin, plugin.repoManager) - from .ChatActivity.LinksIcons import setup_links_buttons_hook + from .integrations.chat.linksicons import setup_links_buttons_hook plugin.links_button_hook_ref = setup_links_buttons_hook(plugin) - from .standaloneHooks.InstallDismissHook import setup_install_dismiss_hook + from .integrations.hooks.InstallDismissHook import setup_install_dismiss_hook plugin.install_dismiss_hook_ref = setup_install_dismiss_hook(plugin) - from .standaloneHooks.universalFragmentFix import setup_universal_fragment_fix + from .integrations.hooks.UniversalFragmentFix import setup_universal_fragment_fix plugin.universal_fragment_fix_ref = setup_universal_fragment_fix(plugin) - from .ChatActivity.export.DecryptorBottomSheet import setup_packit_file_hook + from .integrations.chat.export.DecryptorBottomSheet import setup_packit_file_hook setup_packit_file_hook(plugin) - from .ChatActivity.afpFile import setup_afp_file_hook + from .integrations.chat.AfpFile import setup_afp_file_hook setup_afp_file_hook(plugin) - from .standaloneHooks.addPluginFab import setup_plugins_activity_fab + from .integrations.hooks.AddPluginFab import setup_plugins_activity_fab plugin.plugins_activity_fab_ref = setup_plugins_activity_fab(plugin) - from .standaloneHooks.addIconsFab import setup_icon_packs_activity_fab + from .integrations.hooks.AddIconsFab import setup_icon_packs_activity_fab plugin.icon_packs_activity_fab_ref = setup_icon_packs_activity_fab(plugin) - from .standaloneHooks.settingsActivityHook import setup_settings_activity_hook + from .integrations.hooks.SettingsActivityHook import setup_settings_activity_hook plugin.settings_activity_hook_refs = setup_settings_activity_hook(plugin) - from .SettingsActivity.service.fastExpandableHook import setup_fast_expandable_hook + from .ui.settings.service.FastExpandableHook import setup_fast_expandable_hook plugin.fast_expandable_hook_ref = setup_fast_expandable_hook(plugin, plugin.settingsBuilder.otherSettings) - from .DialogsActivity.pillWidget import setup_pill_widget + from .integrations.chatlist.PillWidget import setup_pill_widget setup_pill_widget(plugin) - from .DialogsActivity.updatesWidget import setup_updates_widget + from .integrations.chatlist.UpdatesWidget import setup_updates_widget setup_updates_widget(plugin) plugin.dialogs_menu_hook_ref = plugin.chatUI.setup_dialogs_menu_hook() plugin.everyone_hook_refs = _everyone.setup_hook(plugin) - from .ChatActivity.inline.enterView import setup_packit_autocomplete + from .integrations.chat.inline.EnterView import setup_packit_autocomplete plugin.packit_hook_constructor_ref = setup_packit_autocomplete(plugin) - from .ChatActivity.inline.inlineBtns import setup_inline_translate_button + from .integrations.chat.inline.InlineBtns import setup_inline_translate_button setup_inline_translate_button(plugin) plugin._init_official_repository() plugin._check_for_update() @@ -221,7 +221,7 @@ def _show_startup_bulletin(plugin): def _check_for_update(plugin): try: - from .DialogsActivity.PackitUpdateSheet import check_and_show + from .integrations.chatlist.PackitUpdateSheet import check_and_show check_and_show() except Exception as e: logx(f"PackIt: update check error: {e}", False) @@ -229,7 +229,7 @@ def _check_for_update(plugin): def _check_startup_updates(plugin): try: - from .ui.pluginsUpdates.startupSheet import check_and_show_startup_updates + from .ui.updates.StartupSheet import check_and_show_startup_updates check_and_show_startup_updates(plugin=plugin) except Exception as e: logx(f"PackIt: startup updates check error: {e}", False) @@ -240,7 +240,7 @@ def _check_update_notifications_bulletin(plugin): def task(): try: - from .ui.pluginsUpdates.fragment import _check_updates, _filter_ignored + from .ui.updates.Fragment import _check_updates, _filter_ignored updates = _filter_ignored(None, _check_updates(None)) if not updates: return @@ -283,7 +283,7 @@ def show(): return try: from elyx import assets - from .utils.media import playSound + from .utils.Media import playSound _snd = assets.sounds.available_updates.path_str playSound(_snd, "sfx_available_updates") except Exception as _e: @@ -291,7 +291,7 @@ def show(): if single_update is not None: def _install(): try: - from .ui.pluginsUpdates.fragment import _get_repos, _get_repo_plugins_url + from .ui.updates.Fragment import _get_repos, _get_repo_plugins_url import requests as _req pid = str(single_update.get("id") or "") repo_id = str(single_update.get("repo_id") or "") @@ -326,7 +326,7 @@ def _install(): if not plugin_item: logx(f"PackIt: update bulletin install: plugin '{pid}' not found in repo", True) return - from .core import install_plugin + from .core.Core import install_plugin run_on_ui_thread(lambda: install_plugin(plugin_item, all_plugins=all_plugins, rm_rid=repo_id)) except Exception as _e: logx(f"PackIt: update bulletin install error: {_e}", True) @@ -335,7 +335,7 @@ def _install(): else: def _action(): try: - from .ui.pluginsUpdates.fragment import show_updates_fragment + from .ui.updates.Fragment import show_updates_fragment show_updates_fragment() except Exception as _e: logx(f"PackIt: update bulletin open error: {_e}", True) @@ -362,7 +362,7 @@ def _check_identity_achievement(plugin): return first_name = str(user.first_name) if user.first_name else "" if first_name.lower() in ("shareui", "fuchs"): - from .ui.AchievementsActivity.service.AchivementsEngine import unlock_secret + from .ui.achievements.service.AchivementsEngine import unlock_secret unlock_secret("identity") @@ -385,7 +385,7 @@ def on_send_message_hook(plugin, account: int, params: Any): if params.message.startswith(".deleteachievements"): try: import os - from .ui.AchievementsActivity.service.AchivementsEngine import _get_db_path, _get_snap_path + from .ui.achievements.service.AchivementsEngine import _get_db_path, _get_snap_path for path in (_get_db_path(), _get_snap_path()): if os.path.exists(path): os.remove(path) diff --git a/packit/src/__init__.py b/packit/src/python/__init__.py similarity index 100% rename from packit/src/__init__.py rename to packit/src/python/__init__.py diff --git a/packit/src/core.py b/packit/src/python/core/Core.py similarity index 96% rename from packit/src/core.py rename to packit/src/python/core/Core.py index df0cadee..a1689d42 100644 --- a/packit/src/core.py +++ b/packit/src/python/core/Core.py @@ -19,17 +19,17 @@ from org.telegram.messenger import ApplicationLoader, AndroidUtilities except Exception as e: import android_utils as _au; _au.log(f"import org.telegram.messenger import ApplicationLoader failed: {e}") - from .utils.importFailed import showImportFailedAlert as _sifa; _sifa() + from ..utils.ImportFailed import showImportFailedAlert as _sifa; _sifa() try: from com.exteragram.messenger.plugins import PluginsController except Exception as e: import android_utils as _au; _au.log(f"import com.exteragram.messenger.plugins import PluginsController failed: {e}") - from .utils.importFailed import showImportFailedAlert as _sifa; _sifa() + from ..utils.ImportFailed import showImportFailedAlert as _sifa; _sifa() try: from org.telegram.messenger import NotificationCenter except Exception as e: import android_utils as _au; _au.log(f"import org.telegram.messenger import NotificationCenter failed: {e}") - from .utils.importFailed import showImportFailedAlert as _sifa; _sifa() + from ..utils.ImportFailed import showImportFailedAlert as _sifa; _sifa() import time import signal @@ -119,7 +119,7 @@ def _is_elyx_plugin(plugin_info: dict) -> bool: def install_plugin(plugin_info: dict, icon_view=None, button=None, original_icon_id=None, loading_view=None, on_finish=None, install_ui=None, all_plugins: list = None, rm_rid: str = "", succ_download=None): deps = plugin_info.get("deps") or [] if deps: - from .ui.PluginListActivity.sheets.depsSheet import show_deps_sheet + from ..ui.plugins.sheets.DepsSheet import show_deps_sheet def on_confirmed(): _do_install(plugin_info, icon_view, button, original_icon_id, loading_view, on_finish, install_ui, rm_rid=rm_rid, succ_download=succ_download) show_deps_sheet(install_ui, plugin_info, on_confirmed, all_plugins=all_plugins, on_cancel=on_finish) @@ -155,10 +155,10 @@ def _commit_index(): return try: if _is_elyx_plugin(plugin_info): - from .utils.installIndex import commit_elyx_pending + from ..utils.InstallIndex import commit_elyx_pending commit_elyx_pending(plugin_info, rm_rid, original_path=temp_path) else: - from .utils.installIndex import commit_pending + from ..utils.InstallIndex import commit_pending commit_pending() except Exception as e: logx(f"core: index commit error: {e}", False) @@ -191,7 +191,7 @@ def _on_plugins_updated(): # to elyx only to avoid double counting. if _is_elyx_plugin(plugin_info): try: - from .ui.AchievementsActivity.service.AchivementsEngine import increment_category + from ..ui.achievements.service.AchivementsEngine import increment_category increment_category("Installing plugins") except Exception as e: logx(f"core: elyx achievement increment error: {e}", False) @@ -207,7 +207,7 @@ def _on_plugins_updated(): logx(f"core: check restart={restart}", True) if restart in ("required", "optional"): logx("core: calling show_restart_dialog", True) - from .ui.restartDialog import show_restart_dialog + from ..ui.dialogs.RestartDialog import show_restart_dialog show_restart_dialog(restart, fragment) except Exception as e: logx(f"core: restart dialog error: {e}", False) @@ -261,7 +261,7 @@ def didReceivedNotification(self, id, account, *args): ElyxEngine.instance.showInstallDialog(fragment, install_params) else: if write_index: - from .utils.installIndex import set_pending + from ..utils.InstallIndex import set_pending set_pending(plugin_info, rm_rid) PluginsController.getInstance().showInstallDialog(fragment, temp_path, True) @@ -275,14 +275,14 @@ def didReceivedNotification(self, id, account, *args): pass -from .utils.hashUtil import hashFile, getHashMethod, METHOD_SHA256, METHOD_BITHASH, matchesStoredHash +from ..utils.HashUtil import hashFile, getHashMethod, METHOD_SHA256, METHOD_BITHASH, matchesStoredHash def _get_plugin_cache_path(pkg: str, filename: str) -> str: # cache is isolated per hash method method = getHashMethod() subdir = "BitHash" if method == METHOD_BITHASH else "sha256" - from .utils.paths import getPluginCacheDir + from ..utils.Paths import getPluginCacheDir cache_dir = getPluginCacheDir(subdir) os.makedirs(cache_dir, exist_ok=True) return os.path.join(cache_dir, filename) @@ -290,7 +290,7 @@ def _get_plugin_cache_path(pkg: str, filename: str) -> str: -# keep old name as alias so fragment.py import stays valid +# keep old name as alias so Fragment.py import stays valid def _sha256_file(path: str) -> str: return hashFile(path) @@ -346,7 +346,7 @@ def _do_install(plugin_info: dict, icon_view=None, button=None, original_icon_id def task(): try: - from .utils.paths import getPluginsDir + from ..utils.Paths import getPluginsDir plugins_dir = getPluginsDir() try: os.makedirs(plugins_dir, exist_ok=True) @@ -520,7 +520,7 @@ def task(): run_on_ui_thread(lambda: BulletinHelper.show_error(_s("core_iconpack_http_error", code=r.status_code))) return - from .utils.paths import getIconPackTmpPath + from ..utils.Paths import getIconPackTmpPath tmp_path = getIconPackTmpPath(pack_id) content_length = r.headers.get("content-length") @@ -596,7 +596,7 @@ def install_plugin_silent(file_path: str, plugin_data: dict, repo_id: str, on_co from elyxcore._plugin_engine import ElyxEngine except ImportError: from elyxcore import ElyxPlugin, ElyxEngine # older SDKs - from .utils.installIndex import commit_elyx_pending + from ..utils.InstallIndex import commit_elyx_pending elyx_plugin = ElyxPlugin(plzip=ZipFile(file_path, "r"), raise_errors=False) @@ -633,7 +633,7 @@ def _elyx_error(error): try: from elyxcore import gen from org.telegram.messenger import Utilities - from .utils.installIndex import set_pending, commit_pending + from ..utils.InstallIndex import set_pending, commit_pending Callback = gen(Utilities.Callback, "run") python_engine = PluginsController.getEngines().get("python") diff --git a/packit/src/dexLoader.py b/packit/src/python/core/DexLoader.py similarity index 79% rename from packit/src/dexLoader.py rename to packit/src/python/core/DexLoader.py index 2c8f33ca..b458cb9f 100644 --- a/packit/src/dexLoader.py +++ b/packit/src/python/core/DexLoader.py @@ -1,52 +1,73 @@ # pyright: reportMissingImports=false # SPDX-License-Identifier: GPL-3.0-or-later -# Loads precompiled Kotlin dexes shipped in packit/dex/ and calls their -# entrypoints. Currently used for the badge system (kawaii.packetik.badges), -# ported from packit/src/other/{badges,chatBadge,chatTitleIcon,profileTitleIcon}.py. +# Loads the precompiled Kotlin shipped in packit/dex/packit.dex and calls its +# entrypoints — badges, the code viewer, the catalogue chrome, the sfx cells. # -# Dex is arch-independent Dalvik bytecode, so a single copy is shipped for all -# ABIs (unlike packit/native/, which is per-ABI). +# One dex for all of kawaii.packetik. It used to be four, named after the +# package each was for, but R8 emits one classes.dex from all of the sources +# and the build script copied that same file out under four names: four +# identical 55K blobs in the artifact, and four class loaders here, each with +# its own copy of the same bytecode resident. +# +# Dex is arch-independent Dalvik bytecode, so one copy serves all ABIs (unlike +# packit/native/, which is per-ABI). from packutil import logx import os -_DEX_BASE = "/plugins/ElyxPlugins/shareui_packit/packit/dex" +_DEX_PATH = "/plugins/ElyxPlugins/shareui_packit/packit/dex/packit.dex" + _BADGES_CLASS = "kawaii.packetik.badges.BadgesNative" +_OPENFILE_CLASS = "kawaii.packetik.openfile.OpenFileNative" +_CATALOG_CLASS = "kawaii.packetik.catalog.CatalogChromeNative" +_SFX_CLASS = "kawaii.packetik.sfx.SfxNative" -# loaded entrypoint Class objects, keyed by dex name (kept for later calls) -_loaded = {} +_loader = None # the one InMemoryDexClassLoader over packit.dex +_classes = {} # entrypoint Class objects by name, kept for later calls -def _dexPath(name: str) -> str: - from .utils.paths import _filesDir - return _filesDir() + _DEX_BASE + "/" + name + ".dex" +def _dexPath() -> str: + from ..utils.Paths import _filesDir + return _filesDir() + _DEX_PATH -def _loadClass(dexName: str, className: str, context): - # loads className from packit/dex//.dex whose parent is the - # host app classloader (so host classes resolve). - # +def _dexLoader(context): # Uses InMemoryDexClassLoader (API 26+; PackIt requires Android 13+): the # plugin dir is writable by the app, and Android's W^X policy refuses to # load a writable dex file via DexClassLoader ("Writable dex file ... is not # allowed"). Loading from an in-memory ByteBuffer sidesteps that entirely. - cached = _loaded.get(dexName) - if cached is not None: - return cached - dex_path = _dexPath(dexName) + global _loader + if _loader is not None: + return _loader + dex_path = _dexPath() if not os.path.exists(dex_path): - logx(f"dexLoader: {dexName}.dex not found at {dex_path}", False) + logx(f"dexLoader: packit.dex not found at {dex_path}", False) return None with open(dex_path, "rb") as f: data = f.read() from java.nio import ByteBuffer from dalvik.system import InMemoryDexClassLoader - parent_cl = context.getClassLoader() - loader = InMemoryDexClassLoader(ByteBuffer.wrap(data), parent_cl) - cls = loader.loadClass(className) - _loaded[dexName] = cls - logx(f"dexLoader: loaded {className} from {dexName}.dex ({len(data)} bytes, in-memory)", True) + # parented to the host app classloader, so host classes resolve + _loader = InMemoryDexClassLoader(ByteBuffer.wrap(data), context.getClassLoader()) + logx(f"dexLoader: loaded packit.dex ({len(data)} bytes, in-memory)", True) + return _loader + + +def _loadClass(className: str, context): + cached = _classes.get(className) + if cached is not None: + return cached + loader = _dexLoader(context) + if loader is None: + return None + try: + cls = loader.loadClass(className) + except Exception as e: + logx(f"dexLoader: {className} not in packit.dex: {e}", False) + return None + _classes[className] = cls + logx(f"dexLoader: resolved {className}", True) return cls @@ -68,13 +89,13 @@ def _callStatic(cls, method: str, *args): def loadBadges(context, enabled: bool) -> bool: - # loads badges.dex and calls BadgesNative.init(classLoader, context, enabled). + # resolves BadgesNative and calls init(classLoader, context, enabled). # returns True on success; caller falls back to the Python impl on False. try: if context is None: from org.telegram.messenger import ApplicationLoader context = ApplicationLoader.applicationContext - cls = _loadClass("badges", _BADGES_CLASS, context) + cls = _loadClass(_BADGES_CLASS, context) if cls is None: return False _callStatic(cls, "init", context.getClassLoader(), context, bool(enabled)) @@ -87,7 +108,7 @@ def loadBadges(context, enabled: bool) -> bool: def setBadgesEnabled(enabled: bool): try: - cls = _loaded.get("badges") + cls = _classes.get(_BADGES_CLASS) if cls is not None: _callStatic(cls, "setEnabled", bool(enabled)) except Exception as e: @@ -96,16 +117,13 @@ def setBadgesEnabled(enabled: bool): def unloadBadges(): try: - cls = _loaded.get("badges") + cls = _classes.get(_BADGES_CLASS) if cls is not None: _callStatic(cls, "deinit") except Exception as e: logx(f"dexLoader: unloadBadges error: {e}", False) -_OPENFILE_CLASS = "kawaii.packetik.openfile.OpenFileNative" - - def openFileCreate(context, path, text_size_px, pad_l, pad_t, pad_r, pad_b, bg_color, text_color, token_types, token_starts, token_ends, color_keys, color_vals): @@ -117,7 +135,7 @@ def openFileCreate(context, path, text_size_px, pad_l, pad_t, pad_r, pad_b, if context is None: from org.telegram.messenger import ApplicationLoader context = ApplicationLoader.applicationContext - cls = _loadClass("openfile", _OPENFILE_CLASS, context) + cls = _loadClass(_OPENFILE_CLASS, context) if cls is None: return None from java import jint, jfloat, jarray @@ -138,10 +156,6 @@ def _ia(lst): return None -_CATALOG_CLASS = "kawaii.packetik.catalog.CatalogChromeNative" -_SFX_CLASS = "kawaii.packetik.sfx.SfxNative" - - def catalogChromeCreate(context, main_bg, card_bg, card_pressed, text_color, accent, accent_pressed, button_text, icon_clear, icon_search, icon_ai, icon_filter, icon_sort, @@ -153,7 +167,7 @@ def catalogChromeCreate(context, main_bg, card_bg, card_pressed, text_color, if context is None: from org.telegram.messenger import ApplicationLoader context = ApplicationLoader.applicationContext - cls = _loadClass("catalog", _CATALOG_CLASS, context) + cls = _loadClass(_CATALOG_CLASS, context) if cls is None: return None from java import jint @@ -183,7 +197,7 @@ def catalogIconsChromeCreate(context, main_bg, card_bg, card_pressed, text_color if context is None: from org.telegram.messenger import ApplicationLoader context = ApplicationLoader.applicationContext - cls = _loadClass("catalog", _CATALOG_CLASS, context) + cls = _loadClass(_CATALOG_CLASS, context) if cls is None: return None from java import jint @@ -210,7 +224,7 @@ def sfxExpandableCreate(context, item_id, text, subtext, checked, collapsed, if context is None: from org.telegram.messenger import ApplicationLoader context = ApplicationLoader.applicationContext - cls = _loadClass("sfx", _SFX_CLASS, context) + cls = _loadClass(_SFX_CLASS, context) if cls is None: return None from java import jint @@ -229,7 +243,7 @@ def sfxChildCreate(context, item_id, text, checked): if context is None: from org.telegram.messenger import ApplicationLoader context = ApplicationLoader.applicationContext - cls = _loadClass("sfx", _SFX_CLASS, context) + cls = _loadClass(_SFX_CLASS, context) if cls is None: return None from java import jint @@ -248,7 +262,7 @@ def sfxVolumeSliderCreate(context, initial, title, off_label, maximum_label, if context is None: from org.telegram.messenger import ApplicationLoader context = ApplicationLoader.applicationContext - cls = _loadClass("sfx", _SFX_CLASS, context) + cls = _loadClass(_SFX_CLASS, context) if cls is None: return None from java import jint @@ -264,7 +278,7 @@ def sfxVolumeSliderCreate(context, initial, title, off_label, maximum_label, def openFileCancel(view): try: - cls = _loaded.get("openfile") + cls = _classes.get(_OPENFILE_CLASS) if cls is not None and view is not None: _callStatic(cls, "cancel", view) except Exception as e: @@ -273,7 +287,7 @@ def openFileCancel(view): def openFileGetText(view): try: - cls = _loaded.get("openfile") + cls = _classes.get(_OPENFILE_CLASS) if cls is not None and view is not None: return _callStatic(cls, "getText", view) except Exception as e: diff --git a/packit/src/nativeLoader.py b/packit/src/python/core/NativeLoader.py similarity index 99% rename from packit/src/nativeLoader.py rename to packit/src/python/core/NativeLoader.py index 52f2a08b..06081092 100644 --- a/packit/src/nativeLoader.py +++ b/packit/src/python/core/NativeLoader.py @@ -38,7 +38,7 @@ def detectArch() -> str: def _soPath(libName: str) -> str: - from .utils.paths import _filesDir + from ..utils.Paths import _filesDir arch = detectArch() return _filesDir() + _BASE + "/" + arch + "/" + libName + ".so" @@ -140,7 +140,7 @@ def _retry(): # index 49 of the error pack; the shared loader binds it now if # cached, else on diceStickersDidLoad (no polling) - from .utils.stickers import load_sticker + from ..utils.Stickers import load_sticker load_sticker(iv, "wtffffffffffDD/49", 100) linear.addView(iv, LayoutHelper.createLinear( diff --git a/packit/src/RepositoryManager.py b/packit/src/python/core/RepositoryManager.py similarity index 50% rename from packit/src/RepositoryManager.py rename to packit/src/python/core/RepositoryManager.py index 2ae65263..23c65047 100644 --- a/packit/src/RepositoryManager.py +++ b/packit/src/python/core/RepositoryManager.py @@ -2,31 +2,43 @@ # SPDX-License-Identifier: GPL-3.0-or-later from packutil import logx -from .utils.netQueue import run_serial_io -import os +from ..utils.NetQueue import run_serial_io +from ..network import Storage +from ..utils import CachedRepos import json -import requests from client_utils import get_last_fragment, run_on_queue try: from elyx import settings, strings except Exception as e: import android_utils as _au; _au.log(f"import elyx import settings, strings failed: {e}") - from .utils.importFailed import showImportFailedAlert as _sifa; _sifa() + from ..utils.ImportFailed import showImportFailedAlert as _sifa; _sifa() try: from org.telegram.messenger import ApplicationLoader except Exception as e: import android_utils as _au; _au.log(f"import org.telegram.messenger import ApplicationLoader failed: {e}") - from .utils.importFailed import showImportFailedAlert as _sifa; _sifa() - -_HEADERS = {"User-Agent": "PackIt/1.0 (Android; github.com/shareui/packit)"} + from ..utils.ImportFailed import showImportFailedAlert as _sifa; _sifa() OFFICIAL_REPO_URL = "https://raw.githubusercontent.com/shareui/packit/refs/heads/main/configs/repomap.json" +# A repository name is a label, not a document. It is drawn on one line in the +# source card, in both repository pickers and in the plugin list, and it arrives +# from a remote repomap that is free to put anything at all in rm_name — so the +# limit belongs here, on the way into storage, and not on each screen that has +# to render whatever it finds. Thirty-two is about what the card fits at 17sp on +# a narrow phone, and comfortably more than any real name so far ("exteraGram +# Utilities" is twenty). +REPO_NAME_MAX = 32 + -def _get_cache_dir() -> str: - from .utils.paths import getReposCacheDir - return getReposCacheDir() +def clampRepoName(value) -> str: + text = str(value or "") + # the name is single-line everywhere it appears, so a newline or a tab in it + # is only ever a way to break someone's layout + text = " ".join(text.split()) + if len(text) > REPO_NAME_MAX: + text = text[:REPO_NAME_MAX - 1].rstrip() + "…" + return text class RepositoryManager: @@ -39,11 +51,22 @@ def getRepositories(self): repos = json.loads(reposJson) if not isinstance(repos, list): return [] + # also on the way out: names stored before the limit existed are + # already on disk, and nothing rewrites them until the list changes + for repo in repos: + if isinstance(repo, dict) and "name" in repo: + repo["name"] = clampRepoName(repo.get("name")) return repos except Exception: return [] def setRepositories(self, repos): + # every write lands here — the add sheet, the edit dialog, the repo=add + # deeplink, the startup cache refresh — so the name limit is applied + # once, in place, and the caller's list matches what was stored + for repo in repos: + if isinstance(repo, dict) and "name" in repo: + repo["name"] = clampRepoName(repo.get("name")) settings.set("repositories", json.dumps(repos), reload_settings=True) try: fragment = get_last_fragment() @@ -51,147 +74,48 @@ def setRepositories(self, repos): fragment.rebuildAllItems() except Exception: pass + # the sources screen is a plain fragment with no adapter, so + # rebuildAllItems never reaches it — it listens here instead + try: + from ..ui.repos import notify_repos_changed + notify_repos_changed() + except Exception: + pass def _fetch_and_save_repomap(self, url: str) -> dict | None: - """Fetch repomap.json from url, save to packit/{rm_rid}.json, return repometa dict.""" - try: - r = requests.get(url, timeout=15, headers=_HEADERS) - if r.status_code != 200: - logx(f"repom: failed to fetch repomap from '{url}': HTTP {r.status_code}", True) - return None - data = r.json() - repometa = data.get("repometa") - if not repometa: - logx(f"repom: no 'repometa' key in response from '{url}'", True) - return None - rm_rid = repometa.get("rm_rid") - if not rm_rid: - logx(f"repom: 'rm_rid' missing in repometa", True) - return None - cache_dir = _get_cache_dir() - os.makedirs(cache_dir, exist_ok=True) - cache_path = os.path.join(cache_dir, f"{rm_rid}.json") - with open(cache_path, "w", encoding="utf-8") as f: - json.dump(data, f, indent=2, ensure_ascii=False) - logx(f"repom: saved repomap to {cache_path}", True) - return repometa - except Exception as e: - logx(f"repom: _fetch_and_save_repomap error: {e}", False) + """Fetch a repomap, cache it, return its repometa. None if any of that fails.""" + data, error = Storage.fetch_repomap(url) + if error: + logx(f"repom: cannot fetch repomap from '{url}': {error}", True) return None - - def _get_temp_dir(self) -> str: - from .utils.paths import getTempDir - return getTempDir() - - def _cleanup_temp_dir(self): - import shutil - temp_dir = self._get_temp_dir() - try: - if os.path.exists(temp_dir): - shutil.rmtree(temp_dir) - logx("repom: cleaned up packitTemp", True) - except Exception as e: - logx(f"repom: _cleanup_temp_dir error: {e}", False) + repometa = data.get("repometa") + if not CachedRepos.write(repometa.get("rm_rid"), data): + return None + return repometa def addRepositoryWithUrl(self, url: str): - # download to packitTemp, validate, move to reposCache or discard - # returns (repometa, error_reason) — error_reason is None on success - import shutil + # returns (repometa, error_reason) — error_reason is None on success. + # + # This used to download into packitTemp and move the file into the cache + # once it validated. Storage validates what it parsed before anything is + # written, so there is nothing to stage: a repomap that fails its checks + # never reaches the disk in the first place. + logx(f"repom: addRepositoryWithUrl: GET {url}", True) + data, error = Storage.fetch_repomap(url) + if error: + logx(f"repom: addRepositoryWithUrl: {error}", True) + return None, error - temp_dir = self._get_temp_dir() - temp_path = os.path.join(temp_dir, "repomap_download.json") - - try: - os.makedirs(temp_dir, exist_ok=True) - logx(f"repom: addRepositoryWithUrl: GET {url}", True) - logx(f"repom: addRepositoryWithUrl: sending headers={_HEADERS}", True) - r = requests.get(url, timeout=15, headers=_HEADERS) - status = r.status_code - logx(f"repom: addRepositoryWithUrl: status={status}", True) - try: - resp_headers = dict(r.headers) - logx(f"repom: addRepositoryWithUrl: resp_headers={resp_headers}", True) - except Exception as ex: - logx(f"repom: addRepositoryWithUrl: could not read resp headers: {ex}", True) - try: - logx(f"repom: addRepositoryWithUrl: body[:500]={r.text[:500]}", True) - except Exception as ex: - logx(f"repom: addRepositoryWithUrl: could not read body: {ex}", True) - if status != 200: - reasons = { - 301: "permanently redirected", - 302: "redirected", - 303: "see other", - 307: "temporarily redirected", - 308: "permanently redirected", - 400: "bad request", - 401: "unauthorized", - 403: "forbidden", - 404: "file not found", - 408: "request timeout", - 410: "resource gone", - 429: "rate limited, try again later", - 451: "unavailable for legal reasons", - 500: "server error", - 502: "bad gateway", - 503: "service unavailable", - 504: "gateway timeout", - } - reason = reasons.get(status, f"HTTP {status}") - self._cleanup_temp_dir() - return None, reason - - with open(temp_path, "w", encoding="utf-8") as f: - f.write(r.text) - logx(f"repom: downloaded to {temp_path}", True) - except Exception as e: - self._cleanup_temp_dir() - return None, str(e) - - # validate - try: - with open(temp_path, "r", encoding="utf-8") as f: - data = json.load(f) - except Exception as e: - logx(f"repom: addRepositoryWithUrl: json parse error: {e}", False) - self._cleanup_temp_dir() - return None, "invalid json" - - logx(f"repom: addRepositoryWithUrl: parsed ok, keys={list(data.keys())}", True) repometa = data.get("repometa") - if not repometa: - logx("repom: addRepositoryWithUrl: missing repometa", True) - self._cleanup_temp_dir() - return None, "missing repometa" - rm_rid = repometa.get("rm_rid") - logx(f"repom: addRepositoryWithUrl: rm_rid={repr(rm_rid)}", True) - if not rm_rid: - logx("repom: addRepositoryWithUrl: missing rm_rid", True) - self._cleanup_temp_dir() - return None, "missing rm_rid" - rm_name = repometa.get("rm_name") - logx(f"repom: addRepositoryWithUrl: rm_name={repr(rm_name)}", True) + logx(f"repom: addRepositoryWithUrl: rm_rid={repr(rm_rid)} rm_name={repr(rm_name)}", True) if not rm_name: - logx("repom: addRepositoryWithUrl: missing rm_name", True) - self._cleanup_temp_dir() return None, "missing rm_name" - # move to reposCache - try: - cache_dir = _get_cache_dir() - os.makedirs(cache_dir, exist_ok=True) - cache_path = os.path.join(cache_dir, f"{rm_rid}.json") - shutil.move(temp_path, cache_path) - logx(f"repom: moved repomap to {cache_path}", True) - except Exception as e: - logx(f"repom: addRepositoryWithUrl: move to cache error: {e}", False) - self._cleanup_temp_dir() + if not CachedRepos.write(rm_rid, data): return None, "cache write failed" - self._cleanup_temp_dir() - repos = self.getRepositories() newRepo = { "id": rm_rid, @@ -256,20 +180,10 @@ def removeRepository(self, idx): repo_id = repo.get("id") logx(f"repom.removeRepository: removing idx={idx}, id={repr(repo_id)}, name={repr(repo.get('name'))}", True) - try: - if repo_id: - cache_dir = _get_cache_dir() - cache_path = os.path.join(cache_dir, f"{repo_id}.json") - logx(f"repom.removeRepository: looking for cache at {cache_path}", True) - if os.path.exists(cache_path): - os.remove(cache_path) - logx(f"repom.removeRepository: deleted cache {cache_path}", True) - else: - logx(f"repom.removeRepository: cache not found at {cache_path}", True) - else: - logx("repom.removeRepository: repo has no id, skipping cache delete", True) - except Exception as e: - logx(f"repom.removeRepository: failed to delete cache: {e}", False) + if repo_id: + dropped = CachedRepos.forget(repo_id) + logx(f"repom.removeRepository: cache for '{repo_id}' " + f"{'deleted' if dropped else 'was not there'}", True) repos.pop(idx) self.setRepositories(repos) @@ -281,23 +195,54 @@ def updateRepoField(self, idx, field, value): repos[idx][field] = value self.setRepositories(repos) - def restoreDefaultRepository(self): + def restoreDefaultRepository(self, on_done=None): + # "restore" means make sure the official repository is there and usable, + # not append another copy of it: it used to append unconditionally, so + # pressing the item twice left two identical entries, and again a third. def task(): repometa = self._fetch_and_save_repomap(OFFICIAL_REPO_URL) - newRepo = { - "id": repometa.get("rm_rid") if repometa else "shareui_official", - "name": repometa.get("rm_name", strings.official_repository) if repometa else strings.official_repository, - "url": OFFICIAL_REPO_URL, - "enabled": True, - "collapsed": False, - "icon": "chats_pin" - } + rm_rid = repometa.get("rm_rid") if repometa else "shareui_official" + rm_name = repometa.get("rm_name", strings.official_repository) if repometa else strings.official_repository + repos = self.getRepositories() - repos.append(newRepo) + existing = -1 + for i, repo in enumerate(repos): + if str(repo.get("id") or "") == str(rm_rid): + existing = i + break + if str(repo.get("url") or "").strip() == OFFICIAL_REPO_URL: + existing = i + break + + if existing >= 0: + # already present: repair it instead — that is what someone + # reaching for "restore" after disabling or renaming it wants + repos[existing]["id"] = rm_rid + repos[existing]["url"] = OFFICIAL_REPO_URL + repos[existing]["enabled"] = True + if not str(repos[existing].get("name") or "").strip(): + repos[existing]["name"] = rm_name + restored = False + else: + repos.append({ + "id": rm_rid, + "name": rm_name, + "url": OFFICIAL_REPO_URL, + "enabled": True, + "collapsed": False, + "icon": "chats_pin", + }) + restored = True + self.setRepositories(repos) fragment = get_last_fragment() if fragment and hasattr(fragment, "rebuildAllItems"): fragment.rebuildAllItems() + if on_done: + try: + on_done(restored) + except Exception as e: + logx(f"repom: restoreDefaultRepository on_done error: {e}", False) run_serial_io(task) def resetRepositories(self): @@ -330,8 +275,6 @@ def updateAllCaches(self, on_complete=None): def task(): try: repos = self.getRepositories() - cache_dir = _get_cache_dir() - os.makedirs(cache_dir, exist_ok=True) changed = False to_remove = [] seen_rids = set() @@ -341,20 +284,21 @@ def task(): if not url: continue try: - r = requests.get(url, timeout=10, headers=_HEADERS) - if r.status_code != 200: - logx(f"updateAllCaches: HTTP {r.status_code} for {url}", True) - continue - data = r.json() - repometa = data.get("repometa") - rm_rid = repometa.get("rm_rid") if repometa else None - - # No repometa — remove repo - if not repometa or not rm_rid: - logx(f"updateAllCaches: no repometa for '{url}', removing repo", True) + data, error = Storage.fetch_repomap(url) + if error in ("missing repometa", "missing rm_rid"): + # it answered, and what it answered with is not a + # repository — drop it + logx(f"updateAllCaches: '{url}' is not a repomap ({error}), removing", True) to_remove.append(i) changed = True continue + if error: + # unreachable or unparseable: keep the repository and + # whatever is already cached for it + logx(f"updateAllCaches: {error} for {url}", True) + continue + repometa = data.get("repometa") + rm_rid = repometa.get("rm_rid") # Duplicate check if rm_rid in seen_rids: @@ -369,9 +313,17 @@ def task(): changed = True logx(f"updateAllCaches: set id='{rm_rid}' for repo '{repo.get('name')}'", True) - cache_path = os.path.join(cache_dir, f"{rm_rid}.json") - with open(cache_path, "w", encoding="utf-8") as f: - json.dump(data, f, indent=2, ensure_ascii=False) + # a repository left without a name shows up as "unnamed" + # everywhere; the repomap has one, and this runs on every + # start, so take it back rather than leave it that way + if not str(repo.get("name") or "").strip(): + rm_name = str(repometa.get("rm_name") or "").strip() + if rm_name: + repos[i]["name"] = rm_name + changed = True + logx(f"updateAllCaches: restored name '{rm_name}' for '{rm_rid}'", True) + + CachedRepos.write(rm_rid, data) logx(f"updateAllCaches: updated cache for '{rm_rid}'", True) except Exception as e: logx(f"updateAllCaches: error for {url}: {e}", False) diff --git a/packit/src/python/core/__init__.py b/packit/src/python/core/__init__.py new file mode 100644 index 00000000..9b5f771d --- /dev/null +++ b/packit/src/python/core/__init__.py @@ -0,0 +1,5 @@ +# pyright: reportMissingImports=false +# SPDX-License-Identifier: GPL-3.0-or-later + +# What the plugin is made of: installing and removing plugins, loading the +# dexes and the native libraries, and the repository list itself. diff --git a/packit/src/deeplinks/contributors.py b/packit/src/python/deeplinks/Contributors.py similarity index 100% rename from packit/src/deeplinks/contributors.py rename to packit/src/python/deeplinks/Contributors.py diff --git a/packit/src/deeplinks/deepHandler.py b/packit/src/python/deeplinks/DeepHandler.py similarity index 75% rename from packit/src/deeplinks/deepHandler.py rename to packit/src/python/deeplinks/DeepHandler.py index a331cb99..8cfccd0d 100644 --- a/packit/src/deeplinks/deepHandler.py +++ b/packit/src/python/deeplinks/DeepHandler.py @@ -14,28 +14,28 @@ except Exception: _dh_strings = None -from . import mainMenu -from . import settings -from . import deeplinkMenu -from . import other -from . import contributors -from . import docs -from . import forum -from . import repo -from . import install -from . import update -from . import problems -from . import pkill -from . import plugin -from .secret import premium -from .secret import terraria -from .secret import aytist -from . import suggestion +from . import MainMenu +from . import Settings +from . import DeeplinkMenu +from . import Other +from . import Contributors +from . import Docs +from . import Forum +from . import Repo +from . import Install +from . import Update +from . import Problems +from . import Pkill +from . import Plugin +from .secret import Premium +from .secret import Terraria +from .secret import Aytist +from . import Suggestion class PackItDeeplinkHook(MethodHook): def __init__(self, plugin): - self.plugin = plugin + self.plugin = Plugin self.pending_intent = None self.pending_param = None self.is_processing = False @@ -67,23 +67,23 @@ def before_hooked_method(self, param): def show_packit_notification(self, url): try: - mainMenu.handle(url) - settings.handle(url, self.plugin) - deeplinkMenu.handle(url) - other.handle(url) - contributors.handle(url) - docs.handle(url) - forum.handle(url) - repo.handle(url, self.plugin.repoManager) - install.handle(url, self.plugin.repoManager) - update.handle(url, self.plugin.repoManager) - problems.handle(url) - pkill.handle(url) - plugin.handle(url, self.plugin.repoManager) - premium.handle(url) - terraria.handle(url) - aytist.handle(url) - suggestion.handle(url, self.plugin) + MainMenu.handle(url) + Settings.handle(url, self.plugin) + DeeplinkMenu.handle(url) + Other.handle(url) + Contributors.handle(url) + Docs.handle(url) + Forum.handle(url) + Repo.handle(url, self.plugin.repoManager) + Install.handle(url, self.plugin.repoManager) + Update.handle(url, self.plugin.repoManager) + Problems.handle(url) + Pkill.handle(url) + Plugin.handle(url, self.plugin.repoManager) + Premium.handle(url) + Terraria.handle(url) + Aytist.handle(url) + Suggestion.handle(url, self.plugin) except Exception as e: logx(f"[PackIt] Error showing notification: {e}", False) try: @@ -126,7 +126,7 @@ def setup_deeplink_hook(plugin): find_class("java.lang.Boolean").TYPE ) method.setAccessible(True) - return plugin.hook_method(method, PackItDeeplinkHook(plugin)) + return Plugin.hook_method(method, PackItDeeplinkHook(Plugin)) except Exception as e: logx(f"[PackIt] Error setting up deeplink hook: {e}", False) return None \ No newline at end of file diff --git a/packit/src/deeplinks/deeplinkMenu.py b/packit/src/python/deeplinks/DeeplinkMenu.py similarity index 100% rename from packit/src/deeplinks/deeplinkMenu.py rename to packit/src/python/deeplinks/DeeplinkMenu.py diff --git a/packit/src/deeplinks/docs.py b/packit/src/python/deeplinks/Docs.py similarity index 100% rename from packit/src/deeplinks/docs.py rename to packit/src/python/deeplinks/Docs.py diff --git a/packit/src/deeplinks/forum.py b/packit/src/python/deeplinks/Forum.py similarity index 92% rename from packit/src/deeplinks/forum.py rename to packit/src/python/deeplinks/Forum.py index 60675a7e..c0b9331f 100644 --- a/packit/src/deeplinks/forum.py +++ b/packit/src/python/deeplinks/Forum.py @@ -7,7 +7,7 @@ from org.telegram.messenger.browser import Browser except Exception as e: import android_utils as _au; _au.log(f"import org.telegram.messenger.browser import Browser failed: {e}") - from ..utils.importFailed import showImportFailedAlert as _sifa; _sifa() + from ..utils.ImportFailed import showImportFailedAlert as _sifa; _sifa() def handle(url): diff --git a/packit/src/deeplinks/install.py b/packit/src/python/deeplinks/Install.py similarity index 91% rename from packit/src/deeplinks/install.py rename to packit/src/python/deeplinks/Install.py index e5a3f05c..0dad392f 100644 --- a/packit/src/deeplinks/install.py +++ b/packit/src/python/deeplinks/Install.py @@ -2,18 +2,19 @@ # SPDX-License-Identifier: GPL-3.0-or-later from packutil import logx -from ..utils.bulletins import factory as _pbf +from ..utils import CachedRepos +from ..utils.Bulletins import factory as _pbf from ui.bulletin import BulletinHelper from client_utils import get_last_fragment, run_on_queue from android_utils import run_on_ui_thread from urllib.parse import urlparse, parse_qs -from ..core import install_plugin, install_icon_pack -from ..ui.PluginListActivity.fragment import InstallUI +from ..core.Core import install_plugin, install_icon_pack +from ..ui.plugins.Fragment import InstallUI try: from elyx import strings except Exception as e: import android_utils as _au; _au.log(f"import elyx import strings failed: {e}") - from ..utils.importFailed import showImportFailedAlert as _sifa; _sifa() + from ..utils.ImportFailed import showImportFailedAlert as _sifa; _sifa() import requests import json import os @@ -22,7 +23,7 @@ from org.telegram.messenger import ApplicationLoader except Exception as e: import android_utils as _au; _au.log(f"import org.telegram.messenger import ApplicationLoader failed: {e}") - from ..utils.importFailed import showImportFailedAlert as _sifa; _sifa() + from ..utils.ImportFailed import showImportFailedAlert as _sifa; _sifa() # install&repo=: required: repo — optional: plugin, icon, version _INSTALL_REQUIRED = {"repo"} @@ -30,11 +31,6 @@ _INSTALL_ALL = _INSTALL_REQUIRED | _INSTALL_OPTIONAL -def _getCachePath(repoId: str) -> str: - from ..utils.paths import getRepoCachePath - return getRepoCachePath(repoId) - - def _findRepo(repoManager, repoId: str) -> dict | None: try: for r in (repoManager.getRepositories() or []): @@ -46,19 +42,7 @@ def _findRepo(repoManager, repoId: str) -> dict | None: def _resolvePluginsUrl(repo: dict) -> str: - repoId = (repo.get("id") or "").strip() - fallback = (repo.get("url") or "").strip() - if not repoId: - return fallback - try: - cachePath = _getCachePath(repoId) - if os.path.exists(cachePath): - with open(cachePath, "r", encoding="utf-8") as f: - cached = json.load(f) - return cached.get("repomap", {}).get("plugins") or fallback - except Exception: - pass - return fallback + return CachedRepos.plugins_url(repo) def handle(url, repoManager): @@ -124,7 +108,7 @@ def _is_version_ok(app_ver_expr: str) -> bool: if not app_ver_expr: return True try: - from ..utils.app_version import check_app_version + from ..utils.AppVersion import check_app_version return check_app_version(app_ver_expr) except Exception: return True @@ -133,7 +117,7 @@ def _is_version_ok(app_ver_expr: str) -> bool: def _find_best_compatible(plugin: dict) -> dict | None: # returns a plugin dict with link/app_version set to best available compatible version # checks root version first (newest), then versions dict descending - from ..ui.PluginActivity.versionPicker import _build_version_entries + from ..ui.plugin.VersionPicker import _build_version_entries entries = _build_version_entries(plugin) for e in entries: if _is_version_ok(e["app_version"]): @@ -156,7 +140,7 @@ def _show_incompatible_sheet(requested_version: str, compatible_plugin: dict, al from org.telegram.ui.ActionBar import BottomSheet, Theme from org.telegram.ui.Components import LayoutHelper from org.telegram.messenger import AndroidUtilities - from ..core import install_plugin + from ..core.Core import install_plugin fragment = get_last_fragment() if not fragment: @@ -376,19 +360,7 @@ def _show_loading_bulletin(): def _resolveIconsUrl(repo: dict) -> str: - repoId = (repo.get("id") or "").strip() - fallback = (repo.get("url") or "").strip() - if not repoId: - return fallback - try: - cachePath = _getCachePath(repoId) - if os.path.exists(cachePath): - with open(cachePath, "r", encoding="utf-8") as f: - cached = json.load(f) - return cached.get("repomap", {}).get("icons") or fallback - except Exception: - pass - return fallback + return CachedRepos.icons_url(repo) def _handleInstallIconPack(repo: dict, iconId: str): diff --git a/packit/src/deeplinks/mainMenu.py b/packit/src/python/deeplinks/MainMenu.py similarity index 89% rename from packit/src/deeplinks/mainMenu.py rename to packit/src/python/deeplinks/MainMenu.py index 17c5cc46..2f637dad 100644 --- a/packit/src/deeplinks/mainMenu.py +++ b/packit/src/python/deeplinks/MainMenu.py @@ -7,7 +7,7 @@ from elyx import strings except Exception as e: import android_utils as _au; _au.log(f"import elyx import strings failed: {e}") - from ..utils.importFailed import showImportFailedAlert as _sifa; _sifa() + from ..utils.ImportFailed import showImportFailedAlert as _sifa; _sifa() def handle(url): diff --git a/packit/src/deeplinks/other.py b/packit/src/python/deeplinks/Other.py similarity index 100% rename from packit/src/deeplinks/other.py rename to packit/src/python/deeplinks/Other.py diff --git a/packit/src/deeplinks/pkill.py b/packit/src/python/deeplinks/Pkill.py similarity index 94% rename from packit/src/deeplinks/pkill.py rename to packit/src/python/deeplinks/Pkill.py index 9b21a261..f1f253cf 100644 --- a/packit/src/deeplinks/pkill.py +++ b/packit/src/python/deeplinks/Pkill.py @@ -13,7 +13,7 @@ from elyx import strings except Exception as e: import android_utils as _au; _au.log(f"import elyx import strings failed: {e}") - from ..utils.importFailed import showImportFailedAlert as _sifa; _sifa() + from ..utils.ImportFailed import showImportFailedAlert as _sifa; _sifa() def kill_process(): time.sleep(1) diff --git a/packit/src/deeplinks/plugin.py b/packit/src/python/deeplinks/Plugin.py similarity index 84% rename from packit/src/deeplinks/plugin.py rename to packit/src/python/deeplinks/Plugin.py index 175bc697..dba6e9be 100644 --- a/packit/src/deeplinks/plugin.py +++ b/packit/src/python/deeplinks/Plugin.py @@ -2,6 +2,7 @@ # SPDX-License-Identifier: GPL-3.0-or-later from packutil import logx +from ..utils import CachedRepos from ui.bulletin import BulletinHelper from client_utils import get_last_fragment, run_on_queue from android_utils import run_on_ui_thread @@ -24,25 +25,8 @@ _REQUIRED = {"plugin", "repo"} -def _getCachePath(repoId: str) -> str: - from ..utils.paths import getRepoCachePath - return getRepoCachePath(repoId) - - def _resolvePluginsUrl(repo: dict) -> str: - repoId = (repo.get("id") or "").strip() - fallback = (repo.get("url") or "").strip() - if not repoId: - return fallback - try: - cachePath = _getCachePath(repoId) - if os.path.exists(cachePath): - with open(cachePath, "r", encoding="utf-8") as f: - cached = json.load(f) - return cached.get("repomap", {}).get("plugins") or fallback - except Exception: - pass - return fallback + return CachedRepos.plugins_url(repo) def _findRepo(repoManager, repoId: str) -> dict | None: @@ -119,7 +103,7 @@ def task(): run_on_ui_thread(lambda: BulletinHelper.show_error(str(strings("dl_plugin_not_found", plugin_id=pluginId)))) return - from ..ui.PluginListActivity.fragment import InstallUI + from ..ui.plugins.Fragment import InstallUI class _FakePlugin: def __init__(self, rm): @@ -128,7 +112,7 @@ def __init__(self, rm): installUI = InstallUI(_FakePlugin(repoManager)) def _show(_p=plugin, _all=all_plugins, _rid=repoId): - from ..ui.PluginActivity.fragment import show_plugin_profile + from ..ui.plugin.Fragment import show_plugin_profile show_plugin_profile(_p, installUI, _all, repo_id=_rid) run_on_ui_thread(_show) diff --git a/packit/src/deeplinks/problems.py b/packit/src/python/deeplinks/Problems.py similarity index 92% rename from packit/src/deeplinks/problems.py rename to packit/src/python/deeplinks/Problems.py index 51ef74be..56911ab5 100644 --- a/packit/src/deeplinks/problems.py +++ b/packit/src/python/deeplinks/Problems.py @@ -7,7 +7,7 @@ from org.telegram.messenger.browser import Browser except Exception as e: import android_utils as _au; _au.log(f"import org.telegram.messenger.browser import Browser failed: {e}") - from ..utils.importFailed import showImportFailedAlert as _sifa; _sifa() + from ..utils.ImportFailed import showImportFailedAlert as _sifa; _sifa() def handle(url): diff --git a/packit/src/deeplinks/repo.py b/packit/src/python/deeplinks/Repo.py similarity index 50% rename from packit/src/deeplinks/repo.py rename to packit/src/python/deeplinks/Repo.py index 015d3b1f..3c8db2b8 100644 --- a/packit/src/deeplinks/repo.py +++ b/packit/src/python/deeplinks/Repo.py @@ -2,11 +2,11 @@ # SPDX-License-Identifier: GPL-3.0-or-later from packutil import logx -from ..utils.bulletins import factory as _pbf +from ..utils.Bulletins import factory as _pbf from ui.bulletin import BulletinHelper from client_utils import get_last_fragment, run_on_queue from android_utils import run_on_ui_thread, OnClickListener -from android.widget import LinearLayout, TextView, FrameLayout, ImageView, ScrollView +from android.widget import LinearLayout, TextView, FrameLayout, ScrollView from android.util import TypedValue from android.view import Gravity from android.graphics.drawable import GradientDrawable @@ -16,7 +16,7 @@ from elyx import strings except Exception as e: import android_utils as _au; _au.log(f"import elyx import strings failed: {e}") - from ..utils.importFailed import showImportFailedAlert as _sifa; _sifa() + from ..utils.ImportFailed import showImportFailedAlert as _sifa; _sifa() from hook_utils import find_class try: from org.telegram.messenger import R as R_tg, ApplicationLoader @@ -26,28 +26,58 @@ from org.telegram.ui.Stories.recorder import ButtonWithCounterView except Exception as e: import android_utils as _au; _au.log(f"repo deeplink: import tg classes failed: {e}") - from ..utils.importFailed import showImportFailedAlert as _sifa; _sifa() + from ..utils.ImportFailed import showImportFailedAlert as _sifa; _sifa() try: from com.exteragram.messenger.utils.text import LocaleUtils except Exception as e: import android_utils as _au; _au.log(f"repo deeplink: import LocaleUtils failed: {e}") - from ..utils.importFailed import showImportFailedAlert as _sifa; _sifa() + from ..utils.ImportFailed import showImportFailedAlert as _sifa; _sifa() from urllib.parse import urlparse, parse_qs import requests import json -import os +from ..network import Storage +from ..utils import CachedRepos BulletinFactory = find_class("org.telegram.ui.Components.BulletinFactory") -# repo=add: required: link — optional: name, icon +# repo=add: required: link — optional: name +# +# "icon" stays in the accepted set and is read nowhere. It used to name an +# R.drawable, which is not how a repository is pictured any more — the icon +# comes out of the repomap the link points at. Links minted before that are +# still in people's chats, so the argument has to be tolerated rather than +# rejected; it is simply ignored. _REPO_ADD_REQUIRED = {"link"} _REPO_ADD_OPTIONAL = {"name", "icon"} _REPO_ADD_ALL = _REPO_ADD_REQUIRED | _REPO_ADD_OPTIONAL -def _get_cache_dir() -> str: - from ..utils.paths import getReposCacheDir - return getReposCacheDir() +def _sheet_chip(act, text: str): + # the same pill the source cards use, so the sheet that adds a source and + # the card it becomes are recognisably the same thing + from ..ui.repos.Card import _chip + from ..ui.repos.RepoIcon import accent_for + return _chip(act, text, accent_for({})) + + +def _sheet_chip_lp(margin_dp=3): + from ..ui.repos.Card import _ROW_H + lp = LinearLayout.LayoutParams(-2, AndroidUtilities.dp(_ROW_H)) + lp.leftMargin = AndroidUtilities.dp(margin_dp) + lp.rightMargin = AndroidUtilities.dp(margin_dp) + return lp + + +def _balance_lines(tv): + # Android breaks a paragraph greedily by default: it fills the first line to + # the margin and drops whatever is left onto the second, which for a + # centred two-line sentence leaves one word stranded under a full line. + # BALANCED asks the line breaker to even the lines out instead. + try: + from android.text import Layout + tv.setBreakStrategy(Layout.BREAK_STRATEGY_BALANCED) + except Exception as e: + logx(f"repo deeplink: balanced break unavailable: {e}", True) def handle(url, repoManager): @@ -69,7 +99,6 @@ def handle(url, repoManager): name = query.get("name", [""])[0].strip() link = query.get("link", [""])[0].strip() - icon = query.get("icon", [""])[0].strip() if not link: BulletinHelper.show_error(strings.repo_add_invalid) @@ -95,47 +124,32 @@ def fetch_task(): repometa = None pluginCount = 0 try: - response = requests.get(link, timeout=10) - if response.status_code == 200: - data = response.json() + data, error = Storage.fetch_repomap(link) + if error: + logx(f"repo deeplink: {error} for '{link}'", True) + else: repometa = data.get("repometa") - - if repometa and repometa.get("rm_rid"): - try: - cache_dir = _get_cache_dir() - os.makedirs(cache_dir, exist_ok=True) - cache_path = os.path.join(cache_dir, f"{repometa['rm_rid']}.json") - with open(cache_path, "w", encoding="utf-8") as f: - json.dump(data, f, indent=2, ensure_ascii=False) - except Exception as e: - logx(f"repo deeplink: cache error: {e}", False) - - repomap = data.get("repomap", {}) - plugins_url = repomap.get("plugins") if repomap else None - if plugins_url: - try: - pr = requests.get(plugins_url, timeout=10) - if pr.status_code == 200: - pdata = pr.json() - plugins = pdata.get("plugins", []) - pluginCount = len(plugins) if isinstance(plugins, (list, dict)) else 0 - except Exception as e: - logx(f"repo deeplink: plugins count error: {e}", False) + # cached now, so the sheet's avatar and everything the + # source screen shows are there the moment it is added + CachedRepos.write(repometa.get("rm_rid"), data) + + plugins_url = CachedRepos.plugins_url(repometa.get("rm_rid"), link) + entries, list_error = Storage.fetch_plugins(plugins_url) + if list_error: + logx(f"repo deeplink: plugin count unavailable: {list_error}", True) else: - plugins = data.get("plugins", []) - if isinstance(plugins, (list, dict)): - pluginCount = len(plugins) + pluginCount = len(entries) except Exception as e: logx(f"repo deeplink: fetch error: {e}", False) - run_on_ui_thread(lambda: _show_confirm_sheet(repometa, pluginCount, name, link, icon, repoManager)) + run_on_ui_thread(lambda: _show_confirm_sheet(repometa, pluginCount, name, link, repoManager)) run_on_queue(fetch_task) except Exception as e: logx(f"repo deeplink: handle error: {e}", False) -def _show_confirm_sheet(repometa, pluginCount, name, link, icon, repoManager): +def _show_confirm_sheet(repometa, pluginCount, name, link, repoManager): try: frag = get_last_fragment() act = frag.getParentActivity() if frag else None @@ -146,11 +160,16 @@ def _show_confirm_sheet(repometa, pluginCount, name, link, icon, repoManager): BulletinHelper.show_error(str(strings["dl_repo_no_metadata"])) return + rm_rid = str(repometa.get("rm_rid") or "") + rm_name = str(repometa.get("rm_name") or name or "") rm_url = str(repometa.get("rm_url") or link) - rm_url_display = rm_url.removeprefix("https://").removeprefix("http://") + rm_url_display = rm_url.removeprefix("https://").removeprefix("http://").rstrip("/") rm_maintainer = str(repometa.get("rm_maintainer") or name) - rm_icon = icon if icon else str(repometa.get("rm_icon") or "msg_folders") - disclaimer_text = strings("repo_add_disclaimer", rm_url_display, rm_maintainer, pluginCount) + # only ever the repomap's own picture — the link's icon argument named an + # R.drawable, and a repository is not a glyph out of the client's sheet + rm_icon = str(repometa.get("rm_icon") or "").strip() + if not rm_icon.lower().startswith(("http://", "https://")): + rm_icon = "" sheet = BottomSheet(act, False, frag.getResourceProvider()) sheet.fixNavigationBar() @@ -160,44 +179,93 @@ def _show_confirm_sheet(repometa, pluginCount, name, link, icon, repoManager): linear.setOrientation(LinearLayout.VERTICAL) frame.addView(linear) - # icon centered + # The source itself, drawn the way the sources screen draws it: the + # repomap's picture, falling back to a monogram on a tonal square. What + # used to be here was a folder glyph tinted with the accent — the same + # picture for every repository in existence, which told the reader + # nothing about the one they were about to add. try: - icon_view = ImageView(act) - icon_id = getattr(R_tg.drawable, rm_icon, 0) - if not icon_id: - icon_id = getattr(R_tg.drawable, "msg_folders", 0) - if icon_id: - icon_view.setImageResource(icon_id) - icon_view.setColorFilter(Theme.getColor(Theme.key_featuredStickers_addButton)) - linear.addView(icon_view, LayoutHelper.createLinear(48, 48, Gravity.CENTER_HORIZONTAL, 0, 20, 0, 0)) + from ..ui.repos.RepoIcon import build_icon_view + icon_view = build_icon_view( + act, {"id": rm_rid, "name": rm_name, "url": link}, 76, 22, rm_icon) + linear.addView(icon_view, LayoutHelper.createLinear( + 76, 76, Gravity.CENTER_HORIZONTAL, 0, 22, 0, 0)) except Exception as e: logx(f"repo deeplink: icon error: {e}", False) - # title + # the repository's name, not "Add repository?" — the question is what + # the buttons are for, and the name is the thing being decided about title_tv = TextView(act) title_tv.setGravity(Gravity.CENTER_HORIZONTAL) - title_tv.setTextSize(TypedValue.COMPLEX_UNIT_DIP, 20) + # SP, not DIP: everything written on this sheet is prose, and prose is + # what the system font scale is for. The pills keep DIP — their height + # is fixed, so a label that grew would sit in a box that did not. + title_tv.setTextSize(TypedValue.COMPLEX_UNIT_SP, 23) + title_tv.setSingleLine(True) + try: + from android.text import TextUtils as _TextUtils + title_tv.setEllipsize(_TextUtils.TruncateAt.END) + except Exception: + pass try: title_tv.setTypeface(AndroidUtilities.getTypeface("fonts/rmedium.ttf")) except Exception: title_tv.setTypeface(AndroidUtilities.bold()) - title_tv.setText(strings.repo_add_title) + title_tv.setText(rm_name or str(strings.repo_add_title)) title_tv.setTextColor(sheet.getThemedColor(Theme.key_windowBackgroundWhiteBlackText)) - linear.addView(title_tv, LayoutHelper.createFrame(-1, -2, 0, 21.0, 16.0, 21.0, 0.0)) + linear.addView(title_tv, LayoutHelper.createFrame(-1, -2, 0, 21.0, 14.0, 21.0, 0.0)) + + # Maintainer, with the mention live. Medium weight and a size up on the + # disclaimer: both are centred grey paragraphs a few dp apart, and at + # the same weight the eye read them as one block of small print instead + # of as a subtitle belonging to the name above it. + if rm_maintainer: + sub_tv = TextView(act) + sub_tv.setGravity(Gravity.CENTER_HORIZONTAL) + sub_tv.setTextSize(TypedValue.COMPLEX_UNIT_SP, 15) + sub_tv.setTextColor(sheet.getThemedColor(Theme.key_windowBackgroundWhiteGrayText)) + _balance_lines(sub_tv) + try: + sub_tv.setTypeface(AndroidUtilities.getTypeface("fonts/rmedium.ttf")) + except Exception: + pass + try: + sub_tv.setText(LocaleUtils.fullyFormatText(rm_maintainer)) + sub_tv.setLinkTextColor(Theme.getColor(Theme.key_windowBackgroundWhiteBlueText)) + sub_tv.setMovementMethod(LinkMovementMethod.getInstance()) + except Exception: + sub_tv.setText(rm_maintainer) + linear.addView(sub_tv, LayoutHelper.createFrame(-1, -2, 0, 21.0, 5.0, 21.0, 0.0)) + + # the facts as pills rather than as a sentence: how much is in there and + # where it comes from + try: + chips_row = LinearLayout(act) + chips_row.setOrientation(LinearLayout.HORIZONTAL) + chips_row.setGravity(Gravity.CENTER) + if pluginCount: + chips_row.addView( + _sheet_chip(act, str(strings.repo_card_plugins).replace("{0}", str(pluginCount))), + _sheet_chip_lp(3)) + if rm_url_display: + chips_row.addView(_sheet_chip(act, rm_url_display), _sheet_chip_lp(3)) + if chips_row.getChildCount(): + linear.addView(chips_row, LayoutHelper.createFrame(-1, -2, 0, 16.0, 14.0, 16.0, 0.0)) + except Exception as e: + logx(f"repo deeplink: chips error: {e}", False) - # disclaimer with accent links + # What is left of the disclaimer once the concrete facts are drawn + # above. It keeps the plain weight and stays the smallest thing here — + # that is what tells it apart from the subtitle — but it gains a dp and + # a wider gap above, because it was small enough to skip over. msg_tv = TextView(act) msg_tv.setGravity(Gravity.CENTER_HORIZONTAL) - msg_tv.setTextSize(TypedValue.COMPLEX_UNIT_DIP, 14) - try: - msg_tv.setText(LocaleUtils.fullyFormatText(disclaimer_text)) - msg_tv.setLinkTextColor(Theme.getColor(Theme.key_windowBackgroundWhiteBlueText)) - msg_tv.setMovementMethod(LinkMovementMethod.getInstance()) - except Exception: - msg_tv.setText(disclaimer_text) + msg_tv.setTextSize(TypedValue.COMPLEX_UNIT_SP, 14) + msg_tv.setText(str(strings["repo_add_disclaimer_short"])) msg_tv.setTextColor(sheet.getThemedColor(Theme.key_windowBackgroundWhiteGrayText)) - msg_tv.setLineSpacing(AndroidUtilities.dp(2), 1.0) - linear.addView(msg_tv, LayoutHelper.createFrame(-1, -2, 0, 21.0, 12.0, 21.0, 0.0)) + msg_tv.setLineSpacing(AndroidUtilities.dp(3), 1.0) + _balance_lines(msg_tv) + linear.addView(msg_tv, LayoutHelper.createFrame(-1, -2, 0, 24.0, 18.0, 24.0, 0.0)) # add button add_btn = ButtonWithCounterView(act, True, frag.getResourceProvider()) @@ -218,19 +286,20 @@ def onClick(self, v): sheet.dismiss() return + # no "icon": it held an R.drawable name and nothing reads + # one any more — the picture comes from the repomap newRepo = { "id": rm_rid, "name": repo_name, "url": link, "enabled": True, "collapsed": False, - "icon": icon if icon else "msg_folders" } currentRepos.append(newRepo) repoManager.setRepositories(currentRepos) BulletinHelper.show_success(strings.repo_add_success) try: - from ..ui.AchievementsActivity.service.AchivementsEngine import increment_category + from ..ui.achievements.service.AchivementsEngine import increment_category increment_category("Repositories") except Exception as e: logx(f"repo deeplink: achievements increment error: {e}", False) diff --git a/packit/src/deeplinks/settings.py b/packit/src/python/deeplinks/Settings.py similarity index 90% rename from packit/src/deeplinks/settings.py rename to packit/src/python/deeplinks/Settings.py index 27fe0796..d49906c8 100644 --- a/packit/src/deeplinks/settings.py +++ b/packit/src/python/deeplinks/Settings.py @@ -7,12 +7,12 @@ from com.exteragram.messenger.plugins import PluginsController except Exception as e: import android_utils as _au; _au.log(f"import com.exteragram.messenger.plugins import PluginsController failed: {e}") - from ..utils.importFailed import showImportFailedAlert as _sifa; _sifa() + from ..utils.ImportFailed import showImportFailedAlert as _sifa; _sifa() try: from com.exteragram.messenger.plugins.ui import PluginSettingsActivity except Exception as e: import android_utils as _au; _au.log(f"import com.exteragram.messenger.plugins.ui import PluginSettingsActivity failed: {e}") - from ..utils.importFailed import showImportFailedAlert as _sifa; _sifa() + from ..utils.ImportFailed import showImportFailedAlert as _sifa; _sifa() def handle(url, plugin): diff --git a/packit/src/deeplinks/suggestion.py b/packit/src/python/deeplinks/Suggestion.py similarity index 75% rename from packit/src/deeplinks/suggestion.py rename to packit/src/python/deeplinks/Suggestion.py index ce0b550d..e56f0451 100644 --- a/packit/src/deeplinks/suggestion.py +++ b/packit/src/python/deeplinks/Suggestion.py @@ -2,6 +2,7 @@ # SPDX-License-Identifier: GPL-3.0-or-later from packutil import logx +from ..utils import CachedRepos from urllib.parse import urlparse, parse_qs from android_utils import run_on_ui_thread from client_utils import get_last_fragment @@ -14,21 +15,8 @@ import os -def _get_cache_path(rm_rid: str) -> str: - from ..utils.paths import getRepoCachePath - return getRepoCachePath(rm_rid) - - -def _load_repomap(rm_rid: str) -> dict | None: - path = _get_cache_path(rm_rid) - if not os.path.exists(path): - return None - try: - with open(path, "r", encoding="utf-8") as f: - return json.load(f) - except Exception as e: - logx(f"suggestion deeplink: _load_repomap error: {e}", False) - return None +def _load_repomap(rm_rid: str): + return CachedRepos.read(rm_rid) def _has_required_fields(data: dict) -> bool: @@ -69,7 +57,7 @@ def handle(url: str, plugin=None): def _open_fragment(data: dict, plugin=None): try: - from ..ui.suggest.fragment import show_suggest_fragment + from ..ui.suggest.Fragment import show_suggest_fragment show_suggest_fragment(data, plugin) except Exception as e: logx(f"suggestion deeplink: _open_fragment error: {e}", False) \ No newline at end of file diff --git a/packit/src/deeplinks/update.py b/packit/src/python/deeplinks/Update.py similarity index 72% rename from packit/src/deeplinks/update.py rename to packit/src/python/deeplinks/Update.py index 452616a2..3453a4fd 100644 --- a/packit/src/deeplinks/update.py +++ b/packit/src/python/deeplinks/Update.py @@ -2,6 +2,8 @@ # SPDX-License-Identifier: GPL-3.0-or-later from packutil import logx +from ..network import Storage +from ..utils import CachedRepos from ui.bulletin import BulletinHelper from client_utils import get_last_fragment, run_on_queue from android_utils import run_on_ui_thread @@ -9,29 +11,22 @@ from org.telegram.messenger import ApplicationLoader except Exception as e: import android_utils as _au; _au.log(f"import org.telegram.messenger import ApplicationLoader failed: {e}") - from ..utils.importFailed import showImportFailedAlert as _sifa; _sifa() + from ..utils.ImportFailed import showImportFailedAlert as _sifa; _sifa() try: from elyx import strings except Exception as e: import android_utils as _au; _au.log(f"import elyx import strings failed: {e}") - from ..utils.importFailed import showImportFailedAlert as _sifa; _sifa() + from ..utils.ImportFailed import showImportFailedAlert as _sifa; _sifa() from urllib.parse import urlparse, parse_qs import requests import json import os -def _get_cache_dir() -> str: - from ..utils.paths import getCacheRoot - return getCacheRoot() - - def _run_update(repoManager): def task(): try: repos = repoManager.getRepositories() - cacheDir = _get_cache_dir() - os.makedirs(cacheDir, exist_ok=True) changed = False toRemove = [] seenRids = set() @@ -41,19 +36,17 @@ def task(): if not url: continue try: - r = requests.get(url, timeout=10) - if r.status_code != 200: - logx(f"update deeplink: HTTP {r.status_code} for {url}", True) - continue - data = r.json() - repometa = data.get("repometa") - rmRid = repometa.get("rm_rid") if repometa else None - - if not repometa or not rmRid: - logx(f"update deeplink: no repometa for '{url}', removing repo", True) + data, error = Storage.fetch_repomap(url) + if error in ("missing repometa", "missing rm_rid"): + logx(f"update deeplink: '{url}' is not a repomap ({error}), removing", True) toRemove.append(i) changed = True continue + if error: + logx(f"update deeplink: {error} for {url}", True) + continue + repometa = data.get("repometa") + rmRid = repometa.get("rm_rid") if rmRid in seenRids: logx(f"update deeplink: duplicate rm_rid='{rmRid}', removing repo", True) @@ -67,9 +60,7 @@ def task(): changed = True logx(f"update deeplink: set id='{rmRid}' for repo '{repo.get('name')}'", True) - cachePath = os.path.join(cacheDir, f"{rmRid}.json") - with open(cachePath, "w", encoding="utf-8") as f: - json.dump(data, f, indent=2, ensure_ascii=False) + CachedRepos.write(rmRid, data) logx(f"update deeplink: updated cache for '{rmRid}'", True) except Exception as e: logx(f"update deeplink: error for {url}: {e}", False) @@ -91,8 +82,6 @@ def _run_update_single(repoManager, repoId: str): def task(): try: repos = repoManager.getRepositories() - cacheDir = _get_cache_dir() - os.makedirs(cacheDir, exist_ok=True) target = next((r for r in repos if r.get("id") == repoId), None) if not target: @@ -105,23 +94,18 @@ def task(): return try: - r = requests.get(url, timeout=10) - if r.status_code != 200: - logx(f"update deeplink: HTTP {r.status_code} for {url}", True) - run_on_ui_thread(lambda: BulletinHelper.show_error(str(strings("dl_update_repo_http_error", code=r.status_code)))) - return - data = r.json() - repometa = data.get("repometa") - rmRid = repometa.get("rm_rid") if repometa else None - - if not repometa or not rmRid: + data, error = Storage.fetch_repomap(url) + if error in ("missing repometa", "missing rm_rid"): logx(f"update deeplink: no repometa for '{url}'", True) run_on_ui_thread(lambda: BulletinHelper.show_error(str(strings["dl_update_repo_no_meta"]))) return - - cachePath = os.path.join(cacheDir, f"{rmRid}.json") - with open(cachePath, "w", encoding="utf-8") as f: - json.dump(data, f, indent=2, ensure_ascii=False) + if error: + logx(f"update deeplink: {error} for {url}", True) + run_on_ui_thread(lambda e=error: BulletinHelper.show_error(str(strings("dl_update_repo_http_error", code=e)))) + return + repometa = data.get("repometa") + rmRid = repometa.get("rm_rid") + CachedRepos.write(rmRid, data) logx(f"update deeplink: updated cache for '{rmRid}'", True) idx = next((i for i, rp in enumerate(repos) if rp.get("id") == repoId), None) diff --git a/packit/src/deeplinks/__init__.py b/packit/src/python/deeplinks/__init__.py similarity index 72% rename from packit/src/deeplinks/__init__.py rename to packit/src/python/deeplinks/__init__.py index 434a6c19..a96dbae4 100644 --- a/packit/src/deeplinks/__init__.py +++ b/packit/src/python/deeplinks/__init__.py @@ -1,6 +1,6 @@ # pyright: reportMissingImports=false # SPDX-License-Identifier: GPL-3.0-or-later -from .deepHandler import setup_deeplink_hook +from .DeepHandler import setup_deeplink_hook __all__ = ['setup_deeplink_hook'] \ No newline at end of file diff --git a/packit/src/deeplinks/secret/aytist.py b/packit/src/python/deeplinks/secret/Aytist.py similarity index 97% rename from packit/src/deeplinks/secret/aytist.py rename to packit/src/python/deeplinks/secret/Aytist.py index 3b4c521f..82fae175 100644 --- a/packit/src/deeplinks/secret/aytist.py +++ b/packit/src/python/deeplinks/secret/Aytist.py @@ -22,7 +22,7 @@ def handle(url): if url != "tg://packit?aytist": return try: - from ...ui.AchievementsActivity.service.AchivementsEngine import unlock_secret + from ...ui.achievements.service.AchivementsEngine import unlock_secret unlock_secret("aytist") run_on_ui_thread(_startSpawnChain) except Exception as e: diff --git a/packit/src/deeplinks/secret/premium.py b/packit/src/python/deeplinks/secret/Premium.py similarity index 92% rename from packit/src/deeplinks/secret/premium.py rename to packit/src/python/deeplinks/secret/Premium.py index a62337b6..6930fafd 100644 --- a/packit/src/deeplinks/secret/premium.py +++ b/packit/src/python/deeplinks/secret/Premium.py @@ -9,7 +9,7 @@ from org.telegram.messenger import ApplicationLoader except Exception as e: import android_utils as _au; _au.log(f"import org.telegram.messenger import ApplicationLoader failed: {e}") - from ...utils.importFailed import showImportFailedAlert as _sifa; _sifa() + from ...utils.ImportFailed import showImportFailedAlert as _sifa; _sifa() def handle(url): @@ -17,7 +17,7 @@ def handle(url): return try: _playMaxVolume() - from ...ui.AchievementsActivity.service.AchivementsEngine import unlock_secret + from ...ui.achievements.service.AchivementsEngine import unlock_secret unlock_secret("premium") except Exception as e: logx(f"deeplinks.premium: error: {e}", False) diff --git a/packit/src/deeplinks/secret/terraria.py b/packit/src/python/deeplinks/secret/Terraria.py similarity index 92% rename from packit/src/deeplinks/secret/terraria.py rename to packit/src/python/deeplinks/secret/Terraria.py index d0180f41..9f99dd56 100644 --- a/packit/src/deeplinks/secret/terraria.py +++ b/packit/src/python/deeplinks/secret/Terraria.py @@ -9,7 +9,7 @@ from org.telegram.messenger import ApplicationLoader except Exception as e: import android_utils as _au; _au.log(f"import org.telegram.messenger import ApplicationLoader failed: {e}") - from ...utils.importFailed import showImportFailedAlert as _sifa; _sifa() + from ...utils.ImportFailed import showImportFailedAlert as _sifa; _sifa() def handle(url): @@ -18,7 +18,7 @@ def handle(url): try: _playMaxVolume() logx(f"deeplinks.terraria: calling unlock_secret", True) - from ...ui.AchievementsActivity.service.AchivementsEngine import unlock_secret + from ...ui.achievements.service.AchivementsEngine import unlock_secret unlock_secret("terraria") logx(f"deeplinks.terraria: unlock_secret returned", True) except Exception as e: diff --git a/packit/src/ChatActivity/__init__.py b/packit/src/python/deeplinks/secret/__init__.py similarity index 100% rename from packit/src/ChatActivity/__init__.py rename to packit/src/python/deeplinks/secret/__init__.py diff --git a/packit/src/python/integrations/__init__.py b/packit/src/python/integrations/__init__.py new file mode 100644 index 00000000..67d022fd --- /dev/null +++ b/packit/src/python/integrations/__init__.py @@ -0,0 +1,5 @@ +# pyright: reportMissingImports=false +# SPDX-License-Identifier: GPL-3.0-or-later + +# Everything that reaches into a screen the client owns — the chat, the chat +# list, the profile, the client's own settings — rather than a screen of ours. diff --git a/packit/src/ChatActivity/afpFile.py b/packit/src/python/integrations/chat/AfpFile.py similarity index 98% rename from packit/src/ChatActivity/afpFile.py rename to packit/src/python/integrations/chat/AfpFile.py index 5c0632d9..53fbc6d1 100644 --- a/packit/src/ChatActivity/afpFile.py +++ b/packit/src/python/integrations/chat/AfpFile.py @@ -29,9 +29,9 @@ def before_hooked_method(self, param): logx(f"afpFile: before_hooked_method error: {e}", False) def _read(self, file_path: str, filename: str): - from ..scl.scl import parse - from ..scl.opts import ParseOpts - from ..utils.paths import getTempDir + from ...scl.Scl import parse + from ...scl.Opts import ParseOpts + from ...utils.Paths import getTempDir import os import shutil import time diff --git a/packit/src/ChatActivity/ConfirmImportBottomSheet.py b/packit/src/python/integrations/chat/ConfirmImportBottomSheet.py similarity index 99% rename from packit/src/ChatActivity/ConfirmImportBottomSheet.py rename to packit/src/python/integrations/chat/ConfirmImportBottomSheet.py index 974fbcde..ddd30232 100644 --- a/packit/src/ChatActivity/ConfirmImportBottomSheet.py +++ b/packit/src/python/integrations/chat/ConfirmImportBottomSheet.py @@ -314,8 +314,8 @@ def __init__(self): super().__init__() def onClick(self, v): import threading import zipfile - from ..core import onlyLocalInstallNoUi - from ..utils.paths import getTempDir + from ...core.Core import onlyLocalInstallNoUi + from ...utils.Paths import getTempDir from ui.bulletin import BulletinHelper from org.telegram.messenger import R as R_tg @@ -601,7 +601,7 @@ def _show(): def _run_installs(): try: import re as _re - from ..utils.app_version import _parse_version, _get_app_version + from ...utils.AppVersion import _parse_version, _get_app_version tmp_dir = getTempDir() os.makedirs(tmp_dir, exist_ok=True) with zipfile.ZipFile(file_path, "r") as zf: diff --git a/packit/src/ChatActivity/ImportBottomSheet.py b/packit/src/python/integrations/chat/ImportBottomSheet.py similarity index 99% rename from packit/src/ChatActivity/ImportBottomSheet.py rename to packit/src/python/integrations/chat/ImportBottomSheet.py index 6c073a5a..73c04925 100644 --- a/packit/src/ChatActivity/ImportBottomSheet.py +++ b/packit/src/python/integrations/chat/ImportBottomSheet.py @@ -26,7 +26,7 @@ def _make_icon_view(activity, icon_str: str, size_dp: int): iv.getImageReceiver().setCrossfadeWithOldImage(True) except Exception: pass - from ..utils.stickers import load_sticker + from ...utils.Stickers import load_sticker load_sticker(iv, icon_str, size_dp) return iv except Exception as e: diff --git a/packit/src/ChatActivity/export/__init__.py b/packit/src/python/integrations/chat/__init__.py similarity index 100% rename from packit/src/ChatActivity/export/__init__.py rename to packit/src/python/integrations/chat/__init__.py diff --git a/packit/src/ChatActivity/export/DecryptorBottomSheet.py b/packit/src/python/integrations/chat/export/DecryptorBottomSheet.py similarity index 90% rename from packit/src/ChatActivity/export/DecryptorBottomSheet.py rename to packit/src/python/integrations/chat/export/DecryptorBottomSheet.py index f764f313..aa85d860 100644 --- a/packit/src/ChatActivity/export/DecryptorBottomSheet.py +++ b/packit/src/python/integrations/chat/export/DecryptorBottomSheet.py @@ -37,8 +37,8 @@ def before_hooked_method(self, param): def _prepare_and_show(self, file_path: str): try: - from ...ChatActivity.export.bin.writer import _get_user_id, _get_install_ts - from ...ChatActivity.export.bin.reader import read_file + from .bin.Writer import _get_user_id, _get_install_ts + from .bin.Reader import read_file from elyx import strings current_user_id = _get_user_id() @@ -54,14 +54,14 @@ def _prepare_and_show(self, file_path: str): import_xp = None if "achievements" in blocks: try: - from ...ui.AchievementsActivity.service.AchivementsEngine import get_level_info + from ....ui.achievements.service.AchivementsEngine import get_level_info achievements_data = json.loads(blocks["achievements"]) def _is_hashed_id(k: str) -> bool: return len(k) == 16 and all(c in "0123456789abcdef" for c in k) if isinstance(achievements_data, dict) and achievements_data and all(_is_hashed_id(k) for k in achievements_data): - from ...ui.AchievementsActivity.service.AchivementsEngine import _hash_account_id + from ....ui.achievements.service.AchivementsEngine import _hash_account_id account_data = achievements_data.get(_hash_account_id(export_user_id), {}) elif isinstance(achievements_data, dict): account_data = achievements_data @@ -76,7 +76,7 @@ def _is_hashed_id(k: str) -> bool: from .ImportBottomSheet import show_import_bottom_sheet def on_confirm(): - from ...ui.AchievementsActivity.service.AchivementsEngine import _hash_account_id + from ....ui.achievements.service.AchivementsEngine import _hash_account_id account_id = _hash_account_id(export_user_id) threading.Thread(target=self._restore, args=(blocks, account_id), daemon=True).start() @@ -92,7 +92,7 @@ def show(): def _restore(self, blocks: dict, account_id: str): try: - from ...ChatActivity.export.bin.reader import _write_blocks + from .bin.Reader import _write_blocks from client_utils import get_last_fragment from ui.bulletin import BulletinHelper from org.telegram.messenger import R diff --git a/packit/src/ChatActivity/export/ImportBottomSheet.py b/packit/src/python/integrations/chat/export/ImportBottomSheet.py similarity index 97% rename from packit/src/ChatActivity/export/ImportBottomSheet.py rename to packit/src/python/integrations/chat/export/ImportBottomSheet.py index c1698472..3f8ad940 100644 --- a/packit/src/ChatActivity/export/ImportBottomSheet.py +++ b/packit/src/python/integrations/chat/export/ImportBottomSheet.py @@ -41,7 +41,7 @@ def show_import_bottom_sheet(fragment, num_blocks: int, on_confirm, import_level level = import_level xp_into = import_xp else: - from ...ui.AchievementsActivity.service.AchivementsEngine import get_level_info, _load_account + from ....ui.achievements.service.AchivementsEngine import get_level_info, _load_account data, _ = _load_account() level, xp_into, _ = get_level_info(data) diff --git a/packit/src/ChatActivity/export/bin/__init__.py b/packit/src/python/integrations/chat/export/__init__.py similarity index 100% rename from packit/src/ChatActivity/export/bin/__init__.py rename to packit/src/python/integrations/chat/export/__init__.py diff --git a/packit/src/ChatActivity/export/bin/reader.py b/packit/src/python/integrations/chat/export/bin/Reader.py similarity index 95% rename from packit/src/ChatActivity/export/bin/reader.py rename to packit/src/python/integrations/chat/export/bin/Reader.py index e10979c2..c045de3e 100644 --- a/packit/src/ChatActivity/export/bin/reader.py +++ b/packit/src/python/integrations/chat/export/bin/Reader.py @@ -6,7 +6,7 @@ import os import ctypes -from .writer import _get_configs_dir, _get_lib, _FILE_NAMES +from .Writer import _get_configs_dir, _get_lib, _FILE_NAMES def _write_blocks(blocks: dict, account_id: str): @@ -52,7 +52,7 @@ def _is_hashed_id(k: str) -> bool: account_data = account_data["d"] depth += 1 - from ....ui.AchievementsActivity.service.AchivementsEngine import load_account_data_for_import + from .....ui.achievements.service.AchivementsEngine import load_account_data_for_import load_account_data_for_import(account_id, account_data) logx(f"exportBin: merged achievements for account {account_id}", True) diff --git a/packit/src/ChatActivity/export/bin/writer.py b/packit/src/python/integrations/chat/export/bin/Writer.py similarity index 95% rename from packit/src/ChatActivity/export/bin/writer.py rename to packit/src/python/integrations/chat/export/bin/Writer.py index 8a7ca6b3..673960ca 100644 --- a/packit/src/ChatActivity/export/bin/writer.py +++ b/packit/src/python/integrations/chat/export/bin/Writer.py @@ -25,7 +25,7 @@ def _get_configs_dir() -> str: - from ....utils.paths import getConfigsDir + from .....utils.Paths import getConfigsDir return getConfigsDir() @@ -55,13 +55,13 @@ def _get_install_ts() -> int: def _get_lib(): - from ....nativeLoader import loadExport + from .....core.NativeLoader import loadExport return loadExport() def _read_achievements_block() -> str: try: - from ....ui.AchievementsActivity.service.AchivementsEngine import ( + from .....ui.achievements.service.AchivementsEngine import ( _load_account, _get_current_account_id ) account_id = _get_current_account_id() @@ -76,7 +76,7 @@ def _read_achievements_block() -> str: def _read_saved_plugins_block() -> str: try: - from ....ui.PluginActivity.fragment import _read_saved_plugins + from .....ui.plugin.Fragment import _read_saved_plugins data = _read_saved_plugins() content = json.dumps(data, ensure_ascii=False) logx(f"exportBin: saved_plugins block read ({len(data)} items)", True) diff --git a/packit/src/DialogsActivity/__init__.py b/packit/src/python/integrations/chat/export/bin/__init__.py similarity index 100% rename from packit/src/DialogsActivity/__init__.py rename to packit/src/python/integrations/chat/export/bin/__init__.py diff --git a/packit/src/ChatActivity/inline/enterView.py b/packit/src/python/integrations/chat/inline/EnterView.py similarity index 92% rename from packit/src/ChatActivity/inline/enterView.py rename to packit/src/python/integrations/chat/inline/EnterView.py index 3511dd75..d240c7ce 100644 --- a/packit/src/ChatActivity/inline/enterView.py +++ b/packit/src/python/integrations/chat/inline/EnterView.py @@ -5,7 +5,6 @@ import os import json import weakref -import requests from base_plugin import MethodHook from client_utils import get_last_fragment, run_on_queue from hook_utils import find_class, get_private_field @@ -108,7 +107,7 @@ def _flag_match(plugin, flags): # app_version: each expression must pass check_app_version if "app_version" in flags: - from ...utils.app_version import check_app_version + from ....utils.AppVersion import check_app_version for expr in flags["app_version"]: if not check_app_version(expr): return False @@ -116,14 +115,6 @@ def _flag_match(plugin, flags): return True -def _get_cache_dir(): - from ...utils.paths import getReposCacheDir - return getReposCacheDir() - -def _get_repo_cache_path(repo_id): - from ...utils.paths import getRepoCachePath - return getRepoCachePath(repo_id) - class _PackitAutocompleteHook(MethodHook): def __init__(self, plugin): MethodHook.__init__(self) @@ -135,8 +126,8 @@ def plugin(self): def after_hooked_method(self, param): try: - from . import inlineState - if not inlineState.get_state(): + from . import InlineState + if not InlineState.get_state(): return plugin = self.plugin if not plugin: @@ -193,8 +184,8 @@ def onTextChanged(self, s, start, before, count): pass def afterTextChanged(self, editable): try: - from . import inlineState - if not inlineState.get_state(): + from . import InlineState + if not InlineState.get_state(): return except Exception: pass @@ -250,62 +241,27 @@ def do_search(): def _packit_load_plugins_from_cache(self): + from ....network import Storage + from ....utils import CachedRepos plugins_list = [] try: - cache_dir = _get_cache_dir() - if not os.path.exists(cache_dir): - return plugins_list - - repos = self.repoManager.getRepositories() - for repo in repos: + for repo in self.repoManager.getRepositories(): repo_id = repo.get("id") if not repo_id: continue - - cache_path = _get_repo_cache_path(repo_id) - if not os.path.exists(cache_path): + plugins_url = CachedRepos.plugins_url(repo) + if not plugins_url: continue - - try: - with open(cache_path, "r", encoding="utf-8") as f: - cached = json.load(f) - - plugins_url = cached.get("repomap", {}).get("plugins") - if not plugins_url: - continue - - try: - r = requests.get(plugins_url, timeout=10) - if r.status_code != 200: - continue - config = r.json() - plugins_raw = config.get("plugins", {}) - - if isinstance(plugins_raw, dict): - for pid, info in plugins_raw.items(): - if isinstance(info, dict): - plugins_list.append({ - "id": pid, - "repo_id": repo_id, - "repo_name": repo.get("name", "Unknown"), - **info - }) - elif isinstance(plugins_raw, list): - for item in plugins_raw: - if isinstance(item, dict) and item.get("id"): - plugins_list.append({ - "id": item.get("id"), - "repo_id": repo_id, - "repo_name": repo.get("name", "Unknown"), - **item - }) - except Exception as e: - logx(f"Packit load plugins from url error: {e}", False) - except Exception as e: - logx(f"Packit load repo cache error for {repo_id}: {e}", False) + entries, error = Storage.fetch_plugins(plugins_url) + if error: + logx(f"Packit autocomplete: repo '{repo_id}': {error}", True) + continue + repo_name = repo.get("name", "Unknown") + for entry in entries: + plugins_list.append({"repo_id": repo_id, "repo_name": repo_name, **entry}) except Exception as e: logx(f"Packit load plugins from cache error: {e}", False) - + return plugins_list @@ -340,7 +296,7 @@ def _packit_search_in_background(self, search_key, token): run_on_ui_thread(lambda: self._packit_show_plugins_popup(result)) return - from ...utils.search import build_index, score as search_score + from ....utils.Search import build_index, score as search_score index = build_index(candidates) @@ -607,8 +563,8 @@ def onItemClick(self, view, position): def open_profile(): try: - from ...ui.PluginListActivity.fragment import InstallUI - from ...ui.PluginActivity.fragment import show_plugin_profile + from ....ui.plugins.Fragment import InstallUI + from ....ui.plugin.Fragment import show_plugin_profile class _FakePlugin: def __init__(self, rm): @@ -683,8 +639,8 @@ def _packit_hook_container_dismiss(self, bot_container): class DismissHook(MethodHook): def before_hooked_method(self_hook, param): try: - from . import inlineState - if not inlineState.get_state(): + from . import InlineState + if not InlineState.get_state(): return except Exception: pass @@ -729,7 +685,7 @@ def _u16len(text) -> int: def _strip_markdown(text) -> str: # plain text for places that cannot render entities (the autocomplete popup # binds its rows to java Strings), so markers don't show up raw - from ...utils.markdown import to_plain + from ....utils.Markdown import to_plain return to_plain(text) @@ -833,7 +789,7 @@ def _packit_send_plugin_info(self, plugin_data): output_type = getattr(self, "_packit_output_type", None) - from .messageBuilder import build_plugin_message + from .MessageBuilder import build_plugin_message message_text, entities = build_plugin_message( name, version, author, plugin_id, repo_id, description, output_type=output_type, diff --git a/packit/src/ChatActivity/inline/inlineBtns.py b/packit/src/python/integrations/chat/inline/InlineBtns.py similarity index 98% rename from packit/src/ChatActivity/inline/inlineBtns.py rename to packit/src/python/integrations/chat/inline/InlineBtns.py index baf685c1..1c649c8c 100644 --- a/packit/src/ChatActivity/inline/inlineBtns.py +++ b/packit/src/python/integrations/chat/inline/InlineBtns.py @@ -162,7 +162,7 @@ def _p(key): v = params.get(key) return str(v) if v is not None else "" - from .messageBuilder import build_plugin_message + from .MessageBuilder import build_plugin_message return build_plugin_message( _p("packit_name"), _p("packit_version"), _p("packit_author"), _p("packit_plugin_id"), _p("packit_repo_id"), translated_desc, @@ -178,7 +178,7 @@ def _do_translate_inline(message_object): # runs on background thread: translates only the description, rebuilds message with formatting try: from client_utils import edit_message - from ...utils.translation import _translate_text + from ....utils.Translation import _translate_text from java.util import Locale owner = message_object.messageOwner @@ -219,7 +219,7 @@ def set_pending(): def set_translated(): try: - from .messageBuilder import edit_message_with_entities + from .MessageBuilder import edit_message_with_entities if not edit_message_with_entities(message_object, rebuilt_text, rebuilt_entities): # last resort: at least put the translated text in place edit_message(message_object, text=rebuilt_text) @@ -340,7 +340,7 @@ def _do_send_file_inline(message_object, plugin_ref): # resolve plugins url from repo cache link = None try: - from ...ui.pluginsUpdates.fragment import _get_repos, _get_repo_plugins_url, _fetch_repo_plugins + from ....ui.updates.Fragment import _get_repos, _get_repo_plugins_url, _fetch_repo_plugins repos = _get_repos() repo_url = None for r in repos: @@ -460,8 +460,8 @@ def __init__(self, plugin): def after_hooked_method(self, param): try: - from . import inlineState - if not inlineState.get_state(): + from . import InlineState + if not InlineState.get_state(): return message_object = param.thisObject if not _is_packit_inline_message(message_object): @@ -544,8 +544,8 @@ def hasSeparator(self, row_idx): class _DidPressCustomBotButtonHook(MethodHook): def before_hooked_method(self, param): try: - from . import inlineState - if not inlineState.get_state(): + from . import InlineState + if not InlineState.get_state(): return logx(f"inlineBtns: didPressCustomBotButton fired, args={len(param.args)}", True) diff --git a/packit/src/ChatActivity/inline/inlineState.py b/packit/src/python/integrations/chat/inline/InlineState.py similarity index 100% rename from packit/src/ChatActivity/inline/inlineState.py rename to packit/src/python/integrations/chat/inline/InlineState.py diff --git a/packit/src/ChatActivity/inline/messageBuilder.py b/packit/src/python/integrations/chat/inline/MessageBuilder.py similarity index 99% rename from packit/src/ChatActivity/inline/messageBuilder.py rename to packit/src/python/integrations/chat/inline/MessageBuilder.py index 5b8537aa..0668115d 100644 --- a/packit/src/ChatActivity/inline/messageBuilder.py +++ b/packit/src/python/integrations/chat/inline/MessageBuilder.py @@ -86,7 +86,7 @@ def span(entity, start, text, **attrs): quote_start = offset if show_description and description: - from ...utils.markdown import parse as md_parse + from ....utils.Markdown import parse as md_parse parsed = md_parse(description) if parsed is not None: desc_text = parsed.text diff --git a/packit/src/ChatActivity/inline/__init__.py b/packit/src/python/integrations/chat/inline/__init__.py similarity index 100% rename from packit/src/ChatActivity/inline/__init__.py rename to packit/src/python/integrations/chat/inline/__init__.py diff --git a/packit/src/ChatActivity/LinksIcons/linksBottomSheet.py b/packit/src/python/integrations/chat/linksicons/LinksBottomSheet.py similarity index 100% rename from packit/src/ChatActivity/LinksIcons/linksBottomSheet.py rename to packit/src/python/integrations/chat/linksicons/LinksBottomSheet.py diff --git a/packit/src/ChatActivity/LinksIcons/__init__.py b/packit/src/python/integrations/chat/linksicons/__init__.py similarity index 60% rename from packit/src/ChatActivity/LinksIcons/__init__.py rename to packit/src/python/integrations/chat/linksicons/__init__.py index 159dda43..dfee2574 100644 --- a/packit/src/ChatActivity/LinksIcons/__init__.py +++ b/packit/src/python/integrations/chat/linksicons/__init__.py @@ -1,4 +1,4 @@ # pyright: reportMissingImports=false # SPDX-License-Identifier: GPL-3.0-or-later -from .linksBottomSheet import setup_links_buttons_hook \ No newline at end of file +from .LinksBottomSheet import setup_links_buttons_hook \ No newline at end of file diff --git a/packit/src/ChatActivity/SecurityBottomSheets/hashBottomSheet.py b/packit/src/python/integrations/chat/securitybottomsheets/HashBottomSheet.py similarity index 94% rename from packit/src/ChatActivity/SecurityBottomSheets/hashBottomSheet.py rename to packit/src/python/integrations/chat/securitybottomsheets/HashBottomSheet.py index aecb99fb..383e30dd 100644 --- a/packit/src/ChatActivity/SecurityBottomSheets/hashBottomSheet.py +++ b/packit/src/python/integrations/chat/securitybottomsheets/HashBottomSheet.py @@ -26,7 +26,7 @@ logx(f"hashBottomSheet: import LayoutHelper error: {e}", False) -from ...utils.hashUtil import hashFile as _computeSha256 +from ....utils.HashUtil import hashFile as _computeSha256 def _extractPluginVersion(filePath: str) -> str | None: @@ -62,49 +62,28 @@ def _extractPluginId(filePath: str) -> str | None: return None def _loadCachedRepos() -> list: - import os, json + # [(name, pluginsUrl, repoId), …] for every repository with a usable cache + from ....utils import CachedRepos result = [] - try: - from ...utils.paths import getReposCacheDir - cacheDir = getReposCacheDir() - except Exception as e: - if DEBUG_LOGS: - logx(f"hashBottomSheet: _loadCachedRepos error: {e}", False) - return result - - if not os.path.isdir(cacheDir): - return result - - for fname in os.listdir(cacheDir): - if not fname.endswith(".json"): + for rm_rid, cached in CachedRepos.all_cached(): + pluginsUrl = CachedRepos.plugins_url(rm_rid) + if not pluginsUrl: continue - try: - with open(os.path.join(cacheDir, fname), "r", encoding="utf-8") as f: - cached = json.load(f) - pluginsUrl = cached.get("repomap", {}).get("plugins") - if not pluginsUrl: - continue - name = cached.get("repometa", {}).get("rm_name") or fname.replace(".json", "") - repoId = cached.get("repometa", {}).get("rm_rid") or fname.replace(".json", "") - result.append((name, pluginsUrl, repoId)) - except Exception as e: - if DEBUG_LOGS: - logx(f"hashBottomSheet: error reading cache {fname}: {e}", False) - + meta = cached.get("repometa") or {} + result.append((meta.get("rm_name") or rm_rid, pluginsUrl, meta.get("rm_rid") or rm_rid)) return result def _getRepoPluginInfo(pluginId: str, pluginsUrl: str) -> dict | None: - import requests - r = requests.get(pluginsUrl, timeout=10) - if r.status_code != 200: + # this walked r.json()["plugins"] as a list, so it found nothing at all in a + # repository that keys its plugins by id — Storage answers in one shape + from ....network import Storage + entries, error = Storage.fetch_plugins(pluginsUrl) + if error: if DEBUG_LOGS: - logx(f"hashBottomSheet: HTTP {r.status_code} for {pluginsUrl}", True) + logx(f"hashBottomSheet: {error} for {pluginsUrl}", True) return None - for plugin in r.json().get("plugins", []): - if plugin.get("id") == pluginId: - return plugin - return None + return next((p for p in entries if p.get("id") == pluginId), None) def _installFromRepo(pluginId: str, pluginsUrl: str, repoManager, act): @@ -137,17 +116,14 @@ def action(): def task(): try: - r = requests.get(pluginsUrl, timeout=15) - if r.status_code != 200: + from ....network import Storage + entries, error = Storage.fetch_plugins(pluginsUrl) + if error: dismissDlg() - run_on_ui_thread(lambda: BulletinHelper.show_error(strings("sec_repo_load_failed", code=r.status_code))) + run_on_ui_thread(lambda e=error: BulletinHelper.show_error(strings("sec_repo_load_failed", code=e))) return - plugin = None - for item in r.json().get("plugins", []): - if isinstance(item, dict) and item.get("id") == pluginId: - plugin = item - break + plugin = next((p for p in entries if p.get("id") == pluginId), None) if not plugin: dismissDlg() @@ -160,7 +136,7 @@ def task(): run_on_ui_thread(lambda: BulletinHelper.show_error(strings["sec_plugin_no_link"])) return - from ...utils.paths import getPluginsDir + from ....utils.Paths import getPluginsDir pluginsDir = getPluginsDir() os.makedirs(pluginsDir, exist_ok=True) tempPath = os.path.join(pluginsDir, f".temp_{pluginId}.plugin") diff --git a/packit/src/ChatActivity/SecurityBottomSheets/signaturesBottomSheet.py b/packit/src/python/integrations/chat/securitybottomsheets/SignaturesBottomSheet.py similarity index 99% rename from packit/src/ChatActivity/SecurityBottomSheets/signaturesBottomSheet.py rename to packit/src/python/integrations/chat/securitybottomsheets/SignaturesBottomSheet.py index c554353a..001666bc 100644 --- a/packit/src/ChatActivity/SecurityBottomSheets/signaturesBottomSheet.py +++ b/packit/src/python/integrations/chat/securitybottomsheets/SignaturesBottomSheet.py @@ -800,7 +800,7 @@ def _showResults(results: dict, act): def onLearnMore(v): try: - from ...utils.localConfig import LocalConfig + from ....utils.LocalConfig import LocalConfig LocalConfig.set("signatures", True) except Exception as ex: if DEBUG_LOGS: @@ -853,7 +853,7 @@ def onLearnMore(v): wrapper.addView(warningView, lp_warn) try: - from ...utils.localConfig import LocalConfig + from ....utils.LocalConfig import LocalConfig showLearnMore = not LocalConfig.get("signatures", False) except Exception: showLearnMore = True diff --git a/packit/src/python/integrations/chat/securitybottomsheets/__init__.py b/packit/src/python/integrations/chat/securitybottomsheets/__init__.py new file mode 100644 index 00000000..b88323f2 --- /dev/null +++ b/packit/src/python/integrations/chat/securitybottomsheets/__init__.py @@ -0,0 +1,5 @@ +# pyright: reportMissingImports=false +# SPDX-License-Identifier: GPL-3.0-or-later + +from .SignaturesBottomSheet import setup_policy_button_hook +from .HashBottomSheet import setup_hash_button_hook \ No newline at end of file diff --git a/packit/src/DialogsActivity/btnCAB.py b/packit/src/python/integrations/chatlist/BtnCAB.py similarity index 98% rename from packit/src/DialogsActivity/btnCAB.py rename to packit/src/python/integrations/chatlist/BtnCAB.py index f30aeae0..d746e714 100644 --- a/packit/src/DialogsActivity/btnCAB.py +++ b/packit/src/python/integrations/chatlist/BtnCAB.py @@ -9,13 +9,13 @@ from org.telegram.ui import ChatActivity except Exception as e: import android_utils as _au; _au.log(f"import org.telegram.ui import ChatActivity failed: {e}") - from ..utils.importFailed import showImportFailedAlert as _sifa; _sifa() + from ...utils.ImportFailed import showImportFailedAlert as _sifa; _sifa() from hook_utils import find_class try: from elyx import settings except Exception as e: import android_utils as _au; _au.log(f"import elyx import settings failed: {e}") - from ..utils.importFailed import showImportFailedAlert as _sifa; _sifa() + from ...utils.ImportFailed import showImportFailedAlert as _sifa; _sifa() class BtnCAB: diff --git a/packit/src/DialogsActivity/btnPluginsMenu.py b/packit/src/python/integrations/chatlist/BtnPluginsMenu.py similarity index 96% rename from packit/src/DialogsActivity/btnPluginsMenu.py rename to packit/src/python/integrations/chatlist/BtnPluginsMenu.py index 258caff3..f0f9a5fe 100644 --- a/packit/src/DialogsActivity/btnPluginsMenu.py +++ b/packit/src/python/integrations/chatlist/BtnPluginsMenu.py @@ -7,7 +7,7 @@ from elyx import settings except Exception as e: import android_utils as _au; _au.log(f"import elyx import settings failed: {e}") - from ..utils.importFailed import showImportFailedAlert as _sifa; _sifa() + from ...utils.ImportFailed import showImportFailedAlert as _sifa; _sifa() _SETTINGS_LINK = "https://t.me/exteraSettings?s=mainMenuSettings" diff --git a/packit/src/DialogsActivity/buildNotCorrect.py b/packit/src/python/integrations/chatlist/BuildNotCorrect.py similarity index 98% rename from packit/src/DialogsActivity/buildNotCorrect.py rename to packit/src/python/integrations/chatlist/BuildNotCorrect.py index a46f25a4..98f43590 100644 --- a/packit/src/DialogsActivity/buildNotCorrect.py +++ b/packit/src/python/integrations/chatlist/BuildNotCorrect.py @@ -22,7 +22,7 @@ def _getDismissedHash() -> str: try: - from ..utils.localConfig import LocalConfig + from ...utils.LocalConfig import LocalConfig return LocalConfig.get(_HASH_CONFIG_KEY, "") except Exception as e: logx(f"buildNotCorrect: _getDismissedHash error: {e}", False) @@ -31,7 +31,7 @@ def _getDismissedHash() -> str: def _saveDismissedHash(hashVal: str): try: - from ..utils.localConfig import LocalConfig + from ...utils.LocalConfig import LocalConfig LocalConfig.set(_HASH_CONFIG_KEY, hashVal) except Exception as e: logx(f"buildNotCorrect: _saveDismissedHash error: {e}", False) @@ -224,7 +224,7 @@ def onClick(self, v): def _checkAndShow(): try: - from ..utils.buildInfo import ( + from ...utils.BuildInfo import ( getBuildClientPkg, getBuildClientName, getBuildStaticVersion, getCurrClientPkg, getCurrClientName, diff --git a/packit/src/DialogsActivity/button.py b/packit/src/python/integrations/chatlist/Button.py similarity index 89% rename from packit/src/DialogsActivity/button.py rename to packit/src/python/integrations/chatlist/Button.py index 02f3ba6d..e8707b2d 100644 --- a/packit/src/DialogsActivity/button.py +++ b/packit/src/python/integrations/chatlist/Button.py @@ -8,21 +8,21 @@ from com.exteragram.messenger.plugins import PluginsController except Exception as e: import android_utils as _au; _au.log(f"import com.exteragram.messenger.plugins import PluginsController failed: {e}") - from ..utils.importFailed import showImportFailedAlert as _sifa; _sifa() + from ...utils.ImportFailed import showImportFailedAlert as _sifa; _sifa() try: from com.exteragram.messenger.plugins.ui import PluginSettingsActivity except Exception as e: import android_utils as _au; _au.log(f"import com.exteragram.messenger.plugins.ui import PluginSettingsActivity failed: {e}") - from ..utils.importFailed import showImportFailedAlert as _sifa; _sifa() + from ...utils.ImportFailed import showImportFailedAlert as _sifa; _sifa() try: from elyx import strings except Exception as e: import android_utils as _au; _au.log(f"import elyx import strings failed: {e}") - from ..utils.importFailed import showImportFailedAlert as _sifa; _sifa() + from ...utils.ImportFailed import showImportFailedAlert as _sifa; _sifa() -from .btnCAB import BtnCAB -from .btnPluginsMenu import BtnPluginsMenu -from .chatDialogButton import ChatDialogButton +from .BtnCAB import BtnCAB +from .BtnPluginsMenu import BtnPluginsMenu +from .ChatDialogButton import ChatDialogButton class ChatButton(BtnCAB, BtnPluginsMenu, ChatDialogButton): diff --git a/packit/src/DialogsActivity/chatDialogButton.py b/packit/src/python/integrations/chatlist/ChatDialogButton.py similarity index 99% rename from packit/src/DialogsActivity/chatDialogButton.py rename to packit/src/python/integrations/chatlist/ChatDialogButton.py index 74448dd7..9dc0eb82 100644 --- a/packit/src/DialogsActivity/chatDialogButton.py +++ b/packit/src/python/integrations/chatlist/ChatDialogButton.py @@ -161,10 +161,10 @@ def _open(): logx(f"ChatDialogButton: open settings error: {e}", False) run_on_ui_thread(_open) elif m == 2: - from ..ui.IconsListActivity.fragment import InstallIconsUI + from ...ui.icons.Fragment import InstallIconsUI run_on_ui_thread(lambda: InstallIconsUI(plugin).open()) else: - from ..ui.PluginListActivity.fragment import InstallUI + from ...ui.plugins.Fragment import InstallUI run_on_ui_thread(lambda: InstallUI(plugin).open()) except Exception as e: logx(f"ChatDialogButton: onClick error: {e}", False) diff --git a/packit/src/DialogsActivity/PackitUpdateSheet.py b/packit/src/python/integrations/chatlist/PackitUpdateSheet.py similarity index 98% rename from packit/src/DialogsActivity/PackitUpdateSheet.py rename to packit/src/python/integrations/chatlist/PackitUpdateSheet.py index 15eca145..b7f595c2 100644 --- a/packit/src/DialogsActivity/PackitUpdateSheet.py +++ b/packit/src/python/integrations/chatlist/PackitUpdateSheet.py @@ -69,7 +69,7 @@ def _get_current_version() -> str: def _get_dismissed_ver() -> str: try: - from ..utils.localConfig import LocalConfig + from ...utils.LocalConfig import LocalConfig v = LocalConfig.get("update_dismissed_ver", "") logx(f"updateSheet: dismissed_ver='{v}'", True) return v @@ -80,7 +80,7 @@ def _get_dismissed_ver() -> str: def _save_dismissed_ver(ver: str): try: - from ..utils.localConfig import LocalConfig + from ...utils.LocalConfig import LocalConfig LocalConfig.set("update_dismissed_ver", ver) logx(f"updateSheet: saved dismissed_ver='{ver}'", True) except Exception as e: @@ -127,7 +127,7 @@ def _show_update_sheet(new_ver: str, changelog: str, sticker: str, download_url: iv.getImageReceiver().setCrossfadeWithOldImage(True) except Exception as e: logx(f"updateSheet: setCrossfadeWithOldImage error: {e}", False) - from ..utils.stickers import load_sticker + from ...utils.Stickers import load_sticker load_sticker(iv, sticker, sticker_size_dp) linear.addView(iv, LayoutHelper.createLinear( sticker_size_dp, sticker_size_dp, Gravity.CENTER_HORIZONTAL, 0, 16, 0, 0 diff --git a/packit/src/DialogsActivity/pillWidget.py b/packit/src/python/integrations/chatlist/PillWidget.py similarity index 99% rename from packit/src/DialogsActivity/pillWidget.py rename to packit/src/python/integrations/chatlist/PillWidget.py index c12162a3..bf68cd16 100644 --- a/packit/src/DialogsActivity/pillWidget.py +++ b/packit/src/python/integrations/chatlist/PillWidget.py @@ -429,7 +429,7 @@ def _open_settings(plugin): def _open_install(plugin): try: - from ..ui.PluginListActivity.fragment import InstallUI + from ...ui.plugins.Fragment import InstallUI InstallUI(plugin).open() except Exception as e: logx(f"PillWidget: _open_install error: {e}", False) @@ -437,7 +437,7 @@ def _open_install(plugin): def _open_icons(plugin): try: - from ..ui.IconsListActivity.fragment import InstallIconsUI + from ...ui.icons.Fragment import InstallIconsUI InstallIconsUI(plugin).open() except Exception as e: logx(f"PillWidget: _open_icons error: {e}", False) diff --git a/packit/src/DialogsActivity/updatesWidget.py b/packit/src/python/integrations/chatlist/UpdatesWidget.py similarity index 97% rename from packit/src/DialogsActivity/updatesWidget.py rename to packit/src/python/integrations/chatlist/UpdatesWidget.py index e3582586..5e420af7 100644 --- a/packit/src/DialogsActivity/updatesWidget.py +++ b/packit/src/python/integrations/chatlist/UpdatesWidget.py @@ -2,7 +2,7 @@ # SPDX-License-Identifier: GPL-3.0-or-later from packutil import logx -from ..utils.netQueue import run_io +from ...utils.NetQueue import run_io from android_utils import run_on_ui_thread from android.view import Gravity from android.widget import LinearLayout, ImageView, TextView @@ -206,7 +206,7 @@ def _prefetch_and_register(plugin): def task(): try: - from ..ui.pluginsUpdates.fragment import _check_updates, _filter_ignored + from ...ui.updates.Fragment import _check_updates, _filter_ignored updates = _filter_ignored(None, _check_updates(None)) _updates_count[0] = len(updates) _updates_list[0] = updates @@ -382,7 +382,7 @@ def _on_click(plugin, pill): def _install_single(plugin, item): # installs the single available update directly, then re-checks on success from client_utils import run_on_queue - from ..ui.pluginsUpdates.fragment import _get_repos, _get_repo_plugins_url + from ...ui.updates.Fragment import _get_repos, _get_repo_plugins_url import requests as _req pid = str(item.get("id") or "") @@ -392,7 +392,7 @@ def _on_installed(installed_pid): if installed_pid != pid: return try: - from ..core import remove_install_listener + from ...core.Core import remove_install_listener remove_install_listener(_on_installed) except Exception as e: logx(f"UpdatesWidget: remove_install_listener error: {e}", False) @@ -431,7 +431,7 @@ def task(): if not plugin_data: logx(f"UpdatesWidget: _install_single plugin '{pid}' not found in repo", True) return - from ..core import install_plugin, add_install_listener + from ...core.Core import install_plugin, add_install_listener add_install_listener(_on_installed) from android_utils import run_on_ui_thread run_on_ui_thread(lambda: install_plugin(plugin_data, all_plugins=all_plugins, rm_rid=repo_id)) @@ -443,7 +443,7 @@ def task(): def _open_updates(plugin): try: - from ..ui.pluginsUpdates.fragment import show_updates_fragment + from ...ui.updates.Fragment import show_updates_fragment show_updates_fragment(plugin) except Exception as e: logx(f"UpdatesWidget: _open_updates error: {e}", False) @@ -477,7 +477,7 @@ def finish_loading(count, updates): def task(): try: - from ..ui.pluginsUpdates.fragment import _check_updates, _filter_ignored + from ...ui.updates.Fragment import _check_updates, _filter_ignored updates = _filter_ignored(None, _check_updates(None)) count = len(updates) run_on_ui_thread(lambda: finish_loading(count, updates)) diff --git a/packit/src/SettingsActivity/SubSettings/__init__.py b/packit/src/python/integrations/chatlist/__init__.py similarity index 100% rename from packit/src/SettingsActivity/SubSettings/__init__.py rename to packit/src/python/integrations/chatlist/__init__.py diff --git a/packit/src/other/badges.py b/packit/src/python/integrations/decorations/Badges.py similarity index 96% rename from packit/src/other/badges.py rename to packit/src/python/integrations/decorations/Badges.py index 20c6e299..2a7988cc 100644 --- a/packit/src/other/badges.py +++ b/packit/src/python/integrations/decorations/Badges.py @@ -126,9 +126,9 @@ def setup_hooks(self): pass # primary path: precompiled Kotlin dex (config fetch + cache + hooks - # all live in packit/dex//badges.dex, source in /kotlin/) + # all live in packit/dex/packit.dex, source in packit/src/kotlin/) try: - from ..dexLoader import loadBadges + from ...core.DexLoader import loadBadges if loadBadges(self.context, enabled): self._dex_loaded = True logx("[Packit Badges] using precompiled dex", True) @@ -182,21 +182,21 @@ def _install_hooks(self): logx(f"[Packit Badges] hook install error: {e}", False) try: - from .chatBadge import setup_chat_badge_hook + from .ChatBadge import setup_chat_badge_hook chat_refs = setup_chat_badge_hook(self.plugin, _lookup) self._hook_refs.extend(chat_refs) except Exception as e: logx(f"[Packit Badges] chat hook error: {e}", False) try: - from .chatTitleIcon import setup_title_icon_hook + from .ChatTitleIcon import setup_title_icon_hook title_refs = setup_title_icon_hook(self.plugin, _lookup) self._hook_refs.extend(title_refs) except Exception as e: logx(f"[Packit Badges] title icon hook error: {e}", False) try: - from .profileTitleIcon import setup_profile_title_icon_hook + from .ProfileTitleIcon import setup_profile_title_icon_hook profile_refs = setup_profile_title_icon_hook(self.plugin, _lookup) self._hook_refs.extend(profile_refs) except Exception as e: @@ -206,7 +206,7 @@ def cleanup(self): try: if self._dex_loaded: try: - from ..dexLoader import unloadBadges + from ...core.DexLoader import unloadBadges unloadBadges() except Exception as e: logx(f"[Packit Badges] dex unload error: {e}", False) diff --git a/packit/src/other/chatBadge.py b/packit/src/python/integrations/decorations/ChatBadge.py similarity index 100% rename from packit/src/other/chatBadge.py rename to packit/src/python/integrations/decorations/ChatBadge.py diff --git a/packit/src/other/chatTitleIcon.py b/packit/src/python/integrations/decorations/ChatTitleIcon.py similarity index 100% rename from packit/src/other/chatTitleIcon.py rename to packit/src/python/integrations/decorations/ChatTitleIcon.py diff --git a/packit/src/other/everyone.py b/packit/src/python/integrations/decorations/Everyone.py similarity index 98% rename from packit/src/other/everyone.py rename to packit/src/python/integrations/decorations/Everyone.py index cb1e0d6b..c80175c9 100644 --- a/packit/src/other/everyone.py +++ b/packit/src/python/integrations/decorations/Everyone.py @@ -23,7 +23,7 @@ def _get_cache_path() -> str: - from ..utils.paths import getCacheRoot + from ...utils.Paths import getCacheRoot cache_dir = getCacheRoot() os.makedirs(cache_dir, exist_ok=True) return os.path.join(cache_dir, _CACHE_FILENAME) diff --git a/packit/src/other/isBeta.py b/packit/src/python/integrations/decorations/IsBeta.py similarity index 98% rename from packit/src/other/isBeta.py rename to packit/src/python/integrations/decorations/IsBeta.py index c35bb897..90c0b5a3 100644 --- a/packit/src/other/isBeta.py +++ b/packit/src/python/integrations/decorations/IsBeta.py @@ -18,8 +18,8 @@ from elyx import strings except Exception as e: import android_utils as _au; _au.log(f"import elyx import strings failed: {e}") - from ..utils.importFailed import showImportFailedAlert as _sifa; _sifa() -from ..utils.localConfig import LocalConfig + from ...utils.ImportFailed import showImportFailedAlert as _sifa; _sifa() +from ...utils.LocalConfig import LocalConfig BETA = False _COUNTDOWN_SEC = 5 diff --git a/packit/src/other/profileTitleIcon.py b/packit/src/python/integrations/decorations/ProfileTitleIcon.py similarity index 100% rename from packit/src/other/profileTitleIcon.py rename to packit/src/python/integrations/decorations/ProfileTitleIcon.py diff --git a/packit/src/other/text.py b/packit/src/python/integrations/decorations/Text.py similarity index 92% rename from packit/src/other/text.py rename to packit/src/python/integrations/decorations/Text.py index d691f6fc..12759832 100644 --- a/packit/src/other/text.py +++ b/packit/src/python/integrations/decorations/Text.py @@ -16,7 +16,7 @@ def check_message(text: str): return lower = text.lower().strip() try: - from ..ui.AchievementsActivity.service.AchivementsEngine import unlock_secret + from ...ui.achievements.service.AchivementsEngine import unlock_secret if lower == _TRIGGER_TALKING: unlock_secret("talking_about_you") elif lower == _TRIGGER_UTILS: diff --git a/packit/src/SettingsActivity/__init__.py b/packit/src/python/integrations/decorations/__init__.py similarity index 100% rename from packit/src/SettingsActivity/__init__.py rename to packit/src/python/integrations/decorations/__init__.py diff --git a/packit/src/standaloneHooks/addIconsFab.py b/packit/src/python/integrations/hooks/AddIconsFab.py similarity index 98% rename from packit/src/standaloneHooks/addIconsFab.py rename to packit/src/python/integrations/hooks/AddIconsFab.py index ca17be39..bb93f1dd 100644 --- a/packit/src/standaloneHooks/addIconsFab.py +++ b/packit/src/python/integrations/hooks/AddIconsFab.py @@ -142,7 +142,7 @@ def _inject_fab(plugin, frag_view, fragment=None): def on_fab_click(v): try: - from ..ui.IconsListActivity.fragment import InstallIconsUI + from ...ui.icons.Fragment import InstallIconsUI InstallIconsUI(plugin).open() except Exception as e: logx(f"addIconsFab: on_fab_click error: {e}", False) diff --git a/packit/src/standaloneHooks/addPluginFab.py b/packit/src/python/integrations/hooks/AddPluginFab.py similarity index 99% rename from packit/src/standaloneHooks/addPluginFab.py rename to packit/src/python/integrations/hooks/AddPluginFab.py index 4e01d34f..7054542b 100644 --- a/packit/src/standaloneHooks/addPluginFab.py +++ b/packit/src/python/integrations/hooks/AddPluginFab.py @@ -108,7 +108,7 @@ def _inject_fab(plugin, frag_view): def on_fab_click(v): try: - from ..ui.PluginListActivity.fragment import InstallUI + from ...ui.plugins.Fragment import InstallUI InstallUI(plugin).open() except Exception as e: logx(f"addPluginFab: on_fab_click error: {e}", False) diff --git a/packit/src/standaloneHooks/InstallDismissHook.py b/packit/src/python/integrations/hooks/InstallDismissHook.py similarity index 93% rename from packit/src/standaloneHooks/InstallDismissHook.py rename to packit/src/python/integrations/hooks/InstallDismissHook.py index 3efcb2c5..044c1ab0 100644 --- a/packit/src/standaloneHooks/InstallDismissHook.py +++ b/packit/src/python/integrations/hooks/InstallDismissHook.py @@ -20,14 +20,14 @@ def after_hooked_method(self, param): if error_str is not None: return try: - from ..ui.AchievementsActivity.service.AchivementsEngine import increment_category + from ...ui.achievements.service.AchivementsEngine import increment_category increment_category("Installing plugins") except Exception as e: logx(f"installSuccessHook: achievements increment error: {e}", False) # backstop index write; core's pluginsUpdated observer is the # primary path. commit_pending consumes _pending -> idempotent. try: - from ..utils.installIndex import commit_pending + from ...utils.InstallIndex import commit_pending commit_pending() except Exception as e: logx(f"installSuccessHook: commit_pending error: {e}", False) diff --git a/packit/src/standaloneHooks/settingsActivityHook.py b/packit/src/python/integrations/hooks/SettingsActivityHook.py similarity index 100% rename from packit/src/standaloneHooks/settingsActivityHook.py rename to packit/src/python/integrations/hooks/SettingsActivityHook.py diff --git a/packit/src/standaloneHooks/universalFragmentFix.py b/packit/src/python/integrations/hooks/UniversalFragmentFix.py similarity index 100% rename from packit/src/standaloneHooks/universalFragmentFix.py rename to packit/src/python/integrations/hooks/UniversalFragmentFix.py diff --git a/packit/src/SettingsActivity/service/__init__.py b/packit/src/python/integrations/hooks/__init__.py similarity index 100% rename from packit/src/SettingsActivity/service/__init__.py rename to packit/src/python/integrations/hooks/__init__.py diff --git a/packit/src/python/network/Storage.py b/packit/src/python/network/Storage.py new file mode 100644 index 00000000..97838967 --- /dev/null +++ b/packit/src/python/network/Storage.py @@ -0,0 +1,218 @@ +# pyright: reportMissingImports=false +# SPDX-License-Identifier: GPL-3.0-or-later + +# Everything the plugin fetches from a repository over the network: its repomap, +# its plugin list, its icon list, its avatar. +# +# Every screen that wanted a plugin list used to GET it itself and unpack a +# "plugins" value that is an object in some repositories and an array in +# others. Nine copies of that existed, no two identical: the User-Agent was +# sent by one of them, the timeout for the same file ranged from 10 to 20 +# seconds, and only one of the three places that fetched a repomap had the full +# table of http reasons the add dialog localises. A repository is asked once, +# from here. +# +# Where a fetched repomap is kept, and everything read back out of it, is +# utils/CachedRepos — this module does not touch that file. + +from packutil import logx +import json +import os + +import requests + +from ..utils import Jsonx as _jsonx +from ..utils.Paths import getRepoIconCachePath, getRepoIconCacheDir + +# repositories are served from github raw and the like; some of them log this +HEADERS = {"User-Agent": "PackIt/1.0 (Android; github.com/shareui/packit)"} + +TIMEOUT = 15 +TIMEOUT_LIST = 20 # plugin and icon lists run to hundreds of kilobytes + + +# ------------------------------------------------------------------- shapes + +def normalize_entries(raw) -> list: + """A repository's "plugins"/"icons" value as a list of dicts, each with an id. + + Both shapes are in the wild: an object keyed by id, and an array of objects + that carry their own. Every caller used to unpack this itself, and they did + not agree on what to do with a malformed entry. + """ + out = [] + if isinstance(raw, dict): + for entry_id, info in raw.items(): + if isinstance(info, dict): + out.append({"id": entry_id, **info}) + elif isinstance(raw, list): + for item in raw: + if isinstance(item, dict) and item.get("id"): + out.append(item) + return out + + +# ------------------------------------------------------------------- network + +# The add dialog localises a failure by matching these exact strings, so they +# are the vocabulary of this module and not free text. +_STATUS_REASONS = { + 301: "permanently redirected", + 302: "redirected", + 303: "see other", + 307: "temporarily redirected", + 308: "permanently redirected", + 400: "bad request", + 401: "unauthorized", + 403: "forbidden", + 404: "file not found", + 408: "request timeout", + 410: "resource gone", + 429: "rate limited, try again later", + 451: "unavailable for legal reasons", + 500: "server error", + 502: "bad gateway", + 503: "service unavailable", + 504: "gateway timeout", +} + + +def fetch_json(url: str, timeout: int = TIMEOUT): + """(data, error). error is a lowercase english reason, or None on success.""" + url = str(url or "").strip() + if not url: + return None, "file not found" + try: + r = requests.get(url, timeout=timeout, headers=HEADERS) + except Exception as e: + logx(f"Storage: request failed for '{url}': {e}", False) + return None, str(e) + if r.status_code != 200: + logx(f"Storage: HTTP {r.status_code} for '{url}'", True) + return None, _STATUS_REASONS.get(r.status_code, f"HTTP {r.status_code}") + try: + return _jsonx.loads(r.text), None + except Exception as e: + logx(f"Storage: bad json at '{url}': {e}", True) + return None, "invalid json" + + +def fetch_repomap(url: str, timeout: int = TIMEOUT): + """(repomap, error) — a repomap is only one if it declares who it is.""" + data, error = fetch_json(url, timeout) + if error: + return None, error + meta = data.get("repometa") if isinstance(data, dict) else None + if not isinstance(meta, dict) or not meta: + return None, "missing repometa" + if not meta.get("rm_rid"): + return None, "missing rm_rid" + return data, None + + +def fetch_entries(url: str, key: str, timeout: int = TIMEOUT_LIST): + """(entries, error) for a plugin or icon list.""" + data, error = fetch_json(url, timeout) + if error: + return None, error + raw = data.get(key, []) if isinstance(data, dict) else [] + return normalize_entries(raw), None + + +def fetch_plugins(url: str, timeout: int = TIMEOUT_LIST): + return fetch_entries(url, "plugins", timeout) + + +def fetch_icons(url: str, timeout: int = TIMEOUT_LIST): + return fetch_entries(url, "icons", timeout) + + +# ------------------------------------------------------- repository avatars + +_MEM_CAP = 64 +_mem = None +_mem_lock = None + + +def _mem_store(): + global _mem + if _mem is None: + from collections import OrderedDict + _mem = OrderedDict() + return _mem + + +def _lock(): + global _mem_lock + if _mem_lock is None: + import threading + _mem_lock = threading.Lock() + return _mem_lock + + +def _mem_key(url: str, px: int) -> str: + # px is part of the key: the same icon is decoded at different sizes for a + # card and for a sheet, and the smaller decode looks soft blown up + return f"{url}|{px}" + + +def peek_icon(url: str, px: int): + """The already-decoded avatar, or None. Cheap enough for the ui thread. + + Routing a bitmap that is already in memory through the worker pool costs a + hop out and a hop back, and a card rebuilt in those two frames shows its + monogram — which is what made avatars blink whenever the list repainted. + """ + if not url: + return None + key = _mem_key(url, px) + with _lock(): + store = _mem_store() + bmp = store.get(key) + if bmp is not None: + store.move_to_end(key) + return bmp + + +def load_icon(url: str, px: int): + """memory -> disk -> network, decoded to a px-sized bitmap. Off the ui thread.""" + from ..utils import ImagePool + + bmp = peek_icon(url, px) + if bmp is not None: + return bmp + + path = getRepoIconCachePath(url) + data = None + try: + if os.path.isfile(path): + with open(path, "rb") as f: + data = f.read() + except Exception: + data = None + + if not data: + data = ImagePool.fetch(url) + if not data: + return None + try: + os.makedirs(getRepoIconCacheDir(), exist_ok=True) + with open(path, "wb") as f: + f.write(data) + except Exception as e: + logx(f"Storage: icon cache write failed: {e}", True) + + bmp = ImagePool.decode(data, px, ImagePool.looks_like_svg(url, data)) + if bmp is None: + # a corrupted cache entry would keep failing forever + try: + os.unlink(path) + except Exception: + pass + return None + with _lock(): + store = _mem_store() + store[_mem_key(url, px)] = bmp + while len(store) > _MEM_CAP: + store.popitem(last=False) + return bmp diff --git a/packit/src/python/network/__init__.py b/packit/src/python/network/__init__.py new file mode 100644 index 00000000..c5d406ee --- /dev/null +++ b/packit/src/python/network/__init__.py @@ -0,0 +1,4 @@ +# pyright: reportMissingImports=false +# SPDX-License-Identifier: GPL-3.0-or-later + +# Everything the plugin asks a repository for. diff --git a/packit/src/scl/doc.py b/packit/src/python/scl/Doc.py similarity index 94% rename from packit/src/scl/doc.py rename to packit/src/python/scl/Doc.py index c3c85d3a..3d5e2b35 100644 --- a/packit/src/scl/doc.py +++ b/packit/src/python/scl/Doc.py @@ -1,6 +1,6 @@ -from . import native as _native -from .errors import TomlError -from .value import Value, ListBuilder, StructBuilder, _makeVal +from . import Native as _native +from .Errors import TomlError +from .Value import Value, ListBuilder, StructBuilder, _makeVal class Doc: # precondition: ptr is a valid doc pointer from the native layer, or None for empty diff --git a/packit/src/scl/errors.py b/packit/src/python/scl/Errors.py similarity index 100% rename from packit/src/scl/errors.py rename to packit/src/python/scl/Errors.py diff --git a/packit/src/scl/native.py b/packit/src/python/scl/Native.py similarity index 99% rename from packit/src/scl/native.py rename to packit/src/python/scl/Native.py index 9266c0c4..3b51ed2d 100644 --- a/packit/src/scl/native.py +++ b/packit/src/python/scl/Native.py @@ -2,8 +2,8 @@ import platform def _soPath() -> str: - from ..utils.paths import _filesDir - from ..nativeLoader import detectArch + from ..utils.Paths import _filesDir + from ..core.NativeLoader import detectArch arch = detectArch() return _filesDir() + f"/plugins/ElyxPlugins/shareui_packit/packit/native/{arch}/libscl.so" diff --git a/packit/src/scl/opts.py b/packit/src/python/scl/Opts.py similarity index 93% rename from packit/src/scl/opts.py rename to packit/src/python/scl/Opts.py index 6821314b..781cfdfa 100644 --- a/packit/src/scl/opts.py +++ b/packit/src/python/scl/Opts.py @@ -1,7 +1,7 @@ import ctypes from dataclasses import dataclass, field -from . import native as _native +from . import Native as _native @dataclass class ParseOpts: diff --git a/packit/src/scl/scl.py b/packit/src/python/scl/Scl.py similarity index 92% rename from packit/src/scl/scl.py rename to packit/src/python/scl/Scl.py index b823a62a..5f8b6b16 100644 --- a/packit/src/scl/scl.py +++ b/packit/src/python/scl/Scl.py @@ -1,8 +1,8 @@ -from . import native as _native -from .errors import ParseError, TomlError -from .opts import ParseOpts -from .doc import Doc -from .value import Value +from . import Native as _native +from .Errors import ParseError, TomlError +from .Opts import ParseOpts +from .Doc import Doc +from .Value import Value NULL = _native.NULL STRING = _native.STRING diff --git a/packit/src/scl/value.py b/packit/src/python/scl/Value.py similarity index 99% rename from packit/src/scl/value.py rename to packit/src/python/scl/Value.py index 2303b2d0..e9d4f5e0 100644 --- a/packit/src/scl/value.py +++ b/packit/src/python/scl/Value.py @@ -1,4 +1,4 @@ -from . import native as _native +from . import Native as _native class Value: def __init__(self, ptr, doc): diff --git a/packit/src/scl/__init__.py b/packit/src/python/scl/__init__.py similarity index 58% rename from packit/src/scl/__init__.py rename to packit/src/python/scl/__init__.py index 50eca646..c23df1e4 100644 --- a/packit/src/scl/__init__.py +++ b/packit/src/python/scl/__init__.py @@ -1,4 +1,4 @@ -from .scl import ( +from .Scl import ( parse, parseFile, version, @@ -17,7 +17,7 @@ STRUCT, UNION, ) -from .errors import ParseError, TomlError -from .opts import ParseOpts -from .doc import Doc -from .value import Value +from .Errors import ParseError, TomlError +from .Opts import ParseOpts +from .Doc import Doc +from .Value import Value diff --git a/packit/src/MainActivity.py b/packit/src/python/ui/MainActivity.py similarity index 91% rename from packit/src/MainActivity.py rename to packit/src/python/ui/MainActivity.py index 253a1b03..794ded23 100644 --- a/packit/src/MainActivity.py +++ b/packit/src/python/ui/MainActivity.py @@ -7,14 +7,13 @@ from elyx import strings, metainfo except Exception as e: import android_utils as _au; _au.log(f"import elyx import strings, metainfo failed: {e}") - from .utils.importFailed import showImportFailedAlert as _sifa; _sifa() -from .SettingsActivity.repos import RepositoriesSettings -from .SettingsActivity.deeplinks import DeeplinksSettings -from .SettingsActivity.settings import OtherSettings -from .SettingsActivity.docs import DocumentationSettings -from .ui.contributors.fragment import show_contributors_fragment -from .SettingsActivity.profile import ProfileSettings -from .SettingsActivity.utilities import UtilitiesSettings + from ..utils.ImportFailed import showImportFailedAlert as _sifa; _sifa() +from .settings.Deeplinks import DeeplinksSettings +from .settings.Settings import OtherSettings +from .settings.Docs import DocumentationSettings +from .contributors.Fragment import show_contributors_fragment +from .settings.Profile import ProfileSettings +from .settings.Utilities import UtilitiesSettings from ui.bulletin import BulletinHelper from base_plugin import BasePlugin, MethodHook from android_utils import run_on_ui_thread @@ -23,17 +22,17 @@ from org.telegram.ui.ActionBar import Theme, BottomSheet except Exception as e: import android_utils as _au; _au.log(f"import org.telegram.ui.ActionBar import Theme, BottomSheet failed: {e}") - from .utils.importFailed import showImportFailedAlert as _sifa; _sifa() + from ..utils.ImportFailed import showImportFailedAlert as _sifa; _sifa() try: from org.telegram.ui.Components import LayoutHelper, UItem, BackupImageView, EffectsTextView, BulletinFactory except Exception as e: import android_utils as _au; _au.log(f"import org.telegram.ui.Components import LayoutHelper, UItem, BackupImageView, EffectsTextView, BulletinFactory failed: {e}") - from .utils.importFailed import showImportFailedAlert as _sifa; _sifa() + from ..utils.ImportFailed import showImportFailedAlert as _sifa; _sifa() try: from com.exteragram.messenger.plugins.models import HeaderSetting except Exception as e: import android_utils as _au; _au.log(f"import com.exteragram.messenger.plugins.models import HeaderSetting failed: {e}") - from .utils.importFailed import showImportFailedAlert as _sifa; _sifa() + from ..utils.ImportFailed import showImportFailedAlert as _sifa; _sifa() from android.widget import FrameLayout, TextView, LinearLayout, ScrollView from android.graphics.drawable import GradientDrawable from android.view import Gravity @@ -42,15 +41,15 @@ from org.telegram.messenger import AndroidUtilities, ImageLocation, MediaDataController, R except Exception as e: import android_utils as _au; _au.log(f"import org.telegram.messenger import AndroidUtilities, ImageLocation, MediaDataController, R failed: {e}") - from .utils.importFailed import showImportFailedAlert as _sifa; _sifa() -from .ui.PluginListActivity.fragment import InstallUI -from .ui.IconsListActivity.fragment import InstallIconsUI + from ..utils.ImportFailed import showImportFailedAlert as _sifa; _sifa() +from .plugins.Fragment import InstallUI +from .icons.Fragment import InstallIconsUI from client_utils import get_last_fragment try: from org.telegram.messenger.browser import Browser except Exception as e: import android_utils as _au; _au.log(f"import org.telegram.messenger.browser import Browser failed: {e}") - from .utils.importFailed import showImportFailedAlert as _sifa; _sifa() + from ..utils.ImportFailed import showImportFailedAlert as _sifa; _sifa() from android.net import Uri from java import dynamic_proxy as dyp from android_utils import OnClickListener, OnLongClickListener @@ -71,7 +70,6 @@ class SettingsBuilder: def __init__(self, repoManager, plugin): self.repoManager = repoManager self.plugin = plugin - self.repositoriesSettings = RepositoriesSettings(repoManager) self.deeplinksSettings = DeeplinksSettings() self.otherSettings = OtherSettings(plugin.chatUI, plugin) self.documentationSettings = DocumentationSettings() @@ -149,12 +147,12 @@ def _create_settings_header(self, context): imageView = BackupImageView(context) imageView.setRoundRadius(AndroidUtilities.dp(45)) - from .utils.stickers import load_sticker + from ..utils.Stickers import load_sticker load_sticker(imageView, __icon__, 130) def _on_sticker_long_click(): try: - from .SettingsActivity.debugItems import show_debug_menu + from .settings.DebugItems import show_debug_menu show_debug_menu() except Exception as _e: logx(f"MainActivity: sticker long click error: {_e}", True) @@ -195,12 +193,19 @@ def _open_install_plugin(self, view): def _check_updates(self, view): try: - from .ui.pluginsUpdates.fragment import show_updates_fragment + from .updates.Fragment import show_updates_fragment show_updates_fragment(self.plugin) except Exception as e: logx(f"MainActivity: _check_updates error: {e}", False) + def _open_repositories(self, view): + try: + from .repos import show_repos_fragment + show_repos_fragment(self.repoManager) + except Exception as e: + logx(f"MainActivity: _open_repositories error: {e}", False) + def _install_icons(self, view): try: install_icons_ui = InstallIconsUI(self.plugin) @@ -290,7 +295,7 @@ def buildMainSettings(self): Text( text=strings.repositories, icon="msg_folders", - create_sub_fragment=self.repositoriesSettings.build, + on_click=self._open_repositories, link_alias="repositories" ), @@ -353,7 +358,7 @@ def buildMainSettings(self): ] def _build_client_label(self): - from .utils.buildInfo import getBuildClientName, getBuildStaticVersion + from ..utils.BuildInfo import getBuildClientName, getBuildStaticVersion client_str = getBuildClientName() static_ver = getBuildStaticVersion() diff --git a/packit/src/deeplinks/secret/__init__.py b/packit/src/python/ui/__init__.py similarity index 100% rename from packit/src/deeplinks/secret/__init__.py rename to packit/src/python/ui/__init__.py diff --git a/packit/src/ui/AchievementsActivity/fragment.py b/packit/src/python/ui/achievements/Fragment.py similarity index 99% rename from packit/src/ui/AchievementsActivity/fragment.py rename to packit/src/python/ui/achievements/Fragment.py index 7821c94b..bb7a7024 100644 --- a/packit/src/ui/AchievementsActivity/fragment.py +++ b/packit/src/python/ui/achievements/Fragment.py @@ -462,7 +462,7 @@ def afterCreateView(self, view): _add_actionbar_glow(view) _add_bottom_glow(view) try: - from ..viewUtils import applyFontToTree + from ..components.ViewUtils import applyFontToTree applyFontToTree(view) except Exception: pass @@ -630,7 +630,7 @@ def afterCreateView(self, view): _add_actionbar_glow(view) _add_bottom_glow(view) try: - from ..viewUtils import applyFontToTree + from ..components.ViewUtils import applyFontToTree applyFontToTree(view) except Exception: pass @@ -761,7 +761,7 @@ def show_hint_sheet(achievement: dict): sheet.setCustomView(root) try: - from ..viewUtils import applyFontToTree + from ..components.ViewUtils import applyFontToTree applyFontToTree(root) except Exception: pass diff --git a/packit/src/other/__init__.py b/packit/src/python/ui/achievements/__init__.py similarity index 100% rename from packit/src/other/__init__.py rename to packit/src/python/ui/achievements/__init__.py diff --git a/packit/src/ui/AchievementsActivity/service/AchivementsEngine.py b/packit/src/python/ui/achievements/service/AchivementsEngine.py similarity index 97% rename from packit/src/ui/AchievementsActivity/service/AchivementsEngine.py rename to packit/src/python/ui/achievements/service/AchivementsEngine.py index f04d217f..7bc4cf32 100644 --- a/packit/src/ui/AchievementsActivity/service/AchivementsEngine.py +++ b/packit/src/python/ui/achievements/service/AchivementsEngine.py @@ -2,7 +2,7 @@ # SPDX-License-Identifier: GPL-3.0-or-later from packutil import logx -from ....utils.bulletins import factory as _pbf +from ....utils.Bulletins import factory as _pbf import os import json import zlib @@ -12,7 +12,7 @@ from org.telegram.messenger import ApplicationLoader, UserConfig except Exception as e: import android_utils as _au; _au.log(f"achievements: import failed: {e}") - from ....utils.importFailed import showImportFailedAlert as _sifa; _sifa() + from ....utils.ImportFailed import showImportFailedAlert as _sifa; _sifa() _achievement_pending = False _bulletin_container = None @@ -46,7 +46,7 @@ def _load_achievements() -> list: def _get_configs_dir() -> str: - from ....utils.paths import getConfigsDir + from ....utils.Paths import getConfigsDir return getConfigsDir() @@ -78,7 +78,7 @@ def _get_current_account_id() -> str: return "0" def _load_lib(): - from ....nativeLoader import loadPackitDb + from ....core.NativeLoader import loadPackitDb lib = loadPackitDb() if lib is not None: logx("packitdb: libpackitdb loaded ok", True) @@ -302,7 +302,7 @@ def sync_completed(data: dict) -> tuple: data[aid] = current_level try: - from ....utils.localConfig import days_since_install + from ....utils.LocalConfig import days_since_install days = days_since_install() except Exception: days = 0 @@ -389,7 +389,7 @@ def show(): return def _open(): - from ....ui.AchievementsActivity.fragment import show_hint_sheet + from ..Fragment import show_hint_sheet show_hint_sheet(achievement) ctx = fragment.getContext() @@ -425,7 +425,7 @@ def _open(): def _play_achievement_sound(): try: - from ....utils.media import playSound + from ....utils.Media import playSound from elyx import assets sound_path = assets.sounds.received_achievement.path_str playSound(sound_path, "sfx_achievement", check_pending=False, default=True) diff --git a/packit/src/standaloneHooks/__init__.py b/packit/src/python/ui/achievements/service/__init__.py similarity index 100% rename from packit/src/standaloneHooks/__init__.py rename to packit/src/python/ui/achievements/service/__init__.py diff --git a/packit/src/ui/contextMenu.py b/packit/src/python/ui/components/ContextMenu.py similarity index 100% rename from packit/src/ui/contextMenu.py rename to packit/src/python/ui/components/ContextMenu.py diff --git a/packit/src/ui/FontManager.py b/packit/src/python/ui/components/FontManager.py similarity index 100% rename from packit/src/ui/FontManager.py rename to packit/src/python/ui/components/FontManager.py diff --git a/packit/src/ui/md3Slider.py b/packit/src/python/ui/components/Md3Slider.py similarity index 100% rename from packit/src/ui/md3Slider.py rename to packit/src/python/ui/components/Md3Slider.py diff --git a/packit/src/ui/viewUtils.py b/packit/src/python/ui/components/ViewUtils.py similarity index 100% rename from packit/src/ui/viewUtils.py rename to packit/src/python/ui/components/ViewUtils.py diff --git a/packit/src/python/ui/components/__init__.py b/packit/src/python/ui/components/__init__.py new file mode 100644 index 00000000..50b2625f --- /dev/null +++ b/packit/src/python/ui/components/__init__.py @@ -0,0 +1,4 @@ +# pyright: reportMissingImports=false +# SPDX-License-Identifier: GPL-3.0-or-later + +# Pieces the plugin's screens are assembled from, none of which is a screen. diff --git a/packit/src/ui/contributors/fragment.py b/packit/src/python/ui/contributors/Fragment.py similarity index 99% rename from packit/src/ui/contributors/fragment.py rename to packit/src/python/ui/contributors/Fragment.py index 2e9b4f04..ceff4d55 100644 --- a/packit/src/ui/contributors/fragment.py +++ b/packit/src/python/ui/contributors/Fragment.py @@ -2,7 +2,7 @@ # SPDX-License-Identifier: GPL-3.0-or-later from packutil import logx -from ...utils.bulletins import factory as _pbf +from ...utils.Bulletins import factory as _pbf from java import dynamic_proxy from android_utils import run_on_ui_thread, OnClickListener from client_utils import get_last_fragment, run_on_queue diff --git a/packit/src/ui/contributors/__init__.py b/packit/src/python/ui/contributors/__init__.py similarity index 100% rename from packit/src/ui/contributors/__init__.py rename to packit/src/python/ui/contributors/__init__.py diff --git a/packit/src/ui/DeeplinkBottomSheets.py b/packit/src/python/ui/dialogs/DeeplinkBottomSheets.py similarity index 98% rename from packit/src/ui/DeeplinkBottomSheets.py rename to packit/src/python/ui/dialogs/DeeplinkBottomSheets.py index 1aed7ee2..e4dc4815 100644 --- a/packit/src/ui/DeeplinkBottomSheets.py +++ b/packit/src/python/ui/dialogs/DeeplinkBottomSheets.py @@ -2,7 +2,7 @@ # SPDX-License-Identifier: GPL-3.0-or-later from packutil import logx -from ..utils.bulletins import factory as _pbf +from ...utils.Bulletins import factory as _pbf from android.view import View, MotionEvent from android.widget import LinearLayout, TextView, FrameLayout, ScrollView, ImageView from android.view import Gravity @@ -17,22 +17,22 @@ from org.telegram.ui.ActionBar import BottomSheet, Theme except Exception as e: import android_utils as _au; _au.log(f"import org.telegram.ui.ActionBar import BottomSheet, Theme failed: {e}") - from ....utils.importFailed import showImportFailedAlert as _sifa; _sifa() + from ...utils.ImportFailed import showImportFailedAlert as _sifa; _sifa() try: from org.telegram.ui.Components import LayoutHelper except Exception as e: import android_utils as _au; _au.log(f"import org.telegram.ui.Components import LayoutHelper failed: {e}") - from ....utils.importFailed import showImportFailedAlert as _sifa; _sifa() + from ...utils.ImportFailed import showImportFailedAlert as _sifa; _sifa() try: from org.telegram.messenger import AndroidUtilities except Exception as e: import android_utils as _au; _au.log(f"import org.telegram.messenger import AndroidUtilities failed: {e}") - from ....utils.importFailed import showImportFailedAlert as _sifa; _sifa() + from ...utils.ImportFailed import showImportFailedAlert as _sifa; _sifa() try: from elyx import strings except Exception as e: import android_utils as _au; _au.log(f"import elyx import strings failed: {e}") - from ....utils.importFailed import showImportFailedAlert as _sifa; _sifa() + from ...utils.ImportFailed import showImportFailedAlert as _sifa; _sifa() DEEPLINKS_DATA = { @@ -474,7 +474,7 @@ def copy_link(v): root.addView(close_btn, LayoutHelper.createLinear(-1, -2, 0, 16, 0, 8)) sheet.setCustomView(root) try: - from .viewUtils import applyFontToTree + from ..components.ViewUtils import applyFontToTree applyFontToTree(root) except Exception: pass diff --git a/packit/src/ui/ExportBottomSheet.py b/packit/src/python/ui/dialogs/ExportBottomSheet.py similarity index 94% rename from packit/src/ui/ExportBottomSheet.py rename to packit/src/python/ui/dialogs/ExportBottomSheet.py index 0718b6fa..a036b9a2 100644 --- a/packit/src/ui/ExportBottomSheet.py +++ b/packit/src/python/ui/dialogs/ExportBottomSheet.py @@ -2,7 +2,7 @@ # SPDX-License-Identifier: GPL-3.0-or-later from packutil import logx -from ..utils.ripple import safe_ripple as _safe_ripple +from ...utils.Ripple import safe_ripple as _safe_ripple import os import ast import re @@ -125,6 +125,39 @@ def _readPluginMeta(filepath): return meta +def _jstr(value) -> str: + try: + return "" if value is None else str(value) + except Exception: + return "" + + +def _hostPluginsByFile() -> dict: + # The host keeps a Plugin per installed id, filled by the engine from the + # plugin's own metadata, and exposes the icon as "pack/index" (Plugin.getIcon). + # We only read the first 5 KB of the file, so a header that sits further in — + # or a plugin whose file is not python source at all — leaves us without an + # icon; the registry has it either way. Keyed by file name so it lines up + # with the directory listing below. + index = {} + try: + from com.exteragram.messenger.plugins import PluginsController + controller = PluginsController.getInstance() + for entry in controller.getPlugins().entrySet(): + try: + plugin_id = _jstr(entry.getKey()) + plugin = entry.getValue() + if plugin is None or not plugin_id: + continue + path = _jstr(controller.getPluginPath(plugin_id)) + index[os.path.basename(path) if path else f"{plugin_id}.plugin"] = plugin + except Exception: + continue + except Exception as e: + logx(f"ExportBottomSheet._hostPluginsByFile: {e}", False) + return index + + def loadPlugins(): # returns list of (filename, name, version, icon) try: @@ -140,6 +173,7 @@ def loadPlugins(): return [] result = [] + host = _hostPluginsByFile() try: for fname in sorted(os.listdir(plugins_dir)): if not fname.endswith((".py", ".plugin")): @@ -151,6 +185,14 @@ def loadPlugins(): name = meta.get("__name__") or os.path.splitext(fname)[0] version = meta.get("__version__") or "" icon = meta.get("__icon__") or "" + plugin = host.get(fname) + if plugin is not None: + if not icon: + icon = _jstr(plugin.getIcon()) + if not meta.get("__name__"): + name = _jstr(plugin.getName()) or name + if not version: + version = _jstr(plugin.getVersion()) result.append((fname, name, version, icon)) except Exception as e: logx(f"ExportBottomSheet.loadPlugins: {e}\n{traceback.format_exc()}", False) @@ -246,7 +288,7 @@ def _createCheckRow(act, label, version_str, icon_str, checked, on_change): except Exception: pass row.addView(icon_view, LayoutHelper.createLinear(icon_size_dp, icon_size_dp, Gravity.CENTER_VERTICAL, 0, 0, 10, 0)) - from ..utils.stickers import load_sticker + from ...utils.Stickers import load_sticker load_sticker(icon_view, icon_str, icon_size_dp) except Exception as e: logx(f"ExportBottomSheet._createCheckRow: icon error: {e}\n{traceback.format_exc()}", False) @@ -746,7 +788,7 @@ def _onExport(): sheet.setCustomView(outer) try: - from .viewUtils import applyFontToTree + from ..components.ViewUtils import applyFontToTree applyFontToTree(outer) except Exception: pass diff --git a/packit/src/ui/FontPickerBottomSheet.py b/packit/src/python/ui/dialogs/FontPickerBottomSheet.py similarity index 98% rename from packit/src/ui/FontPickerBottomSheet.py rename to packit/src/python/ui/dialogs/FontPickerBottomSheet.py index db35fb37..2908c017 100644 --- a/packit/src/ui/FontPickerBottomSheet.py +++ b/packit/src/python/ui/dialogs/FontPickerBottomSheet.py @@ -35,7 +35,7 @@ logx(f"FontPickerBottomSheet: import AndroidUtilities failed: {e}", False) AndroidUtilities = None -from .FontManager import listFontFiles, setFont, getSelectedFilename +from ..components.FontManager import listFontFiles, setFont, getSelectedFilename from java import dynamic_proxy from android.view import MotionEvent @@ -240,7 +240,7 @@ def _createRadioIndicator(act, is_selected): def _loadTypefaceForFile(filename): # loads Typeface from res/fonts/filename, returns None on failure try: - from .FontManager import getFontPath + from ..components.FontManager import getFontPath from android.graphics import Typeface path = getFontPath(filename) if path: @@ -412,7 +412,7 @@ def _showStyleSheet(act, family, styles, selected_filename, parent_sheet, on_sel root.addView(_createCloseButton(act, sheet.dismiss), LayoutHelper.createLinear(-1, -2, 0, 16, 0, 8)) try: - from .viewUtils import applyFontToTree + from ..components.ViewUtils import applyFontToTree applyFontToTree(root) except Exception: pass @@ -486,7 +486,7 @@ def showFontPicker(act, on_select=None): root.addView(_createCloseButton(act, sheet.dismiss), LayoutHelper.createLinear(-1, -2, 0, 16, 0, 8)) try: - from .viewUtils import applyFontToTree + from ..components.ViewUtils import applyFontToTree applyFontToTree(root) except Exception: pass diff --git a/packit/src/ui/NoInternetBanner.py b/packit/src/python/ui/dialogs/NoInternetBanner.py similarity index 99% rename from packit/src/ui/NoInternetBanner.py rename to packit/src/python/ui/dialogs/NoInternetBanner.py index 6e21a24f..fc1a389e 100644 --- a/packit/src/ui/NoInternetBanner.py +++ b/packit/src/python/ui/dialogs/NoInternetBanner.py @@ -333,7 +333,7 @@ def _create_banner(self): except Exception: pass try: - from ..viewUtils import applyFont + from ..components.ViewUtils import applyFont applyFont(tv) except Exception: pass diff --git a/packit/src/ui/reportDialog.py b/packit/src/python/ui/dialogs/ReportDialog.py similarity index 96% rename from packit/src/ui/reportDialog.py rename to packit/src/python/ui/dialogs/ReportDialog.py index ff48ebf3..2051c930 100644 --- a/packit/src/ui/reportDialog.py +++ b/packit/src/python/ui/dialogs/ReportDialog.py @@ -2,7 +2,7 @@ # SPDX-License-Identifier: GPL-3.0-or-later from packutil import logx -from ..utils.bulletins import factory as _pbf +from ...utils.Bulletins import factory as _pbf import ctypes import json import os @@ -122,43 +122,14 @@ def onAnimationRepeat(self, a, *args): pass def _load_reasons(repo_id: str) -> list: - # loads reasons from cached repomap for given repo_id - if not repo_id: - return [] - try: - from ..utils.paths import getRepoCachePath - cache_path = getRepoCachePath(repo_id) - if not os.path.exists(cache_path): - return [] - with open(cache_path, "r", encoding="utf-8") as f: - cached = json.load(f) - reasons = cached.get("reasons", {}).get("reasons", []) - if isinstance(reasons, list): - return [str(r) for r in reasons if r] - return [] - except Exception as e: - logx(f"reportDialog: _load_reasons error: {e}", False) - return [] + from ...utils import CachedRepos + return CachedRepos.reasons(repo_id) def _load_report_settings(repo_id: str): - # returns (forum_username, topic_msg_id) or (None, None) - if not repo_id: - return None, None - try: - from ..utils.paths import getRepoCachePath - cache_path = getRepoCachePath(repo_id) - if not os.path.exists(cache_path): - return None, None - with open(cache_path, "r", encoding="utf-8") as f: - cached = json.load(f) - settings = cached.get("reasons", {}).get("settings", []) - if isinstance(settings, list) and len(settings) >= 2: - return str(settings[0]), int(settings[1]) - return None, None - except Exception as e: - logx(f"reportDialog: _load_report_settings error: {e}", False) - return None, None + # (forum_username, topic_msg_id), or (None, None) + from ...utils import CachedRepos + return CachedRepos.report_settings(repo_id) def _submit_report(forum_username: str, topic_msg_id: int, plugin_name: str, plugin_id: str, repo_id: str, reason: str, description: str, act, on_done): @@ -1060,7 +1031,7 @@ def _on_submit(v): card.setScaleY(0.92) try: - from .viewUtils import applyFontToTree + from ..components.ViewUtils import applyFontToTree applyFontToTree(card) except Exception: pass diff --git a/packit/src/ui/restartDialog.py b/packit/src/python/ui/dialogs/RestartDialog.py similarity index 98% rename from packit/src/ui/restartDialog.py rename to packit/src/python/ui/dialogs/RestartDialog.py index 0c45ff64..80ae6916 100644 --- a/packit/src/ui/restartDialog.py +++ b/packit/src/python/ui/dialogs/RestartDialog.py @@ -326,8 +326,8 @@ def _dismiss(on_end=None): def _do_restart(): try: - from ..deeplinks import pkill - pkill.handle("tg://packit?pkill") + from ...deeplinks import Pkill + Pkill.handle("tg://packit?pkill") except Exception as e: logx(f"restartDialog: pkill error: {e}", False) @@ -347,7 +347,7 @@ def _do_restart(): card.setScaleY(0.92) try: - from ..viewUtils import applyFontToTree + from ..components.ViewUtils import applyFontToTree applyFontToTree(card) except Exception: pass diff --git a/packit/src/python/ui/dialogs/__init__.py b/packit/src/python/ui/dialogs/__init__.py new file mode 100644 index 00000000..864b431f --- /dev/null +++ b/packit/src/python/ui/dialogs/__init__.py @@ -0,0 +1,4 @@ +# pyright: reportMissingImports=false +# SPDX-License-Identifier: GPL-3.0-or-later + +# Dialogs and bottom sheets that belong to no single screen. diff --git a/packit/src/ui/FilesActivity/fragment.py b/packit/src/python/ui/files/Fragment.py similarity index 99% rename from packit/src/ui/FilesActivity/fragment.py rename to packit/src/python/ui/files/Fragment.py index c10251c4..f32a2db4 100644 --- a/packit/src/ui/FilesActivity/fragment.py +++ b/packit/src/python/ui/files/Fragment.py @@ -2,7 +2,7 @@ # SPDX-License-Identifier: GPL-3.0-or-later from packutil import logx -from ...utils.ripple import safe_ripple as _safe_ripple +from ...utils.Ripple import safe_ripple as _safe_ripple import os from android.view import View, Gravity, MotionEvent from android.widget import LinearLayout, TextView, FrameLayout, ScrollView, ImageView, HorizontalScrollView @@ -90,7 +90,7 @@ def _restore_icon(icon_id): def _task(): try: - from .openFileFragment import _is_binary, open_file + from .OpenFileFragment import _is_binary, open_file if _is_binary(path): logx("filesActivity: binary file, showing sheet", True) if icon_view is not None: @@ -154,7 +154,7 @@ def _format_size(size): def _get_cache_root(): try: - from ...utils.paths import getCacheRoot + from ...utils.Paths import getCacheRoot return getCacheRoot() except Exception as e: logx(f"filesActivity: _get_cache_root error: {e}", False) @@ -977,7 +977,7 @@ def _get_file_info(path): def _show_file_info(act, path): try: - from .infoDialog import show_info_dialog + from .InfoDialog import show_info_dialog info = _get_file_info(path) name = os.path.basename(path) show_info_dialog(act, name, info) diff --git a/packit/src/ui/FilesActivity/infoDialog.py b/packit/src/python/ui/files/InfoDialog.py similarity index 98% rename from packit/src/ui/FilesActivity/infoDialog.py rename to packit/src/python/ui/files/InfoDialog.py index a8eb8546..17fc9270 100644 --- a/packit/src/ui/FilesActivity/infoDialog.py +++ b/packit/src/python/ui/files/InfoDialog.py @@ -2,8 +2,8 @@ # SPDX-License-Identifier: GPL-3.0-or-later from packutil import logx -from ...utils.ripple import safe_ripple as _safe_ripple -from ...utils.bulletins import factory as _pbf +from ...utils.Ripple import safe_ripple as _safe_ripple +from ...utils.Bulletins import factory as _pbf import ctypes from android_utils import run_on_ui_thread, OnClickListener @@ -366,7 +366,7 @@ def _dismiss(): card.setScaleY(0.92) try: - from ..viewUtils import applyFontToTree + from ..components.ViewUtils import applyFontToTree applyFontToTree(card) except Exception: pass diff --git a/packit/src/ui/FilesActivity/openFileFragment.py b/packit/src/python/ui/files/OpenFileFragment.py similarity index 98% rename from packit/src/ui/FilesActivity/openFileFragment.py rename to packit/src/python/ui/files/OpenFileFragment.py index 0cc2f44f..16024399 100644 --- a/packit/src/ui/FilesActivity/openFileFragment.py +++ b/packit/src/python/ui/files/OpenFileFragment.py @@ -173,7 +173,7 @@ def onTouch(self, v, event): sheet.setCustomView(container) try: - from ..viewUtils import applyFontToTree + from ..components.ViewUtils import applyFontToTree applyFontToTree(container) except Exception: pass @@ -220,7 +220,7 @@ def onFragmentDestroy(self, *_): self._load_cancelled = True self._highlight_cancelled = True try: - from ...dexLoader import openFileCancel + from ...core.DexLoader import openFileCancel if self._viewer_view is not None: openFileCancel(self._viewer_view) except Exception as e: @@ -411,7 +411,7 @@ def _tokenize(self): if ext not in (".json", ".py", ".plugin", ".java", ".kt"): return tt, ts, te, ck, cv try: - from .packlight import tokenizeJson, tokenizePython, tokenizeJava, tokenizeKotlin, _resolveColors + from .Packlight import tokenizeJson, tokenizePython, tokenizeJava, tokenizeKotlin, _resolveColors text = self._text if ext == ".json": result = tokenizeJson(text) @@ -446,7 +446,7 @@ def _attach_viewer(self, tt, ts, te, ck, cv): if self._load_cancelled or self._viewer_container is None: return try: - from ...dexLoader import openFileCreate, openFileCancel + from ...core.DexLoader import openFileCreate, openFileCancel # drop a previous viewer (rebuild after save) if self._viewer_view is not None: try: @@ -544,7 +544,7 @@ def _startHighlight(self): def _highlightBg(self, ext: str): try: - from .packlight import tokenizeJson, tokenizePython, tokenizeJava, tokenizeKotlin, _resolveColors + from .Packlight import tokenizeJson, tokenizePython, tokenizeJava, tokenizeKotlin, _resolveColors if self._highlight_cancelled: return text = self._text @@ -574,7 +574,7 @@ def _applyHighlightChunked(self, text: str, tokBuf, ranges, cnt: int, colors: di return try: from android.text import SpannableString - from .packlight import _applySpans + from .Packlight import _applySpans if offset == 0: self._spannable = SpannableString(text) diff --git a/packit/src/ui/FilesActivity/packlight.py b/packit/src/python/ui/files/Packlight.py similarity index 98% rename from packit/src/ui/FilesActivity/packlight.py rename to packit/src/python/ui/files/Packlight.py index e1e100cb..4721f333 100644 --- a/packit/src/ui/FilesActivity/packlight.py +++ b/packit/src/python/ui/files/Packlight.py @@ -72,7 +72,7 @@ def _loadLib(): global _lib if _lib is not None: return _lib - from ...nativeLoader import loadPackLight + from ...core.NativeLoader import loadPackLight _lib = loadPackLight() if _lib is not None: _setupArgtypes(_lib) diff --git a/packit/src/ui/AchievementsActivity/__init__.py b/packit/src/python/ui/files/__init__.py similarity index 100% rename from packit/src/ui/AchievementsActivity/__init__.py rename to packit/src/python/ui/files/__init__.py diff --git a/packit/src/ui/IconsListActivity/fragment.py b/packit/src/python/ui/icons/Fragment.py similarity index 91% rename from packit/src/ui/IconsListActivity/fragment.py rename to packit/src/python/ui/icons/Fragment.py index 10eecd9f..b323c87a 100644 --- a/packit/src/ui/IconsListActivity/fragment.py +++ b/packit/src/python/ui/icons/Fragment.py @@ -2,8 +2,8 @@ # SPDX-License-Identifier: GPL-3.0-or-later from packutil import logx -from ...utils.bulletins import factory as _pbf -from ...utils.netQueue import run_io +from ...utils.Bulletins import factory as _pbf +from ...utils.NetQueue import run_io import json import threading import re @@ -25,32 +25,32 @@ from elyx import settings, strings except Exception as e: import android_utils as _au; _au.log(f"import elyx import settings, strings failed: {e}") - from ...utils.importFailed import showImportFailedAlert as _sifa; _sifa() + from ...utils.ImportFailed import showImportFailedAlert as _sifa; _sifa() try: from org.telegram.ui.ActionBar import Theme except Exception as e: import android_utils as _au; _au.log(f"import org.telegram.ui.ActionBar import Theme failed: {e}") - from ...utils.importFailed import showImportFailedAlert as _sifa; _sifa() + from ...utils.ImportFailed import showImportFailedAlert as _sifa; _sifa() try: from org.telegram.ui.Components import LayoutHelper, EditTextBoldCursor except Exception as e: import android_utils as _au; _au.log(f"import org.telegram.ui.Components import LayoutHelper, EditTextBoldCursor failed: {e}") - from ...utils.importFailed import showImportFailedAlert as _sifa; _sifa() + from ...utils.ImportFailed import showImportFailedAlert as _sifa; _sifa() try: from org.telegram.messenger import AndroidUtilities, R as R_tg except Exception as e: import android_utils as _au; _au.log(f"import org.telegram.messenger import AndroidUtilities, R as R_tg failed: {e}") - from ...utils.importFailed import showImportFailedAlert as _sifa; _sifa() + from ...utils.ImportFailed import showImportFailedAlert as _sifa; _sifa() from android_utils import OnClickListener, OnLongClickListener try: from com.exteragram.messenger.plugins.ui.components.templates import UniversalFragment except Exception as e: import android_utils as _au; _au.log(f"import com.exteragram.messenger.plugins.ui.components.templates import UniversalFragment failed: {e}") - from ...utils.importFailed import showImportFailedAlert as _sifa; _sifa() + from ...utils.ImportFailed import showImportFailedAlert as _sifa; _sifa() from .RepoBottomSheet import show_icon_repo_sheet from .SortBottomSheet import show_icon_sort_menu -from ...utils import search as search_mod +from ...utils import Search as search_mod def _count_active_repos(repoManager) -> int: @@ -218,6 +218,7 @@ def _ui(q=q, filtered=filtered): outer.visible_icons = [] outer._card_registry = [] outer._ticker_started = False + outer._ticker_gen += 1 outer._preview_epoch += 1 if hasattr(outer, "subtitle"): outer.subtitle.setText(strings["icons_count"].format(len(filtered))) @@ -270,7 +271,7 @@ def onTextChanged(self, s, start, before, count): def _icons_on_clear_click(self, act): try: from elyx import assets - from ...utils.media import playSound + from ...utils.Media import playSound playSound(assets.sounds.clear_search.path_str, "sfx_clear_search") except Exception: pass @@ -287,7 +288,7 @@ def _icons_on_clear_click(self, act): def _icons_on_search_btn_click(self, act): try: from elyx import assets - from ...utils.media import playSound + from ...utils.Media import playSound playSound(assets.sounds.search_btn.path_str, "sfx_search") except Exception: pass @@ -308,9 +309,10 @@ def _icons_show_repo_menu(self, act): for r in (self.install_ui.plugin.repoManager.getRepositories() or []): if not r or not r.get("enabled"): continue - name = str(r.get("name") or "").strip() + # url only: a nameless source still serves icons, and the sheet + # already labels it "unnamed" url = str(r.get("url") or "").strip() - if name and url: + if url: repos.append(r) except Exception: pass @@ -353,7 +355,7 @@ def _icons_build_chrome_kotlin(self, act): # java-side chrome skeleton (kawaii.packetik.catalog.CatalogChromeNative. # createIconsChrome); returns (main_layout, scroll, clear_btn) or None -> # the python fallback builder runs instead - from ...dexLoader import catalogIconsChromeCreate + from ...core.DexLoader import catalogIconsChromeCreate live_search = bool(settings.get("live_search", True)) try: accent = Theme.getColor(Theme.key_featuredStickers_addButton) @@ -628,10 +630,10 @@ def open(self): if not r.get("enabled"): logx(f"IconList.open: skipping disabled repo '{name}'", True) continue - if name and url: + if url: repos.append(r) else: - logx(f"IconList.open: skipping repo with empty name or url: name='{name}' url='{url}'", True) + logx(f"IconList.open: skipping repo with empty url: name='{name}'", True) except Exception as e: logx(f"IconList.open: error processing repo: {e}", False) continue @@ -683,40 +685,18 @@ def load_task(): continue logx(f"IconList._open_all_repos_icons: loading repo '{repo.get('name')}' id='{repo_id}' url='{repo_url}'", True) try: - icons_url = repo_url - if repo_id: - try: - from org.telegram.messenger import ApplicationLoader - except Exception as e: - import android_utils as _au; _au.log(f"import ApplicationLoader failed: {e}") - from ...utils.importFailed import showImportFailedAlert as _sifa; _sifa() - import os - from ...utils.paths import getRepoCachePath - cache_path = getRepoCachePath(repo_id) - logx(f"IconList._open_all_repos_icons: cache_path='{cache_path}' exists={os.path.exists(cache_path)}", True) - if os.path.exists(cache_path): - with open(cache_path, "r", encoding="utf-8") as f: - cached = json.load(f) - icons_url = cached.get("repomap", {}).get("icons") or repo_url - logx(f"IconList._open_all_repos_icons: resolved icons_url from cache='{icons_url}'", True) - + from ...network import Storage + from ...utils import CachedRepos + icons_url = CachedRepos.icons_url(repo, repo_url) logx(f"IconList._open_all_repos_icons: fetching icons from '{icons_url}'", True) - response = requests.get(icons_url, timeout=10) - logx(f"IconList._open_all_repos_icons: repo '{repo.get('name')}' HTTP {response.status_code}", True) - if response.status_code != 200: - logx(f"IconList._open_all_repos_icons: repo '{repo.get('name')}': HTTP {response.status_code}, skipping", True) + entries, error = Storage.fetch_icons(icons_url) + if error: + logx(f"IconList._open_all_repos_icons: repo '{repo.get('name')}': {error}, skipping", True) continue - config = response.json() - icons = config.get("icons", {}) - logx(f"IconList._open_all_repos_icons: repo '{repo.get('name')}' icons type={type(icons).__name__} len={len(icons) if icons else 0}", True) - if isinstance(icons, dict): - for iconId, info in icons.items(): - if isinstance(info, dict): - all_icons.append({"id": iconId, "repo_name": repo.get("name", "Unknown"), **info}) - elif isinstance(icons, list): - for item in icons: - if isinstance(item, dict) and item.get("id"): - all_icons.append({"id": item.get("id"), "repo_name": repo.get("name", "Unknown"), **item}) + logx(f"IconList._open_all_repos_icons: repo '{repo.get('name')}' icons={len(entries)}", True) + repo_name = repo.get("name", "Unknown") + for entry in entries: + all_icons.append({"repo_name": repo_name, **entry}) except Exception as e: logx(f"IconList._open_all_repos_icons: failed to load repo '{repo.get('name')}': {e}", False) @@ -760,44 +740,21 @@ def _open_repo_icons(self, repo): def load_task(): try: - icons_url = repo_url - if repo_id: - try: - from org.telegram.messenger import ApplicationLoader - except Exception as e: - import android_utils as _au; _au.log(f"import ApplicationLoader failed: {e}") - from ...utils.importFailed import showImportFailedAlert as _sifa; _sifa() - import os - from ...utils.paths import getRepoCachePath - cache_path = getRepoCachePath(repo_id) - logx(f"IconList._open_repo_icons: cache_path='{cache_path}' exists={os.path.exists(cache_path)}", True) - if os.path.exists(cache_path): - with open(cache_path, "r", encoding="utf-8") as f: - cached = json.load(f) - icons_url = cached.get("repomap", {}).get("icons") or repo_url - logx(f"IconList._open_repo_icons: resolved icons_url from cache='{icons_url}'", True) - + from ...network import Storage + from ...utils import CachedRepos + icons_url = CachedRepos.icons_url(repo_id, repo_url) logx(f"IconList._open_repo_icons: fetching '{icons_url}'", True) - r = requests.get(icons_url, timeout=20) - logx(f"IconList._open_repo_icons: HTTP {r.status_code}", True) - if r.status_code != 200: - raise Exception(f"HTTP {r.status_code}") - config = r.json() - icons_raw = config.get("icons", []) - logx(f"IconList._open_repo_icons: icons_raw type={type(icons_raw).__name__} len={len(icons_raw) if icons_raw else 0}", True) - icons = [] - if isinstance(icons_raw, dict): - for iid, info in icons_raw.items(): - if isinstance(info, dict): - icons.append({"id": iid, **info}) - elif isinstance(icons_raw, list): - for item in icons_raw: - if isinstance(item, dict) and item.get("id"): - icons.append(item) - else: - logx(f"IconList._open_repo_icons: skipping list item (no id or not dict): {item}", True) - + icons, error = Storage.fetch_icons(icons_url) + if error: + raise Exception(error) logx(f"IconList._open_repo_icons: parsed icons count={len(icons)}", True) + # left behind for the sources screen: repomap points at this + # file by url and carries no count of its own + try: + from ...utils import RepoStats + RepoStats.remember(repo_id, icons=len(icons)) + except Exception as e: + logx(f"IconList._open_repo_icons: stats write failed: {e}", True) # index build is heavy — run it here on the queue thread prebuilt = search_mod.build_index(icons) run_on_ui_thread(lambda: self._update_current_fragment_icons(icons, prebuilt)) @@ -922,6 +879,9 @@ def __init__(self, install_ui, title, icons, show_loading_initial=False, repo_id # registry of (iv, loaded_list) for the global swap ticker self._card_registry = [] self._ticker_started = False + # bumped with the registry: a ticker whose generation is stale stops + # instead of animating cards from a discarded list forever + self._ticker_gen = 0 self._live_search_spinner = None # bumped on every list rebuild; preview tasks captured with an # older epoch abort instead of downloading for discarded cards @@ -929,7 +889,7 @@ def __init__(self, install_ui, title, icons, show_loading_initial=False, repo_id def onFragmentCreate(self, *_): try: - from ..NoInternetBanner import NoInternetBanner as _NIB + from ..dialogs.NoInternetBanner import NoInternetBanner as _NIB self._no_internet_banner = _NIB(None) def _on_restore(): @@ -1360,7 +1320,7 @@ def onScrollChange(self, v, scrollX, scrollY, oldScrollX, oldScrollY): main_layout.addView(scroll, LinearLayout.LayoutParams(-1, 0, 1.0)) self.search.addTextChangedListener(_IconsSearchTextWatcher(self, clear_btn, act)) try: - from ..viewUtils import applyFontToTree + from ..components.ViewUtils import applyFontToTree applyFontToTree(self.content_view) except Exception: pass @@ -1416,6 +1376,7 @@ def build_list_with_sort(self, sort_type: str, q=None): self.lazy_load_queue.clear() self._card_registry = [] self._ticker_started = False + self._ticker_gen += 1 self._preview_epoch += 1 if not q: @@ -1586,13 +1547,55 @@ def _start_ticker_if_needed(self): import random Runnable = find_class("java.lang.Runnable") registry = self._card_registry + delegate = self + # the ticker used to re-post itself on the first card's view: a + # detached view queues the runnable until it is attached again, so + # the ticker could stall — and a card left mid-crossfade then stays + # invisible forever. The heartbeat now runs on the main handler, + # which no view can hold up, and every tick heals stranded cards. + gen = self._ticker_gen ticker_runnable = [None] + # keeps runnables handed to java alive until they fire + pending = [] # track last swapped card and bitmap to prevent consecutive repeats last_iv = [None] last_bmp = [None] + def _runnable(fn): + class _R(dynamic_proxy(Runnable)): + def __init__(self): + super().__init__() + def run(self): + try: + fn() + finally: + try: + pending.remove(self) + except Exception: + pass + r = _R() + pending.append(r) + if len(pending) > 64: + del pending[:len(pending) - 64] + return r + + def _post(fn, delay): + AndroidUtilities.runOnUIThread(_runnable(fn), delay) + + def _restore_alpha(v): + # a crossfade that gets interrupted leaves the preview at alpha + # 0 — the card then shows its name and count with an empty icon, + # exactly like a preview that never loaded + try: + if v.getAlpha() < 1.0: + v.setAlpha(1.0) + except Exception: + pass + def tick(): try: + if delegate._ticker_gen != gen or delegate._card_registry is not registry: + return # the list was rebuilt; a newer ticker owns it candidates = [(iv, bitmaps) for iv, bitmaps in registry if len(bitmaps) >= 1] if candidates: # exclude last card if other options exist @@ -1605,48 +1608,56 @@ def tick(): bmp = random.choice(other_bmps if other_bmps else bitmaps) last_iv[0] = iv last_bmp[0] = bmp + def do_swap(v=iv, b=bmp): try: - fade_out_done = make_end_action(lambda: ( - v.setImageBitmap(b), - v.animate().alpha(1.0).setDuration(200).start() - )) - v.animate().alpha(0.0).setDuration(200).withEndAction(fade_out_done).start() + # heal anything a previous interrupted fade left + # behind, including the card we are about to use + for other_iv, _ in registry: + if other_iv is not v: + _restore_alpha(other_iv) + try: + v.animate().cancel() + except Exception: + pass + v.setAlpha(1.0) + + def _swap_in(): + try: + v.setImageBitmap(b) + v.animate().alpha(1.0).setDuration(200).start() + except Exception: + pass + # the fade-in can be interrupted too, so pin + # the end state instead of trusting it + _post(lambda: _restore_alpha(v), 260) + + v.animate().alpha(0.0).setDuration(200).start() + # driven by time, not by withEndAction: a cancelled + # animation drops its end action and the swap with it + _post(_swap_in, 210) except Exception: try: v.setImageBitmap(b) + v.setAlpha(1.0) except Exception: pass + run_on_ui_thread(do_swap) - try: - if registry: - registry[0][0].postDelayed(ticker_runnable[0], 2000) - except Exception: - pass + else: + for iv, _ in registry: + _restore_alpha(iv) + AndroidUtilities.runOnUIThread(ticker_runnable[0], 2000) except Exception as ex: logx(f"icons ticker: tick error: {ex}", True) - class _TickerRunnable(dynamic_proxy(Runnable)): - def __init__(self): - super().__init__() - def run(self): - tick() - - # helper: wrap lambda as Runnable for withEndAction - def make_end_action(fn): - class _R(dynamic_proxy(Runnable)): - def __init__(self): - super().__init__() - def run(self): - fn() - return _R() - - ticker_runnable[0] = _TickerRunnable() + ticker_runnable[0] = _runnable(tick) + # _runnable drops its ref once it fires, but the heartbeat is reused + pending.append(ticker_runnable[0]) def post_start(): try: - if registry: - registry[0][0].postDelayed(ticker_runnable[0], 2000) + AndroidUtilities.runOnUIThread(ticker_runnable[0], 2000) except Exception as ex: logx(f"icons ticker: post_start error: {ex}", True) run_on_ui_thread(post_start) @@ -1680,6 +1691,19 @@ def make_item(self, icon): iv_lp = LinearLayout.LayoutParams(icon_size_px, icon_size_px) iv_lp.bottomMargin = AndroidUtilities.dp(8) inner.addView(iv, iv_lp) + # neutral block until the first preview decodes, so a card that is + # still downloading reads as loading instead of as broken + try: + _ph_color = Theme.getColor(Theme.key_emptyListPlaceholder) + _ph = GradientDrawable() + _ph.setShape(GradientDrawable.RECTANGLE) + _ph.setCornerRadius(AndroidUtilities.dp(10)) + _ph.setColor(ctypes.c_int32( + (0x33 << 24) | (_ph_color & 0xFFFFFF) + ).value) + iv.setBackground(_ph) + except Exception: + pass name_tv = TextView(act) try: @@ -1694,7 +1718,7 @@ def make_item(self, icon): try: _q = getattr(self, "last_search_query", None) if _q: - from ..viewUtils import highlightQuery + from ..components.ViewUtils import highlightQuery _hl = highlightQuery( _display_name, str(_q), Theme.getColor(Theme.key_featuredStickers_addButton), @@ -1811,7 +1835,15 @@ def fetch_rest(rest): if bmp is not None: loaded.append(bmp) - def fetch_first(urls=all_urls): + def _bind_preview(b): + try: + iv.setImageBitmap(b) + iv.setBackground(None) + iv.setAlpha(1.0) + except Exception: + pass + + def fetch_first(urls=all_urls, attempt=0): # phase 1: get any single preview on screen ASAP for i, url in enumerate(urls): if delegate._preview_epoch != epoch: @@ -1820,11 +1852,19 @@ def fetch_first(urls=all_urls): if bmp is not None: loaded.append(bmp) if delegate._preview_epoch == epoch: - run_on_ui_thread(lambda b=bmp: iv.setImageBitmap(b)) + run_on_ui_thread(lambda b=bmp: _bind_preview(b)) rest = urls[i + 1:] if rest: _preview_pool_submit(lambda: fetch_rest(rest)) return + # every preview of this pack failed — a card used to stay empty + # for the rest of the session over one network blip, with no + # retry and nothing to tell it apart from a loaded card + if attempt == 0 and urls and delegate._preview_epoch == epoch: + logx(f"icons: no preview loaded for '{_display_name}', retrying", False) + threading.Timer( + 3.0, lambda: _preview_pool_submit(lambda: fetch_first(urls, 1)) + ).start() _preview_pool_submit(fetch_first) @@ -1832,7 +1872,7 @@ def fetch_first(urls=all_urls): card.setFocusable(True) def _on_click(v, _icon=icon): try: - from ...core import install_icon_pack + from ...core.Core import install_icon_pack install_icon_pack(_icon) except Exception as ex: logx(f"icons: card click error: {ex}", True) @@ -1863,7 +1903,7 @@ def _on_long_click(v, _icon=icon, _repo_id=self.repo_id): except Exception as _be: logx(f"icons: copy bulletin error: {_be}", True) try: - from ...ui.AchievementsActivity.service.AchivementsEngine import increment_category + from ..achievements.service.AchivementsEngine import increment_category increment_category("Copying links") except Exception: pass diff --git a/packit/src/ui/IconsListActivity/RepoBottomSheet.py b/packit/src/python/ui/icons/RepoBottomSheet.py similarity index 97% rename from packit/src/ui/IconsListActivity/RepoBottomSheet.py rename to packit/src/python/ui/icons/RepoBottomSheet.py index 46b73dc5..04cc1cc6 100644 --- a/packit/src/ui/IconsListActivity/RepoBottomSheet.py +++ b/packit/src/python/ui/icons/RepoBottomSheet.py @@ -15,22 +15,22 @@ from org.telegram.ui.ActionBar import BottomSheet, Theme except Exception as e: import android_utils as _au; _au.log(f"import org.telegram.ui.ActionBar import BottomSheet, Theme failed: {e}") - from ...utils.importFailed import showImportFailedAlert as _sifa; _sifa() + from ...utils.ImportFailed import showImportFailedAlert as _sifa; _sifa() try: from org.telegram.ui.Components import LayoutHelper except Exception as e: import android_utils as _au; _au.log(f"import org.telegram.ui.Components import LayoutHelper failed: {e}") - from ...utils.importFailed import showImportFailedAlert as _sifa; _sifa() + from ...utils.ImportFailed import showImportFailedAlert as _sifa; _sifa() try: from org.telegram.messenger import AndroidUtilities except Exception as e: import android_utils as _au; _au.log(f"import org.telegram.messenger import AndroidUtilities failed: {e}") - from ...utils.importFailed import showImportFailedAlert as _sifa; _sifa() + from ...utils.ImportFailed import showImportFailedAlert as _sifa; _sifa() try: from elyx import strings except Exception as e: import android_utils as _au; _au.log(f"import elyx import strings failed: {e}") - from ...utils.importFailed import showImportFailedAlert as _sifa; _sifa() + from ...utils.ImportFailed import showImportFailedAlert as _sifa; _sifa() def show_icon_repo_sheet(install_ui, repos, on_select=None): @@ -239,7 +239,7 @@ def on_all_click(v): sheet.setCustomView(root) try: - from ...ui.viewUtils import applyFontToTree + from ..components.ViewUtils import applyFontToTree applyFontToTree(root) except Exception: pass diff --git a/packit/src/ui/IconsListActivity/SortBottomSheet.py b/packit/src/python/ui/icons/SortBottomSheet.py similarity index 97% rename from packit/src/ui/IconsListActivity/SortBottomSheet.py rename to packit/src/python/ui/icons/SortBottomSheet.py index 8914645c..7fa28af9 100644 --- a/packit/src/ui/IconsListActivity/SortBottomSheet.py +++ b/packit/src/python/ui/icons/SortBottomSheet.py @@ -15,22 +15,22 @@ from elyx import settings, strings except Exception as e: import android_utils as _au; _au.log(f"import elyx import settings, strings failed: {e}") - from ...utils.importFailed import showImportFailedAlert as _sifa; _sifa() + from ...utils.ImportFailed import showImportFailedAlert as _sifa; _sifa() try: from org.telegram.ui.ActionBar import BottomSheet, Theme except Exception as e: import android_utils as _au; _au.log(f"import org.telegram.ui.ActionBar import BottomSheet, Theme failed: {e}") - from ...utils.importFailed import showImportFailedAlert as _sifa; _sifa() + from ...utils.ImportFailed import showImportFailedAlert as _sifa; _sifa() try: from org.telegram.ui.Components import LayoutHelper except Exception as e: import android_utils as _au; _au.log(f"import org.telegram.ui.Components import LayoutHelper failed: {e}") - from ...utils.importFailed import showImportFailedAlert as _sifa; _sifa() + from ...utils.ImportFailed import showImportFailedAlert as _sifa; _sifa() try: from org.telegram.messenger import AndroidUtilities except Exception as e: import android_utils as _au; _au.log(f"import org.telegram.messenger import AndroidUtilities failed: {e}") - from ...utils.importFailed import showImportFailedAlert as _sifa; _sifa() + from ...utils.ImportFailed import showImportFailedAlert as _sifa; _sifa() _SORT_ICONS = { "alpha_az": "msg_archive", diff --git a/packit/src/ui/AchievementsActivity/service/__init__.py b/packit/src/python/ui/icons/__init__.py similarity index 100% rename from packit/src/ui/AchievementsActivity/service/__init__.py rename to packit/src/python/ui/icons/__init__.py diff --git a/packit/src/ui/PluginActivity/fragment.py b/packit/src/python/ui/plugin/Fragment.py similarity index 99% rename from packit/src/ui/PluginActivity/fragment.py rename to packit/src/python/ui/plugin/Fragment.py index 676b64f4..030536c7 100644 --- a/packit/src/ui/PluginActivity/fragment.py +++ b/packit/src/python/ui/plugin/Fragment.py @@ -2,7 +2,7 @@ # SPDX-License-Identifier: GPL-3.0-or-later from packutil import logx -from ...utils.bulletins import factory as _pbf +from ...utils.Bulletins import factory as _pbf import ctypes import threading from android.view import Gravity, View @@ -36,7 +36,7 @@ def _get_saved_plugins_path() -> str: - from ...utils.paths import getConfigsDir + from ...utils.Paths import getConfigsDir import os d = getConfigsDir() os.makedirs(d, exist_ok=True) @@ -464,13 +464,13 @@ def _on_click(v): logx(f"pluginProfile: _build_team_card error: {_e}", True) -from .versionPicker import _show_version_picker +from .VersionPicker import _show_version_picker def _show_plugin_menu(act, p, anchor_view, repo_id: str = ""): try: - from ..PluginListActivity.helpers.PluginActions import share_plugin_file, view_plugin_code, download_plugin_file - from ..PluginListActivity.helpers.ReportService import report_plugin + from ..plugins.helpers.PluginActions import share_plugin_file, view_plugin_code, download_plugin_file + from ..plugins.helpers.ReportService import report_plugin from org.telegram.ui.Components import ItemOptions from org.telegram.ui.ActionBar import ActionBarMenuSubItem from org.telegram.messenger import R as R_tg, AndroidUtilities @@ -504,7 +504,7 @@ def _copy_and_dismiss(link): AndroidUtilities.addToClipboard(link) options.dismiss() try: - from ...ui.AchievementsActivity.service.AchivementsEngine import increment_category + from ..achievements.service.AchivementsEngine import increment_category increment_category("Copying links") except Exception as _ae: logx(f"pluginProfile: achievement increment error: {_ae}", True) @@ -584,7 +584,7 @@ def onFragmentCreate(self, *_): def onFragmentDestroy(self, *_): logx(f"pluginProfile: onFragmentDestroy plugin={self.plugin.get('id')}", True) try: - from ...ui.AchievementsActivity.service.AchivementsEngine import unregister_bulletin_container + from ..achievements.service.AchivementsEngine import unregister_bulletin_container unregister_bulletin_container(self.content_view) except Exception as e: logx(f"pluginProfile: unregister_bulletin_container error: {e}", False) @@ -688,7 +688,7 @@ def beforeCreateView(self): self.content_view.setBackgroundColor(bg_color) try: - from ...ui.AchievementsActivity.service.AchivementsEngine import register_bulletin_container + from ..achievements.service.AchivementsEngine import register_bulletin_container register_bulletin_container(self.content_view) except Exception as e: logx(f"pluginProfile: register_bulletin_container error: {e}", False) @@ -760,7 +760,7 @@ def overScrollBy(self, deltaX, deltaY, scrollX, scrollY, ) iv_lp.rightMargin = AndroidUtilities.dp(14) top_row.addView(iv, iv_lp) - from ...utils.stickers import load_sticker + from ...utils.Stickers import load_sticker load_sticker(iv, icon_str, sticker_size) info_col = LinearLayout(act) @@ -928,7 +928,7 @@ def _format_date(raw, prefix): deps = p.get("deps") or [] if has_link: - from ...utils.app_version import check_app_version as _check_app_version + from ...utils.AppVersion import check_app_version as _check_app_version plugin_app_ver = p.get("app_version") is_available = (not plugin_app_ver) or _check_app_version(plugin_app_ver) @@ -1050,7 +1050,7 @@ def _set_loading(_btn, _label, _btn_text_color, _act, isLoading): def _do_install(_p, _install_ui, _all, _btn, _label, _btn_text_color, _act, on_finish_override=None, succ_download=None): - from ...core import install_plugin + from ...core.Core import install_plugin if not on_finish_override: _set_loading(_btn, _label, _btn_text_color, _act, True) @@ -1058,7 +1058,7 @@ def _finish(ok): if ok: try: from elyx import assets - from ...utils.media import playSound + from ...utils.Media import playSound _snd = assets.sounds.install.path_str playSound(_snd, "sfx_install") except Exception: @@ -1203,8 +1203,8 @@ def onInstallClickFab(v, _p=p, _install_ui=_install_ui_ref, _all=_all_plugins_re if not versions: _do_install(_p, _install_ui, _all, _btn, _label, _btn_text_color, _act) return - from ...utils.app_version import check_app_version as _check_app_version2 - from .versionPicker import _build_version_entries + from ...utils.AppVersion import check_app_version as _check_app_version2 + from .VersionPicker import _build_version_entries all_entries = _build_version_entries(_p) hide_unavail = False try: @@ -1239,7 +1239,7 @@ def onInstallClickFab(v, _p=p, _install_ui=_install_ui_ref, _all=_all_plugins_re # rebind _do_install so its internal loader uses fab dimensions def _do_install(_p, _install_ui, _all, _btn, _label, _btn_text_color, _act, on_finish_override=None, succ_download=None): - from ...core import install_plugin + from ...core.Core import install_plugin if not on_finish_override: _set_loading_fab(_btn, _label, _btn_text_color, _act, True) @@ -1247,7 +1247,7 @@ def _finish(ok): if ok: try: from elyx import assets - from ...utils.media import playSound + from ...utils.Media import playSound _snd = assets.sounds.install.path_str playSound(_snd, "sfx_install") except Exception: @@ -1979,7 +1979,7 @@ def _make_text_btn_ext(icon_name, text): translate_btn_ext = _make_text_btn_ext("msg_replace", str(strings["translate"])) def onTranslateClickExt(v, _p=p, _readme=fetched_readme): - from ...utils.translation import translate_plugin + from ...utils.Translation import translate_plugin text = _readme[0] if _readme[0] else None translate_plugin(_p, text_override=text) translate_btn_ext.setOnClickListener(OnClickListener(onTranslateClickExt)) @@ -2103,7 +2103,7 @@ def _make_text_btn(icon_name, text): translate_btn = _make_icon_only_btn("msg_replace") def onTranslateClick(v, _p=p): - from ...utils.translation import translate_plugin + from ...utils.Translation import translate_plugin translate_plugin(_p) translate_btn.setOnClickListener(OnClickListener(onTranslateClick)) @@ -2568,7 +2568,7 @@ def onTranslateBarClick(v, _p=p): return import threading import requests as _req - from ...utils.translation import _show_translate_sheet + from ...utils.Translation import _show_translate_sheet from java.util import Locale def _set_loading(loading): @@ -3053,7 +3053,7 @@ def _get_client_color(client): ) iv_lp.rightMargin = AndroidUtilities.dp(10) dep_row.addView(dep_iv, iv_lp) - from ...utils.stickers import load_sticker + from ...utils.Stickers import load_sticker load_sticker(dep_iv, dep_icon_str, icon_size_dp) # status icon: msg_select green / msg_cancel red @@ -3262,7 +3262,7 @@ def onTouch(self, v, event): logx(f"pluginProfile: beforeCreateView done, content_view={self.content_view}", True) try: - from ..viewUtils import applyFontToTree + from ..components.ViewUtils import applyFontToTree applyFontToTree(self.content_view) except Exception: pass @@ -3330,7 +3330,7 @@ def _make_scroll_item(self, act, plugin, item_w, text_color): except Exception: pass col.addView(iv, icon_container_lp) - from ...utils.stickers import load_sticker + from ...utils.Stickers import load_sticker load_sticker(iv, icon_str, size_dp) else: # same placeholder as ImportBottomSheet: circle with plugins_filled icon @@ -3397,7 +3397,7 @@ def _make_mini_card(self, act, plugin, text_color, gray_color): iv_lp = LinearLayout.LayoutParams(AndroidUtilities.dp(size_dp), AndroidUtilities.dp(size_dp)) iv_lp.rightMargin = AndroidUtilities.dp(12) row.addView(iv, iv_lp) - from ...utils.stickers import load_sticker + from ...utils.Stickers import load_sticker load_sticker(iv, icon_str, size_dp) info = LinearLayout(act) @@ -3596,7 +3596,7 @@ def on_delete(v): import shutil import os import signal - from ...utils.paths import getPackItPluginDir + from ...utils.Paths import getPackItPluginDir try: shutil.rmtree(getPackItPluginDir(), ignore_errors=True) except Exception as e: diff --git a/packit/src/ui/PluginActivity/versionPicker.py b/packit/src/python/ui/plugin/VersionPicker.py similarity index 98% rename from packit/src/ui/PluginActivity/versionPicker.py rename to packit/src/python/ui/plugin/VersionPicker.py index e306cf30..cd7d4bc3 100644 --- a/packit/src/ui/PluginActivity/versionPicker.py +++ b/packit/src/python/ui/plugin/VersionPicker.py @@ -2,8 +2,8 @@ # SPDX-License-Identifier: GPL-3.0-or-later from packutil import logx -from ...utils.ripple import safe_ripple as _safe_ripple -from ...utils.bulletins import factory as _pbf +from ...utils.Ripple import safe_ripple as _safe_ripple +from ...utils.Bulletins import factory as _pbf import threading from android_utils import run_on_ui_thread, OnClickListener, OnLongClickListener from client_utils import get_last_fragment @@ -51,7 +51,7 @@ def _build_version_entries(plugin): # returns list sorted newest first def _ver_key(v): try: - from ..PluginListActivity.helpers.utils import _parse_version + from ..plugins.helpers.Utils import _parse_version return _parse_version(v) except Exception: return [] @@ -89,7 +89,7 @@ def _show_version_picker(act, plugin, install_ui, all_plugins, btn, label, btn_t from android.util import TypedValue from android.graphics.drawable import GradientDrawable from android_utils import OnClickListener - from ...utils.app_version import check_app_version as _check_app_version + from ...utils.AppVersion import check_app_version as _check_app_version import ctypes from java import dynamic_proxy @@ -102,7 +102,7 @@ def _show_version_picker(act, plugin, install_ui, all_plugins, btn, label, btn_t try: from elyx import settings as _s if _s.get("hide_unavailable_plugins", False): - from ...utils.app_version import check_app_version as _check_app_version + from ...utils.AppVersion import check_app_version as _check_app_version entries = [e for e in entries if not e["app_version"] or _check_app_version(e["app_version"])] except Exception: pass @@ -842,7 +842,7 @@ def _unwrap(): card.setScaleX(0.92) card.setScaleY(0.92) try: - from ..viewUtils import applyFontToTree + from ..components.ViewUtils import applyFontToTree applyFontToTree(card) except Exception: pass diff --git a/packit/src/ui/FilesActivity/__init__.py b/packit/src/python/ui/plugin/__init__.py similarity index 100% rename from packit/src/ui/FilesActivity/__init__.py rename to packit/src/python/ui/plugin/__init__.py diff --git a/packit/src/ui/PluginListActivity/card.py b/packit/src/python/ui/plugins/Card.py similarity index 94% rename from packit/src/ui/PluginListActivity/card.py rename to packit/src/python/ui/plugins/Card.py index 1bb1c936..668d43f1 100644 --- a/packit/src/ui/PluginListActivity/card.py +++ b/packit/src/python/ui/plugins/Card.py @@ -42,9 +42,9 @@ Browser = None from .helpers.PluginActions import copy_plugin_link, share_plugin_file, view_plugin_code, report_plugin, download_plugin_file, translate_plugin -from .filter.tagLayoutListener import _TagsOverflowListener -from .helpers.utils import _check_app_version -from ..viewUtils import highlightQuery as _highlight_query +from .filter.TagLayoutListener import _TagsOverflowListener +from .helpers.Utils import _check_app_version +from ..components.ViewUtils import highlightQuery as _highlight_query def make_plugin_card(self, p): @@ -135,7 +135,7 @@ def create_icon_pill(icon_name, handler): def onIconClick(v, plugin=p): try: - from ..PluginActivity.fragment import show_plugin_profile + from ..plugin.Fragment import show_plugin_profile show_plugin_profile(plugin, self.install_ui, self.plugins, repo_id=self.repo_id or str(plugin.get("_repo_id") or "")) except Exception as e: pass @@ -150,7 +150,7 @@ def onIconClick(v, plugin=p): # make_plugin_card runs off the UI thread (_load_initial_batch worker), # MediaDataController and BackupImageView.setImage must run on the UI thread - from ...utils.stickers import load_sticker + from ...utils.Stickers import load_sticker run_on_ui_thread(lambda: load_sticker(icon_view, icon_str, icon_size_dp)) except Exception as e: pass @@ -317,7 +317,7 @@ def onTagClick(v, url=tag_url): def onPlusClick(v, plugin=p): try: - from ..PluginActivity.fragment import show_plugin_profile + from ..plugin.Fragment import show_plugin_profile show_plugin_profile(plugin, self.install_ui, self.plugins, repo_id=self.repo_id or str(plugin.get("_repo_id") or ""), scroll_to_tags=True) @@ -479,7 +479,7 @@ def _position_and_show(): pass try: - from ..PluginActivity.fragment import show_plugin_profile + from ..plugin.Fragment import show_plugin_profile show_plugin_profile(plugin, self.install_ui, self.plugins, repo_id=self.repo_id or str(plugin.get("_repo_id") or "")) except Exception as e: pass @@ -487,7 +487,7 @@ def _position_and_show(): def onCardClick(v, plugin=p, row_ref=row, hint_ref=current_hint_ref, available=is_available): if not self._s_show_view_button: try: - from ..PluginActivity.fragment import show_plugin_profile + from ..plugin.Fragment import show_plugin_profile show_plugin_profile(plugin, self.install_ui, self.plugins, repo_id=self.repo_id or str(plugin.get("_repo_id") or "")) except Exception as e: pass @@ -556,7 +556,7 @@ def do_install(): pass return try: - from ...core import install_plugin + from ...core.Core import install_plugin install_plugin( p, install_ui=self.install_ui, @@ -569,7 +569,7 @@ def do_install(): def do_download_relocated(): download_plugin_file(p) try: - from ...ui.AchievementsActivity.service.AchivementsEngine import increment_category + from ..achievements.service.AchivementsEngine import increment_category increment_category("Downloading") except Exception as e: pass @@ -577,7 +577,7 @@ def do_download_relocated(): def do_copy_relocated(): copy_plugin_link(p, self.repo_id or self.title, copyLinkSoundPath) try: - from ...ui.AchievementsActivity.service.AchivementsEngine import increment_category + from ..achievements.service.AchivementsEngine import increment_category increment_category("Copying links") except Exception as e: pass @@ -585,7 +585,7 @@ def do_copy_relocated(): def do_share_relocated(): share_plugin_file(p, str(display_name), act_for_share) try: - from ...ui.AchievementsActivity.service.AchivementsEngine import increment_category + from ..achievements.service.AchivementsEngine import increment_category increment_category("Sharing") except Exception as e: pass @@ -593,7 +593,7 @@ def do_share_relocated(): def do_code_relocated(): view_plugin_code(p, act) try: - from ...ui.AchievementsActivity.service.AchivementsEngine import increment_category + from ..achievements.service.AchivementsEngine import increment_category increment_category("Viewing code") except Exception as e: pass @@ -604,7 +604,7 @@ def do_translate_relocated(): def do_report_relocated(): report_plugin(p, act, repo_id=self.repo_id or str(p.get("_repo_id") or "")) try: - from ...ui.AchievementsActivity.service.AchivementsEngine import increment_category + from ..achievements.service.AchivementsEngine import increment_category increment_category("Reporting") except Exception as e: pass @@ -628,10 +628,10 @@ def do_report_relocated(): def show_plugin_actions_menu(anchor_view): try: - from ..contextMenu import show_plugin_context_menu + from ..components.ContextMenu import show_plugin_context_menu def do_install(): - from ...core import install_plugin + from ...core.Core import install_plugin install_plugin( p, install_ui=self.install_ui, @@ -642,7 +642,7 @@ def do_install(): def do_download(): download_plugin_file(p) try: - from ...ui.AchievementsActivity.service.AchivementsEngine import increment_category + from ..achievements.service.AchivementsEngine import increment_category increment_category("Downloading") except Exception: pass @@ -650,7 +650,7 @@ def do_download(): def do_copy(): copy_plugin_link(p, self.repo_id or self.title, copyLinkSoundPath) try: - from ...ui.AchievementsActivity.service.AchivementsEngine import increment_category + from ..achievements.service.AchivementsEngine import increment_category increment_category("Copying links") except Exception: pass @@ -658,7 +658,7 @@ def do_copy(): def do_share(): share_plugin_file(p, str(display_name), act_for_share) try: - from ...ui.AchievementsActivity.service.AchivementsEngine import increment_category + from ..achievements.service.AchivementsEngine import increment_category increment_category("Sharing") except Exception: pass @@ -666,7 +666,7 @@ def do_share(): def do_code(): view_plugin_code(p, act) try: - from ...ui.AchievementsActivity.service.AchivementsEngine import increment_category + from ..achievements.service.AchivementsEngine import increment_category increment_category("Viewing code") except Exception: pass @@ -677,7 +677,7 @@ def do_translate(): def do_report(): report_plugin(p, act, repo_id=self.repo_id or str(p.get("_repo_id") or "")) try: - from ...ui.AchievementsActivity.service.AchivementsEngine import increment_category + from ..achievements.service.AchivementsEngine import increment_category increment_category("Reporting") except Exception: pass diff --git a/packit/src/ui/PluginListActivity/fragment.py b/packit/src/python/ui/plugins/Fragment.py similarity index 88% rename from packit/src/ui/PluginListActivity/fragment.py rename to packit/src/python/ui/plugins/Fragment.py index e88ece2e..7e67e262 100644 --- a/packit/src/ui/PluginListActivity/fragment.py +++ b/packit/src/python/ui/plugins/Fragment.py @@ -2,7 +2,7 @@ # SPDX-License-Identifier: GPL-3.0-or-later from packutil import logx -from ...utils.netQueue import run_io +from ...utils.NetQueue import run_io import re import json import threading @@ -18,7 +18,6 @@ from java import dynamic_proxy import os from hook_utils import find_class -import requests from android_utils import run_on_ui_thread from client_utils import get_last_fragment, run_on_queue from ui.bulletin import BulletinHelper @@ -26,28 +25,28 @@ from elyx import settings, strings except Exception as e: import android_utils as _au; _au.log(f"import elyx import settings, strings failed: {e}") - from ...utils.importFailed import showImportFailedAlert as _sifa; _sifa() + from ...utils.ImportFailed import showImportFailedAlert as _sifa; _sifa() try: from org.telegram.ui.ActionBar import Theme except Exception as e: import android_utils as _au; _au.log(f"import org.telegram.ui.ActionBar import Theme failed: {e}") - from ...utils.importFailed import showImportFailedAlert as _sifa; _sifa() + from ...utils.ImportFailed import showImportFailedAlert as _sifa; _sifa() try: from org.telegram.ui.Components import LayoutHelper, BackupImageView, EditTextBoldCursor except Exception as e: import android_utils as _au; _au.log(f"import org.telegram.ui.Components import LayoutHelper, BackupImageView, EditTextBoldCursor failed: {e}") - from ...utils.importFailed import showImportFailedAlert as _sifa; _sifa() + from ...utils.ImportFailed import showImportFailedAlert as _sifa; _sifa() try: from org.telegram.messenger import AndroidUtilities, MediaDataController, ImageLocation, R as R_tg except Exception as e: import android_utils as _au; _au.log(f"import org.telegram.messenger import AndroidUtilities, MediaDataController, ImageLocation, R as R_tg failed: {e}") - from ...utils.importFailed import showImportFailedAlert as _sifa; _sifa() + from ...utils.ImportFailed import showImportFailedAlert as _sifa; _sifa() from android_utils import OnClickListener try: from com.exteragram.messenger.plugins.ui.components.templates import UniversalFragment except Exception as e: import android_utils as _au; _au.log(f"import com.exteragram.messenger.plugins.ui.components.templates import UniversalFragment failed: {e}") - from ...utils.importFailed import showImportFailedAlert as _sifa; _sifa() + from ...utils.ImportFailed import showImportFailedAlert as _sifa; _sifa() try: from com.exteragram.messenger.utils.text import LocaleUtils except Exception as e: @@ -56,12 +55,12 @@ from org.telegram.ui.ActionBar import ActionBarPopupWindow except Exception as e: import android_utils as _au; _au.log(f"import org.telegram.ui.ActionBar import ActionBarPopupWindow failed: {e}") - from ...utils.importFailed import showImportFailedAlert as _sifa; _sifa() + from ...utils.ImportFailed import showImportFailedAlert as _sifa; _sifa() try: from android.net import Uri except Exception as e: import android_utils as _au; _au.log(f"import android.net import Uri failed: {e}") - from ...utils.importFailed import showImportFailedAlert as _sifa; _sifa() + from ...utils.ImportFailed import showImportFailedAlert as _sifa; _sifa() try: from org.telegram.messenger.browser import Browser except Exception: @@ -70,49 +69,49 @@ from androidx.core.content import ContextCompat except Exception as e: import android_utils as _au; _au.log(f"import androidx.core.content import ContextCompat failed: {e}") - from ...utils.importFailed import showImportFailedAlert as _sifa; _sifa() + from ...utils.ImportFailed import showImportFailedAlert as _sifa; _sifa() try: from android.graphics.drawable import GradientDrawable, RippleDrawable except Exception as e: import android_utils as _au; _au.log(f"import android.graphics.drawable import GradientDrawable, RippleDrawable failed: {e}") - from ...utils.importFailed import showImportFailedAlert as _sifa; _sifa() + from ...utils.ImportFailed import showImportFailedAlert as _sifa; _sifa() try: from android.graphics import Color as AColor, PorterDuff except Exception as e: import android_utils as _au; _au.log(f"import android.graphics import Color, PorterDuff failed: {e}") - from ...utils.importFailed import showImportFailedAlert as _sifa; _sifa() + from ...utils.ImportFailed import showImportFailedAlert as _sifa; _sifa() try: from android.content.res import ColorStateList as AColorStateList except Exception as e: import android_utils as _au; _au.log(f"import android.content.res import ColorStateList failed: {e}") - from ...utils.importFailed import showImportFailedAlert as _sifa; _sifa() + from ...utils.ImportFailed import showImportFailedAlert as _sifa; _sifa() try: from android.view import View as AView, Gravity as AGravity except Exception as e: import android_utils as _au; _au.log(f"import android.view import View, Gravity failed: {e}") - from ...utils.importFailed import showImportFailedAlert as _sifa; _sifa() + from ...utils.ImportFailed import showImportFailedAlert as _sifa; _sifa() try: from android.widget import FrameLayout as AFrame, LinearLayout as ALinear, TextView as AText, ImageView as AImage except Exception as e: import android_utils as _au; _au.log(f"import android.widget import FrameLayout, LinearLayout, TextView, ImageView failed: {e}") - from ...utils.importFailed import showImportFailedAlert as _sifa; _sifa() + from ...utils.ImportFailed import showImportFailedAlert as _sifa; _sifa() -from .helpers import uiHelpers +from .helpers import UiHelpers from .sheets.RepoBottomSheet import show_repo_sheet from .sheets.SortBottomSheet import show_sort_menu from .sheets.AISearchSheet import show_ai_search_sheet -from .filter.filterDrawer import show_tag_drawer -from ...utils import search as search_mod -from .filter import filterEngine as tag_mod +from .filter.FilterDrawer import show_tag_drawer +from ...utils import Search as search_mod +from .filter import FilterEngine as tag_mod from .helpers.PluginActions import copy_plugin_link, share_plugin_file, view_plugin_code, report_plugin, download_plugin_file, translate_plugin -from ...utils.media import playSound -from ...core import install_plugin -from . import card as _card +from ...utils.Media import playSound +from ...core.Core import install_plugin +from . import Card as _card -from .helpers.utils import ( +from .helpers.Utils import ( _count_active_repos, _plural_form, _format_plural, _build_stats_label, - _build_plugin_count_label, _is_filtered, _parse_version, _check_app_version, + _build_plugin_count_label, _parse_version, _check_app_version, _filter_unavailable, ) @@ -159,34 +158,34 @@ def _parse_github_url(self, url): return None, None def _apply_press_scale(self, view): - uiHelpers.apply_press_scale(view) + UiHelpers.apply_press_scale(view) def _apply_press_scale_on_target(self, view, target): - uiHelpers.apply_press_scale_on_target(view, target) + UiHelpers.apply_press_scale_on_target(view, target) def _create_close_button(self, act, text=None): - return uiHelpers.create_close_button(act, text) + return UiHelpers.create_close_button(act, text) def _setup_bottom_sheet(self, sheet): - uiHelpers.setup_bottom_sheet(sheet) + UiHelpers.setup_bottom_sheet(sheet) def _create_rounded_bg(self, color): - return uiHelpers.create_rounded_bg(color) + return UiHelpers.create_rounded_bg(color) def _format_file_size(self, bytes_val): - return uiHelpers.format_file_size(bytes_val) + return UiHelpers.format_file_size(bytes_val) def _make_info_chip(self, act, text, color_key, size_sp=11): - return uiHelpers.make_info_chip(act, text, color_key, size_sp) + return UiHelpers.make_info_chip(act, text, color_key, size_sp) def _create_pill(self, act, background, pressed, padding_h=14, padding_v=8): - return uiHelpers.create_pill(act, background, pressed, padding_h, padding_v) + return UiHelpers.create_pill(act, background, pressed, padding_h, padding_v) def _resolve_icon(self, name): - return uiHelpers.resolve_icon(name) + return UiHelpers.resolve_icon(name) def _get_theme_colors(self): - return uiHelpers.get_theme_colors() + return UiHelpers.get_theme_colors() def open(self): fragment = get_last_fragment() @@ -198,9 +197,12 @@ def open(self): try: if not r or not r.get("enabled"): continue - name = str(r.get("name") or "").strip() + # the url is what makes a repository usable; the name is a + # label, and requiring one meant a source someone had + # renamed to nothing dropped out of the catalogue — with + # every source nameless, the screen refused to open at all url = str(r.get("url") or "").strip() - if name and url: + if url: repos.append(r) except Exception: continue @@ -218,10 +220,10 @@ def open(self): show_repo_sheet(self, repos) def _create_circular_loading(self, act, size_dp=20): - return uiHelpers.create_circular_loading(act, size_dp) + return UiHelpers.create_circular_loading(act, size_dp) def _create_center_loading_animation(self, parent_layout): - return uiHelpers.create_center_loading_animation(parent_layout) + return UiHelpers.create_center_loading_animation(parent_layout) def _reload_current_plugins(self, repo_id=None): delegate = getattr(self, '_active_delegate', None) @@ -243,69 +245,35 @@ def load_task(): repo_url = (repo.get("url") or "").strip() if not repo_url: continue try: - plugins_url = repo_url - if r_id: - try: - from org.telegram.messenger import ApplicationLoader - except Exception as e: - pass - import os - from ...utils.paths import getRepoCachePath - cache_path = getRepoCachePath(r_id) - if os.path.exists(cache_path): - with open(cache_path, "r", encoding="utf-8") as f: - cached = json.load(f) - resolved = cached.get("repomap", {}).get("plugins") or repo_url - plugins_url = resolved - - response = requests.get(plugins_url, timeout=10) - if response.status_code != 200: continue - config = response.json() - plugins = config.get("plugins", {}) - if isinstance(plugins, dict): - for pluginId, info in plugins.items(): - if isinstance(info, dict): - all_plugins.append({"id": pluginId, "repo_name": repo.get("name", "Unknown"), "_repo_id": r_id, **info}) - elif isinstance(plugins, list): - for item in plugins: - if isinstance(item, dict) and item.get("id"): - all_plugins.append({"id": item.get("id"), "repo_name": repo.get("name", "Unknown"), "_repo_id": r_id, **item}) + from ...network import Storage + from ...utils import CachedRepos + entries, error = Storage.fetch_plugins( + CachedRepos.plugins_url(repo, repo_url)) + if error: + continue + repo_name = repo.get("name", "Unknown") + for entry in entries: + all_plugins.append( + {"repo_name": repo_name, "_repo_id": r_id, **entry}) except Exception as e: - pass + logx(f"InstallUI: repo '{r_id}' load failed: {e}", True) run_on_ui_thread(lambda: self._update_current_fragment_plugins(all_plugins)) else: repos = self.repoManager.getRepositories() repo = next((r for r in repos if r.get("id") == repo_id), None) if not repo: return - repo_url = (repo.get("url") or "").strip() - plugins_url = repo_url + from ...network import Storage + from ...utils import CachedRepos + plugins, error = Storage.fetch_plugins(CachedRepos.plugins_url(repo)) + if error: + raise Exception(error) + # the sources screen has no other way to know how big a + # source is: repomap only points at this file by url try: - from org.telegram.messenger import ApplicationLoader + from ...utils import RepoStats + RepoStats.remember(repo_id, plugins=len(plugins)) except Exception as e: - pass - import os - from ...utils.paths import getRepoCachePath - cache_path = getRepoCachePath(repo_id) - if os.path.exists(cache_path): - with open(cache_path, "r", encoding="utf-8") as f: - cached = json.load(f) - resolved = cached.get("repomap", {}).get("plugins") or repo_url - plugins_url = resolved - - r = requests.get(plugins_url, timeout=20) - if r.status_code != 200: - raise Exception(f"HTTP {r.status_code}") - config = r.json() - plugins_raw = config.get("plugins", []) - plugins = [] - if isinstance(plugins_raw, dict): - for pid, info in plugins_raw.items(): - if isinstance(info, dict): - plugins.append({"id": pid, **info}) - elif isinstance(plugins_raw, list): - for item in plugins_raw: - if isinstance(item, dict) and item.get("id"): - plugins.append(item) + logx(f"InstallUI: repo stats write failed: {e}", True) run_on_ui_thread(lambda: self._update_current_fragment_plugins(plugins)) except Exception as e: BulletinHelper.show_error(str(strings["pl_load_failed"])) @@ -434,10 +402,13 @@ def __init__(self, install_ui, title, plugins, show_loading_initial=False, repo_ self.is_loading = False self.scroll_listener = None self.current_sort_type = "alpha_az" - self.selected_tags = set() - self.selected_authors = set() - self.selected_app_versions = set() - self.selected_saved = {"saved", "unsaved"} + # None = untouched (no filter). A set is what the drawer applied, + # and an empty one means "nothing selected" — which shows nothing, + # not everything. + self.selected_tags = None + self.selected_authors = None + self.selected_app_versions = None + self.selected_saved = None self._active_drawer = None self.batch_size = 10 self._ai_result_active = False @@ -453,7 +424,7 @@ def __init__(self, install_ui, title, plugins, show_loading_initial=False, repo_ def onFragmentCreate(self, *_): try: - from ..NoInternetBanner import NoInternetBanner as _NIB + from ..dialogs.NoInternetBanner import NoInternetBanner as _NIB self._no_internet_banner = _NIB(None) def _on_restore(): @@ -489,7 +460,7 @@ def onFragmentDestroy(self, *_): logx(f"InstallUI: NoInternetBanner unregister error: {e}", False) try: if hasattr(self, 'content_view') and self.content_view is not None: - from ...ui.AchievementsActivity.service.AchivementsEngine import unregister_bulletin_container + from ..achievements.service.AchivementsEngine import unregister_bulletin_container unregister_bulletin_container(self.content_view) parent = self.content_view.getParent() if parent is not None: @@ -503,7 +474,7 @@ def onFragmentDestroy(self, *_): except Exception: pass try: - from ...utils.localConfig import LocalConfig + from ...utils.LocalConfig import LocalConfig showTgc = LocalConfig.get("showTgc", False) if not showTgc: count = LocalConfig.get("installUiOpenCount", 0) + 1 @@ -513,7 +484,7 @@ def onFragmentDestroy(self, *_): def _show(): try: - from .sheets.tgChannelSheet import show_tg_channel_sheet + from .sheets.TgChannelSheet import show_tg_channel_sheet frag = get_last_fragment() if not frag: return @@ -545,7 +516,7 @@ def beforeCreateView(self): # thread, freezing the fragment open animation for ~half a second. # Data arriving before the chrome is already handled by the # _data_ready_before_view flag consumed inside build_list_view. - from . import listView as _lv + from . import ListView as _lv from android_utils import run_on_ui_thread act = get_last_fragment().getContext() try: @@ -680,37 +651,47 @@ def build_list_with_sort(self, sort_type: str, q=None): scored.sort(key=lambda x: x[0]) filtered = [p for _, p in scored] - if self.selected_tags: - filtered = tag_mod.filter_by_tags(filtered, self.selected_tags) + # None = section never filtered. An empty selection is deliberate + # and matches nothing — the drawer no longer rewrites it to "all". + if self.selected_tags is not None: + filtered = tag_mod.filter_by_tags(filtered, self.selected_tags) if self.selected_tags else [] - if self.selected_authors: + if self.selected_authors is not None: all_authors = set( str(p.get("author") or "").strip() for p in self.plugins if str(p.get("author") or "").strip() and str(p.get("author") or "").strip().lower() != "unknown" ) - # no filter if all selected - if self.selected_authors < all_authors: + # everything selected is the same as no filter; anything less + # filters (">=" and not "<": a selection holding a stale name + # is still a filter, a strict-subset test called it none) + if not self.selected_authors: + filtered = [] + elif not (self.selected_authors >= all_authors): filtered = [ p for p in filtered if str(p.get("author") or "").strip() in self.selected_authors ] - if self.selected_app_versions: + if self.selected_app_versions is not None: all_versions = set( str(p.get("app_version") or "").strip() for p in self.plugins if str(p.get("app_version") or "").strip() and str(p.get("app_version") or "").strip().lower() != "unknown" ) - if self.selected_app_versions < all_versions: + if not self.selected_app_versions: + filtered = [] + elif not (self.selected_app_versions >= all_versions): filtered = [ p for p in filtered if str(p.get("app_version") or "").strip() in self.selected_app_versions ] - if hasattr(self, 'selected_saved') and self.selected_saved != {"saved", "unsaved"}: + if getattr(self, "selected_saved", None) is not None and not self.selected_saved: + filtered = [] + elif getattr(self, "selected_saved", None) is not None and self.selected_saved != {"saved", "unsaved"}: try: - from ..PluginActivity.fragment import _read_saved_plugins + from ..plugin.Fragment import _read_saved_plugins saved_ids = set(_read_saved_plugins()) show_saved = "saved" in self.selected_saved show_unsaved = "unsaved" in self.selected_saved @@ -732,7 +713,11 @@ def build_list_with_sort(self, sort_type: str, q=None): self.filtered_plugins = filtered if hasattr(self, 'subtitle'): total = len(self.plugins) - if _is_filtered(self): + # straight from the two lists. Asking a helper whether a filter + # "is active" meant the header could disagree with what the list + # actually holds: its tag universe left out the untagged bucket, + # so filtering by "Unsorted" (12 of 43) kept the header on 43. + if len(filtered) != total: self.subtitle.setText(f"{len(filtered)}/{_build_plugin_count_label(total)}") else: self.subtitle.setText(_build_plugin_count_label(total)) diff --git a/packit/src/ui/PluginListActivity/listView.py b/packit/src/python/ui/plugins/ListView.py similarity index 99% rename from packit/src/ui/PluginListActivity/listView.py rename to packit/src/python/ui/plugins/ListView.py index 1c95d5bb..5dcbb211 100644 --- a/packit/src/ui/PluginListActivity/listView.py +++ b/packit/src/python/ui/plugins/ListView.py @@ -32,12 +32,12 @@ except Exception as e: import android_utils as _au; _au.log(f"listView: import AndroidUtilities, R_tg failed: {e}") -from .helpers import uiHelpers +from .helpers import UiHelpers from .sheets.AISearchSheet import show_ai_search_sheet from .sheets.SortBottomSheet import show_sort_menu -from .filter.filterDrawer import show_tag_drawer -from ...utils.media import playSound -from .helpers.utils import _build_plugin_count_label +from .filter.FilterDrawer import show_tag_drawer +from ...utils.Media import playSound +from .helpers.Utils import _build_plugin_count_label @@ -274,7 +274,7 @@ def _build_chrome_kotlin(self, act): # java-side chrome skeleton (kawaii.packetik.catalog.CatalogChromeNative): # the same tree costs hundreds of bridge calls from python. Returns # (main_layout, scroll, clear_btn) or None -> python fallback below. - from ...dexLoader import catalogChromeCreate + from ...core.DexLoader import catalogChromeCreate from elyx import settings as _s live_search = bool(_s.get("live_search", True)) try: @@ -634,7 +634,7 @@ def build_list_view(self) -> View: self.content_view = FrameLayout(act) self.content_view.setBackgroundColor(self.main_bg_color) - from ...ui.AchievementsActivity.service.AchivementsEngine import register_bulletin_container + from ..achievements.service.AchivementsEngine import register_bulletin_container register_bulletin_container(self.content_view) chrome = None try: @@ -936,7 +936,7 @@ def onScrollChange(self, v, scrollX, scrollY, oldScrollX, oldScrollY): self.search.addTextChangedListener(_SearchTextWatcherWithClear(self, clear_btn)) try: - from ..viewUtils import applyFontToTree + from ..components.ViewUtils import applyFontToTree applyFontToTree(self.content_view) except Exception: pass diff --git a/packit/src/ui/IconsListActivity/__init__.py b/packit/src/python/ui/plugins/__init__.py similarity index 100% rename from packit/src/ui/IconsListActivity/__init__.py rename to packit/src/python/ui/plugins/__init__.py diff --git a/packit/src/ui/PluginListActivity/filter/filterDrawer.py b/packit/src/python/ui/plugins/filter/FilterDrawer.py similarity index 96% rename from packit/src/ui/PluginListActivity/filter/filterDrawer.py rename to packit/src/python/ui/plugins/filter/FilterDrawer.py index 8479eaea..fef950de 100644 --- a/packit/src/ui/PluginListActivity/filter/filterDrawer.py +++ b/packit/src/python/ui/plugins/filter/FilterDrawer.py @@ -12,7 +12,7 @@ from java import dynamic_proxy from hook_utils import find_class from android_utils import OnClickListener -from . import filterEngine +from . import FilterEngine try: from org.telegram.ui.ActionBar import Theme except Exception as e: @@ -156,7 +156,12 @@ def __init__(self, act, content_view, plugins, selected_tags, on_apply, self.on_apply = on_apply self._is_open = False - self._current_selected = set(selected_tags) if selected_tags else set() + # None means the section was never touched: it opens with everything + # selected, which is the same thing as no filter. An empty set is a + # real choice — every chip switched off — and has to stay empty + # instead of quietly flipping back to all-on, which looked like the + # filters resetting themselves. + self._current_selected = set(selected_tags) if selected_tags is not None else None self._tags_summary = {} self._tag_rows = {} # tag_name -> (row_view, border_drawable) self._tags_expanded = False @@ -164,17 +169,17 @@ def __init__(self, act, content_view, plugins, selected_tags, on_apply, self._authors_summary = {} self._author_rows = {} self._authors_expanded = False - self._current_authors = set(selected_authors) if selected_authors else set() + self._current_authors = set(selected_authors) if selected_authors is not None else None self._app_versions_summary = {} self._app_version_rows = {} self._app_versions_expanded = False - self._current_app_versions = set(selected_app_versions) if selected_app_versions else set() + self._current_app_versions = set(selected_app_versions) if selected_app_versions is not None else None # saved filter: set of "saved" and/or "unsaved", both active by default self._saved_rows = {} self._saved_expanded = False - self._current_saved = set(selected_saved) if selected_saved else {"saved", "unsaved"} + self._current_saved = set(selected_saved) if selected_saved is not None else None self._overlay = None self._drawer = None @@ -649,11 +654,16 @@ def on_reset(v): self._refresh_rows() def on_apply(v): + # a section that never populated stays None (= untouched), so it + # keeps meaning "no filter" instead of "nothing selected" + def _snapshot(sel): + return set(sel) if sel is not None else None + self.on_apply( - set(self._current_selected), - set(self._current_authors), - set(self._current_app_versions), - set(self._current_saved), + _snapshot(self._current_selected), + _snapshot(self._current_authors), + _snapshot(self._current_app_versions), + _snapshot(self._current_saved), ) self.close() @@ -837,7 +847,7 @@ def _populate_generic(self, section_key): self._authors_list.removeAllViews() self._author_rows.clear() self._authors_summary = _collect_authors(self.plugins) - if not self._current_authors: + if self._current_authors is None: self._current_authors = set(self._authors_summary.keys()) summary = self._authors_summary current_sel = self._current_authors @@ -858,7 +868,7 @@ def _populate_generic(self, section_key): } except Exception: pass - if not self._current_saved: + if self._current_saved is None: self._current_saved = {"saved", "unsaved"} for key, label in saved_items.items(): is_sel = key in self._current_saved @@ -883,7 +893,7 @@ def handler(v): self._app_versions_list.removeAllViews() self._app_version_rows.clear() self._app_versions_summary = _collect_app_versions(self.plugins) - if not self._current_app_versions: + if self._current_app_versions is None: self._current_app_versions = set(self._app_versions_summary.keys()) summary = self._app_versions_summary current_sel = self._current_app_versions @@ -916,14 +926,14 @@ def _populate_tags(self): try: self._tags_list.removeAllViews() self._tag_rows.clear() - self._tags_summary = filterEngine.collect_tags(self.plugins) + self._tags_summary = FilterEngine.collect_tags(self.plugins) - if not self._current_selected: + if self._current_selected is None: self._current_selected = set(self._tags_summary.keys()) for tag_name, count in self._tags_summary.items(): # use localized label for unsorted, keep key internal - if tag_name == filterEngine._UNSORTED_KEY: + if tag_name == FilterEngine._UNSORTED_KEY: try: from elyx import strings as _s display_name = str(_s["filter_tag_unsorted"]) @@ -953,14 +963,19 @@ def handler(v): logx(f"SortDrawer._populate_tags error: {e}", False) def _refresh_rows(self): + # a section can still be None here if its populate failed + tags = self._current_selected or set() + authors = self._current_authors or set() + versions = self._current_app_versions or set() + saved = self._current_saved or set() for tag_name, (row, border, name_tv) in self._tag_rows.items(): - self._update_row_style(border, name_tv, tag_name in self._current_selected) + self._update_row_style(border, name_tv, tag_name in tags) for key, (row, border, name_tv) in self._author_rows.items(): - self._update_row_style(border, name_tv, key in self._current_authors) + self._update_row_style(border, name_tv, key in authors) for key, (row, border, name_tv) in self._app_version_rows.items(): - self._update_row_style(border, name_tv, key in self._current_app_versions) + self._update_row_style(border, name_tv, key in versions) for key, (row, border, name_tv) in self._saved_rows.items(): - self._update_row_style(border, name_tv, key in self._current_saved) + self._update_row_style(border, name_tv, key in saved) def _register_back_callback(self): try: @@ -992,7 +1007,7 @@ def _unregister_back_callback(self): def open(self, selected_tags, selected_authors=None, selected_app_versions=None, selected_saved=None): try: - self._current_selected = set(selected_tags) if selected_tags else set() + self._current_selected = set(selected_tags) if selected_tags is not None else None if selected_authors is not None: self._current_authors = set(selected_authors) if selected_app_versions is not None: diff --git a/packit/src/ui/PluginListActivity/filter/filterEngine.py b/packit/src/python/ui/plugins/filter/FilterEngine.py similarity index 100% rename from packit/src/ui/PluginListActivity/filter/filterEngine.py rename to packit/src/python/ui/plugins/filter/FilterEngine.py diff --git a/packit/src/ui/PluginListActivity/filter/tagLayoutListener.py b/packit/src/python/ui/plugins/filter/TagLayoutListener.py similarity index 74% rename from packit/src/ui/PluginListActivity/filter/tagLayoutListener.py rename to packit/src/python/ui/plugins/filter/TagLayoutListener.py index 0430c4e0..995a6cdc 100644 --- a/packit/src/ui/PluginListActivity/filter/tagLayoutListener.py +++ b/packit/src/python/ui/plugins/filter/TagLayoutListener.py @@ -6,8 +6,17 @@ def _chip_extent(child) -> int: - # laid-out width plus the trailing margin the row adds after it - w = child.getWidth() + # The width the chip WANTS, plus the trailing margin the row adds after it. + # + # Not getWidth(): once the chips overrun the row the last one is laid out + # squeezed into whatever is left, so reading its width back makes the total + # add up to exactly the row width. Overflow then looked like a perfect fit, + # "+N" stayed hidden and the squeezed chip remained on the card as a sliver + # — visible on cards with four tags once the search-match badge narrows the + # row. + w = _measure_width(child) + if w <= 0: + w = child.getWidth() try: w += child.getLayoutParams().rightMargin except Exception: @@ -16,8 +25,9 @@ def _chip_extent(child) -> int: def _measure_width(view) -> int: - # the "+N" chip starts GONE, so it has no laid-out width yet — measure it - # unconstrained to learn how much room it needs + # unconstrained measure: the natural width of a chip, ignoring how the row + # ended up laying it out (and the only way to size the "+N" chip, which + # starts GONE and therefore has no laid-out width at all) try: spec = View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED) view.measure(spec, spec) diff --git a/packit/src/ui/PluginActivity/__init__.py b/packit/src/python/ui/plugins/filter/__init__.py similarity index 100% rename from packit/src/ui/PluginActivity/__init__.py rename to packit/src/python/ui/plugins/filter/__init__.py diff --git a/packit/src/ui/PluginListActivity/helpers/PluginActions.py b/packit/src/python/ui/plugins/helpers/PluginActions.py similarity index 93% rename from packit/src/ui/PluginListActivity/helpers/PluginActions.py rename to packit/src/python/ui/plugins/helpers/PluginActions.py index c999b408..4fd4daeb 100644 --- a/packit/src/ui/PluginListActivity/helpers/PluginActions.py +++ b/packit/src/python/ui/plugins/helpers/PluginActions.py @@ -3,21 +3,21 @@ from packutil import logx -from ....utils.bulletins import factory as _pbf +from ....utils.Bulletins import factory as _pbf from client_utils import get_last_fragment from hook_utils import find_class from .ReportService import report_plugin -from ....utils.translation import translate_plugin +from ....utils.Translation import translate_plugin try: from org.telegram.messenger import AndroidUtilities, R as R_tg except Exception as e: import android_utils as _au; _au.log(f"import org.telegram.messenger import AndroidUtilities, R as R_tg failed: {e}") - from ....utils.importFailed import showImportFailedAlert as _sifa; _sifa() + from ....utils.ImportFailed import showImportFailedAlert as _sifa; _sifa() try: from elyx import strings except Exception as e: import android_utils as _au; _au.log(f"import elyx import strings failed: {e}") - from ....utils.importFailed import showImportFailedAlert as _sifa; _sifa() + from ....utils.ImportFailed import showImportFailedAlert as _sifa; _sifa() from android.net import Uri try: from org.telegram.messenger.browser import Browser @@ -30,7 +30,7 @@ def copy_plugin_link(plugin_info: dict, repo_title: str, sound_path: str = None): try: if sound_path: - from ....utils.media import playSound + from ....utils.Media import playSound playSound(sound_path, "sfx_copy_link") except Exception: pass @@ -50,7 +50,7 @@ def copy_plugin_link(plugin_info: dict, repo_title: str, sound_path: str = None) plugin_name = plugin_info.get("name") or plugin_info.get("id") or "Unknown" _pbf(container, resource_provider).createSimpleBulletin(R_tg.raw.voip_invite, strings("plugin_link_copied", plugin_name)).show() try: - from ....ui.AchievementsActivity.service.AchivementsEngine import increment_category + from ...achievements.service.AchivementsEngine import increment_category increment_category("Copying links") except Exception as e: logx(f"copy_plugin_link: achievements increment error: {e}", False) @@ -60,10 +60,10 @@ def copy_plugin_link(plugin_info: dict, repo_title: str, sound_path: str = None) def share_plugin_file(plugin_info: dict, display_name: str, activity): try: - from ....utils.share import share_plugin_file as _share_plugin_file + from ....utils.Share import share_plugin_file as _share_plugin_file _share_plugin_file(plugin_info, display_name, activity) try: - from ....ui.AchievementsActivity.service.AchivementsEngine import increment_category + from ...achievements.service.AchivementsEngine import increment_category increment_category("Sharing") except Exception as e: logx(f"share_plugin_file: achievements increment error: {e}", False) @@ -183,7 +183,7 @@ def _show_download_ok(path): strings("download_saved", folder=folder) ).show() try: - from ....ui.AchievementsActivity.service.AchivementsEngine import increment_category + from ...achievements.service.AchivementsEngine import increment_category increment_category("Downloading") except Exception as e: logx(f"download: achievements increment error: {e}", False) @@ -235,7 +235,7 @@ def view_plugin_code(plugin_info: dict, activity): _pbf(activity.getWindow().getDecorView(), None).createErrorBulletin(strings["failed_to_open_url"]).show() return try: - from ....ui.AchievementsActivity.service.AchivementsEngine import increment_category + from ...achievements.service.AchivementsEngine import increment_category increment_category("Viewing code") except Exception as e: logx(f"view_plugin_code: achievements increment error: {e}", False) diff --git a/packit/src/ui/PluginListActivity/helpers/ReportService.py b/packit/src/python/ui/plugins/helpers/ReportService.py similarity index 91% rename from packit/src/ui/PluginListActivity/helpers/ReportService.py rename to packit/src/python/ui/plugins/helpers/ReportService.py index fe017104..f1335db0 100644 --- a/packit/src/ui/PluginListActivity/helpers/ReportService.py +++ b/packit/src/python/ui/plugins/helpers/ReportService.py @@ -2,7 +2,7 @@ # SPDX-License-Identifier: GPL-3.0-or-later from packutil import logx -from ....utils.bulletins import factory as _pbf +from ....utils.Bulletins import factory as _pbf from android_utils import run_on_ui_thread from client_utils import get_last_fragment @@ -22,7 +22,7 @@ def report_plugin(plugin_info: dict, activity, repo_id: str = ""): rid = repo_id or str(plugin_info.get("repo_id") or plugin_info.get("_repo_id") or "") pid = str(plugin_info.get("id") or "") - from ....ui.reportDialog import _load_report_settings + from ...dialogs.ReportDialog import _load_report_settings from elyx import strings forum_username, topic_msg_id = _load_report_settings(rid) @@ -43,7 +43,7 @@ def _show_missing(field): run_on_ui_thread(lambda: _show_missing("topic_msg_id")) return - from ....ui.reportDialog import show_report_dialog + from ...dialogs.ReportDialog import show_report_dialog _name = name _rid = rid _pid = pid diff --git a/packit/src/ui/PluginListActivity/helpers/uiHelpers.py b/packit/src/python/ui/plugins/helpers/UiHelpers.py similarity index 100% rename from packit/src/ui/PluginListActivity/helpers/uiHelpers.py rename to packit/src/python/ui/plugins/helpers/UiHelpers.py diff --git a/packit/src/ui/PluginListActivity/helpers/utils.py b/packit/src/python/ui/plugins/helpers/Utils.py similarity index 74% rename from packit/src/ui/PluginListActivity/helpers/utils.py rename to packit/src/python/ui/plugins/helpers/Utils.py index 0d68c9be..e79327b0 100644 --- a/packit/src/ui/PluginListActivity/helpers/utils.py +++ b/packit/src/python/ui/plugins/helpers/Utils.py @@ -74,28 +74,6 @@ def _build_plugin_count_label(plugin_count: int) -> str: except Exception: return strings("plugin_many", plugin_count) -def _is_filtered(self_obj) -> bool: - # true if any filter reduces the full plugin set - all_tags = set() - all_authors = set() - all_versions = set() - for p in self_obj.plugins: - for t in (p.get("tags") or []): - if isinstance(t, list) and t: - all_tags.add(t[0]) - a = str(p.get("author") or "").strip() - if a and a.lower() != "unknown": - all_authors.add(a) - v = str(p.get("app_version") or "").strip() - if v and v.lower() != "unknown": - all_versions.add(v) - - tags_filtered = bool(self_obj.selected_tags) and self_obj.selected_tags < all_tags - authors_filtered = bool(self_obj.selected_authors) and self_obj.selected_authors < all_authors - versions_filtered = bool(self_obj.selected_app_versions) and self_obj.selected_app_versions < all_versions - saved_filtered = hasattr(self_obj, 'selected_saved') and self_obj.selected_saved != {"saved", "unsaved"} - return tags_filtered or authors_filtered or versions_filtered or saved_filtered - def _parse_version(v_str): try: return tuple(int(x) for x in str(v_str).strip().split(".")) @@ -103,7 +81,7 @@ def _parse_version(v_str): return (0,) def _check_app_version(app_version_expr): - from ....utils.app_version import check_app_version + from ....utils.AppVersion import check_app_version return check_app_version(app_version_expr) def _filter_unavailable(plugins): diff --git a/packit/src/ui/PluginListActivity/__init__.py b/packit/src/python/ui/plugins/helpers/__init__.py similarity index 100% rename from packit/src/ui/PluginListActivity/__init__.py rename to packit/src/python/ui/plugins/helpers/__init__.py diff --git a/packit/src/ui/PluginListActivity/sheets/AISearchSheet.py b/packit/src/python/ui/plugins/sheets/AISearchSheet.py similarity index 98% rename from packit/src/ui/PluginListActivity/sheets/AISearchSheet.py rename to packit/src/python/ui/plugins/sheets/AISearchSheet.py index dfc5cd2a..1047c6bf 100644 --- a/packit/src/ui/PluginListActivity/sheets/AISearchSheet.py +++ b/packit/src/python/ui/plugins/sheets/AISearchSheet.py @@ -2,8 +2,8 @@ # SPDX-License-Identifier: GPL-3.0-or-later from packutil import logx -from ....utils.ripple import safe_ripple as _safe_ripple -from ....utils.bulletins import factory as _pbf +from ....utils.Ripple import safe_ripple as _safe_ripple +from ....utils.Bulletins import factory as _pbf import ctypes import json import base64 @@ -37,7 +37,7 @@ def _load_gemini_cache() -> dict: try: - from ....utils.paths import getGeminiCachePath + from ....utils.Paths import getGeminiCachePath path = getGeminiCachePath() if not os.path.exists(path): return {} @@ -51,7 +51,7 @@ def _load_gemini_cache() -> dict: def _save_gemini_cache(cache: dict) -> None: try: - from ....utils.paths import getGeminiCachePath + from ....utils.Paths import getGeminiCachePath path = getGeminiCachePath() os.makedirs(os.path.dirname(path), exist_ok=True) with open(path, "w", encoding="utf-8") as f: @@ -92,8 +92,8 @@ def _get_device_id() -> str: def _load_gemini_key() -> "str | None": # returns full key string or None try: - from ....nativeLoader import loadPackitKey - from ....utils.paths import getKeysDir + from ....core.NativeLoader import loadPackitKey + from ....utils.Paths import getKeysDir except Exception as e: logx(f"AISearchSheet: _load_gemini_key import failed: {e}", False) return None @@ -824,7 +824,7 @@ def _on_error(): sheet.setCustomView(root) try: - from ...viewUtils import applyFontToTree + from ...components.ViewUtils import applyFontToTree applyFontToTree(root) except Exception as e: logx(f"AISearchSheet: applyFontToTree failed: {e}", False) diff --git a/packit/src/ui/PluginListActivity/sheets/depsSheet.py b/packit/src/python/ui/plugins/sheets/DepsSheet.py similarity index 99% rename from packit/src/ui/PluginListActivity/sheets/depsSheet.py rename to packit/src/python/ui/plugins/sheets/DepsSheet.py index be049468..8cea35b0 100644 --- a/packit/src/ui/PluginListActivity/sheets/depsSheet.py +++ b/packit/src/python/ui/plugins/sheets/DepsSheet.py @@ -2,7 +2,7 @@ # SPDX-License-Identifier: GPL-3.0-or-later from packutil import logx -from ....utils.bulletins import factory as _pbf +from ....utils.Bulletins import factory as _pbf from android.view import View, Gravity from android.widget import LinearLayout, TextView, FrameLayout, ImageView from android.util import TypedValue @@ -257,7 +257,7 @@ def on_cancel_click(v): sheet.setCustomView(root) try: - from ...viewUtils import applyFontToTree + from ...components.ViewUtils import applyFontToTree applyFontToTree(root) except Exception: pass @@ -326,7 +326,7 @@ def _make_dep_card(act, dep_id, dep_name, dep_version, dep_author, dep_min_versi icon_lp = LinearLayout.LayoutParams(icon_size_px, icon_size_px) icon_lp.rightMargin = AndroidUtilities.dp(10) main_row.addView(icon_view, icon_lp) - from ....utils.stickers import load_sticker + from ....utils.Stickers import load_sticker load_sticker(icon_view, dep_icon, icon_size_dp) except Exception as e: logx(f"depsSheet: icon init error for '{dep_id}': {e}", False) @@ -483,7 +483,7 @@ def _do_refresh(): logx(f"depsSheet: _do_refresh error for '{dep_id}': {e}", False) def on_install(v): - from ....core import install_plugin + from ....core.Core import install_plugin if observer_registered[0] is None: try: from java import dynamic_proxy diff --git a/packit/src/ui/PluginListActivity/sheets/RepoBottomSheet.py b/packit/src/python/ui/plugins/sheets/RepoBottomSheet.py similarity index 97% rename from packit/src/ui/PluginListActivity/sheets/RepoBottomSheet.py rename to packit/src/python/ui/plugins/sheets/RepoBottomSheet.py index 412fc2a5..af29b09b 100644 --- a/packit/src/ui/PluginListActivity/sheets/RepoBottomSheet.py +++ b/packit/src/python/ui/plugins/sheets/RepoBottomSheet.py @@ -15,22 +15,22 @@ from org.telegram.ui.ActionBar import BottomSheet, Theme except Exception as e: import android_utils as _au; _au.log(f"import org.telegram.ui.ActionBar import BottomSheet, Theme failed: {e}") - from ....utils.importFailed import showImportFailedAlert as _sifa; _sifa() + from ....utils.ImportFailed import showImportFailedAlert as _sifa; _sifa() try: from org.telegram.ui.Components import LayoutHelper except Exception as e: import android_utils as _au; _au.log(f"import org.telegram.ui.Components import LayoutHelper failed: {e}") - from ....utils.importFailed import showImportFailedAlert as _sifa; _sifa() + from ....utils.ImportFailed import showImportFailedAlert as _sifa; _sifa() try: from org.telegram.messenger import AndroidUtilities except Exception as e: import android_utils as _au; _au.log(f"import org.telegram.messenger import AndroidUtilities failed: {e}") - from ....utils.importFailed import showImportFailedAlert as _sifa; _sifa() + from ....utils.ImportFailed import showImportFailedAlert as _sifa; _sifa() try: from elyx import strings except Exception as e: import android_utils as _au; _au.log(f"import elyx import strings failed: {e}") - from ....utils.importFailed import showImportFailedAlert as _sifa; _sifa() + from ....utils.ImportFailed import showImportFailedAlert as _sifa; _sifa() def show_repo_sheet(install_ui, repos, on_select=None): @@ -247,7 +247,7 @@ def on_close(v): root.addView(close_btn, LayoutHelper.createLinear(-1, -2, 0, 8, 0, 0)) sheet.setCustomView(root) try: - from ...viewUtils import applyFontToTree + from ...components.ViewUtils import applyFontToTree applyFontToTree(root) except Exception: pass diff --git a/packit/src/ui/PluginListActivity/sheets/SortBottomSheet.py b/packit/src/python/ui/plugins/sheets/SortBottomSheet.py similarity index 97% rename from packit/src/ui/PluginListActivity/sheets/SortBottomSheet.py rename to packit/src/python/ui/plugins/sheets/SortBottomSheet.py index 7e1d1a5d..e8f9887b 100644 --- a/packit/src/ui/PluginListActivity/sheets/SortBottomSheet.py +++ b/packit/src/python/ui/plugins/sheets/SortBottomSheet.py @@ -15,22 +15,22 @@ from elyx import settings, strings except Exception as e: import android_utils as _au; _au.log(f"import elyx import settings, strings failed: {e}") - from ....utils.importFailed import showImportFailedAlert as _sifa; _sifa() + from ....utils.ImportFailed import showImportFailedAlert as _sifa; _sifa() try: from org.telegram.ui.ActionBar import BottomSheet, Theme except Exception as e: import android_utils as _au; _au.log(f"import org.telegram.ui.ActionBar import BottomSheet, Theme failed: {e}") - from ....utils.importFailed import showImportFailedAlert as _sifa; _sifa() + from ....utils.ImportFailed import showImportFailedAlert as _sifa; _sifa() try: from org.telegram.ui.Components import LayoutHelper except Exception as e: import android_utils as _au; _au.log(f"import org.telegram.ui.Components import LayoutHelper failed: {e}") - from ....utils.importFailed import showImportFailedAlert as _sifa; _sifa() + from ....utils.ImportFailed import showImportFailedAlert as _sifa; _sifa() try: from org.telegram.messenger import AndroidUtilities except Exception as e: import android_utils as _au; _au.log(f"import org.telegram.messenger import AndroidUtilities failed: {e}") - from ....utils.importFailed import showImportFailedAlert as _sifa; _sifa() + from ....utils.ImportFailed import showImportFailedAlert as _sifa; _sifa() _SORT_ICONS = { "alpha_az": "msg_archive", @@ -234,7 +234,7 @@ def on_close_sort(v): sort_sheet.setCustomView(sort_root) try: - from ...viewUtils import applyFontToTree + from ...components.ViewUtils import applyFontToTree applyFontToTree(sort_root) except Exception: pass diff --git a/packit/src/ui/PluginListActivity/sheets/tgChannelSheet.py b/packit/src/python/ui/plugins/sheets/TgChannelSheet.py similarity index 96% rename from packit/src/ui/PluginListActivity/sheets/tgChannelSheet.py rename to packit/src/python/ui/plugins/sheets/TgChannelSheet.py index debcf260..3badeb11 100644 --- a/packit/src/ui/PluginListActivity/sheets/tgChannelSheet.py +++ b/packit/src/python/ui/plugins/sheets/TgChannelSheet.py @@ -44,7 +44,7 @@ def _on_touch(v, event): def show_tg_channel_sheet(activity, resource_provider): try: from elyx import strings - from ....utils.localConfig import LocalConfig + from ....utils.LocalConfig import LocalConfig sheet = BottomSheet(activity, False, resource_provider) sheet.fixNavigationBar() @@ -86,7 +86,7 @@ def onClick(self, v): sheet.dismiss() LocalConfig.set("showTgc", True) try: - from ....ui.AchievementsActivity.service.AchivementsEngine import unlock_secret + from ...achievements.service.AchivementsEngine import unlock_secret unlock_secret("subscriber") except Exception as e: logx(f"tgChannelSheet: achievement unlock error: {e}", False) @@ -119,7 +119,7 @@ def onClick(self, v): scroll.addView(frame) sheet.setCustomView(scroll) try: - from ...viewUtils import applyFontToTree + from ...components.ViewUtils import applyFontToTree applyFontToTree(scroll) except Exception: pass diff --git a/packit/src/ui/PluginListActivity/filter/__init__.py b/packit/src/python/ui/plugins/sheets/__init__.py similarity index 100% rename from packit/src/ui/PluginListActivity/filter/__init__.py rename to packit/src/python/ui/plugins/sheets/__init__.py diff --git a/packit/src/python/ui/repos/Actions.py b/packit/src/python/ui/repos/Actions.py new file mode 100644 index 00000000..79525dba --- /dev/null +++ b/packit/src/python/ui/repos/Actions.py @@ -0,0 +1,328 @@ +# pyright: reportMissingImports=false +# SPDX-License-Identifier: GPL-3.0-or-later + +# Everything the Sources screen can do to a repository. +# +# The behaviour is the one the settings-list screen had — same confirmations, +# same bulletins, same easter eggs — only the entry points moved: per-card +# actions into the card's overflow menu, the bulk ones into the menu behind the +# summary row. The one cleanup: the screen no longer carries its own copy of +# RepositoryManager.updateAllCaches. + +from packutil import logx + +from android_utils import run_on_ui_thread +from client_utils import get_last_fragment +from hook_utils import find_class + +try: + from elyx import strings +except Exception as e: + import android_utils as _au; _au.log(f"repos actions: import elyx strings failed: {e}") +try: + from ui.alert import AlertDialogBuilder + from ui.bulletin import BulletinHelper +except Exception as e: + import android_utils as _au; _au.log(f"repos actions: import ui helpers failed: {e}") +try: + from org.telegram.messenger import R as R_tg +except Exception as e: + import android_utils as _au; _au.log(f"repos actions: import R failed: {e}") + +from ...utils.Bulletins import factory as _pbf +from ..components.ContextMenu import show_plugin_context_menu +from . import notify_repos_changed + + +def _bulletin(raw_name: str, text): + try: + frag = get_last_fragment() + container = frag.getParentActivity().getWindow().getDecorView() + rp = frag.getResourceProvider() + _pbf(container, rp).createSimpleBulletin(getattr(R_tg.raw, raw_name), str(text)).show() + except Exception as e: + logx(f"repos actions: bulletin error: {e}", True) + + +def open_url(act, url: str): + if not url: + return + try: + from android.net import Uri + from org.telegram.messenger.browser import Browser + Browser.openUrl(act, Uri.parse(url)) + except Exception as e: + logx(f"repos actions: open_url error: {e}", False) + try: + from android.content import Intent + from android.net import Uri + act.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url))) + except Exception as e2: + logx(f"repos actions: open_url fallback error: {e2}", False) + + +def copy_link(repo: dict): + url = str(repo.get("url") or "").strip() + try: + from org.telegram.messenger import AndroidUtilities + if url and AndroidUtilities.addToClipboard(url): + _bulletin("voip_invite", strings.repo_link_copied) + return + except Exception as e: + logx(f"repos actions: copy_link error: {e}", False) + BulletinHelper.show_error(str(strings.failed_to_copy)) + + +def _share_link(repo: dict) -> str: + # The link goes into a telegram message, so it has to survive that client's + # url detection: percent-encoding it whole made the message text look like + # a link but stopped resolving. Only the name is escaped, and only for the + # characters that would otherwise end or split the query. + name = str(repo.get("name") or "").strip() + for ch, esc in (("%", "%25"), ("&", "%26"), ("=", "%3D"), ("#", "%23"), (" ", "%20")): + name = name.replace(ch, esc) + url = str(repo.get("url") or "").strip() + # no icon= any more: it carried an R.drawable name, and the other side now + # takes the picture from the repomap. Links already sent with one still + # work — the argument is accepted and ignored. + return f"tg://packit?repo=add&name={name}&link={url}" + + +def share_repository(act, repo: dict): + # the deeplink the other client will resolve back into a repository + share_url = _share_link(repo) + try: + from java import jclass, dynamic_proxy + frag = get_last_fragment() + if not frag or not act: + return + ShareAlert = find_class("org.telegram.ui.Components.ShareAlert") + ShareDelegateClass = jclass("org.telegram.ui.Components.ShareAlert$ShareAlertDelegate") + + class ShareDelegate(dynamic_proxy(ShareDelegateClass)): + def __init__(self): + super().__init__() + + def didShare(self): + # the link went to a chat — saying it is in the clipboard, which + # is what this reported before, describes the other button + run_on_ui_thread(lambda: _bulletin("voip_invite", strings.repo_link_shared)) + + def didCopy(self): + # false: ShareAlert copies and reports it itself + return False + + alert = ShareAlert(act, None, share_url, True, share_url, False) + alert.setDelegate(ShareDelegate()) + frag.showDialog(alert) + except Exception as e: + logx(f"repos actions: share error: {e}", False) + BulletinHelper.show_error(str(strings.failed_to_copy)) + + +def delete_repository(act, delegate, repo: dict): + try: + builder = AlertDialogBuilder(act) + builder.set_title(str(strings.delete_repository_title)) + builder.set_message(str(strings.delete_repository_message)) + + def on_yes(b, w): + idx, _ = delegate._index_of(repo) + if idx < 0: + delegate.reload() + return + delegate.repoManager.removeRepository(idx) + delegate.reload() + + builder.set_positive_button(str(strings.delete_button), on_yes) + builder.set_negative_button(str(strings.close_button), lambda b, w: b.dismiss()) + try: + builder.make_button_red(AlertDialogBuilder.BUTTON_POSITIVE) + except Exception as e: + logx(f"repos actions: red button error: {e}", True) + builder.show() + except Exception as e: + logx(f"repos actions: delete error: {e}", False) + + +def refresh_all(delegate): + _bulletin("shared_link_enter", strings.repos_updating) + + def _done(): + run_on_ui_thread(lambda: (_bulletin("shared_link_enter", strings.update_repos_success), + delegate.reload())) + + try: + delegate.repoManager.updateAllCaches(on_complete=_done) + except Exception as e: + logx(f"repos actions: refresh_all error: {e}", False) + + +def export_repositories(act, delegate): + repos = delegate.repoManager.getRepositories() + links = [] + for repo in repos: + if not str(repo.get("url") or "").strip(): + continue + links.append(_share_link(repo)) + if not links: + BulletinHelper.show_error(str(strings.no_repositories_to_export)) + return + text = "\n\n".join(links) + try: + from org.telegram.messenger import AndroidUtilities + if AndroidUtilities.addToClipboard(text): + _bulletin("voip_invite", strings.repositories_exported) + return + except Exception as e: + logx(f"repos actions: export error: {e}", False) + BulletinHelper.show_error(str(strings.failed_to_copy)) + + +def toggle_all(delegate): + repos = delegate.repoManager.getRepositories() + if not repos: + return + target = not any(r.get("enabled", True) for r in repos) + for repo in repos: + repo["enabled"] = target + delegate.repoManager.setRepositories(repos) + delegate.reload() + + +def restore_default(delegate): + repos = delegate.repoManager.getRepositories() + if len(repos) >= 10: + BulletinHelper.show_error(str(strings.max_repositories_allowed)) + return + + def _done(restored): + def _ui(): + if restored: + BulletinHelper.show_success(str(strings.default_repo_restored)) + else: + BulletinHelper.show_info(str(strings.repo_default_already)) + delegate.reload() + run_on_ui_thread(_ui) + + delegate.repoManager.restoreDefaultRepository(on_done=_done) + + +def _easter_egg(act, message): + try: + builder = AlertDialogBuilder(act) + builder.set_title(str(strings.easter_egg_title)) + builder.set_message(str(message)) + builder.set_positive_button(str(strings.close_button), lambda b, w: b.dismiss()) + builder.show() + except Exception as e: + logx(f"repos actions: easter egg error: {e}", False) + + +def clear_all_except_first(act, delegate): + repos = delegate.repoManager.getRepositories() + if len(repos) <= 1: + _easter_egg(act, strings.easter_egg_clear_message) + return + try: + builder = AlertDialogBuilder(act) + builder.set_title(str(strings.clear_all_title)) + builder.set_message(str(strings.clear_all_message)) + + def on_yes(b, w): + delegate.repoManager.clearAllExceptFirst() + _bulletin("group_pip_delete_icon", strings.repositories_cleared) + delegate.reload() + + builder.set_positive_button(str(strings.clear_button), on_yes) + builder.set_negative_button(str(strings.close_button), lambda b, w: b.dismiss()) + try: + builder.make_button_red(AlertDialogBuilder.BUTTON_POSITIVE) + except Exception: + pass + builder.show() + except Exception as e: + logx(f"repos actions: clear all error: {e}", False) + + +def reset_repositories(act, delegate): + repos = delegate.repoManager.getRepositories() + if len(repos) <= 1: + _easter_egg(act, strings.easter_egg_reset_message) + return + try: + builder = AlertDialogBuilder(act) + builder.set_title(str(strings.reset_repositories_title)) + builder.set_message(str(strings.reset_repositories_message)) + + def on_yes(b, w): + delegate.repoManager.resetRepositories() + _bulletin("group_pip_delete_icon", strings.repositories_reset) + delegate.reload() + + builder.set_positive_button(str(strings.reset_button), on_yes) + builder.set_negative_button(str(strings.close_button), lambda b, w: b.dismiss()) + try: + builder.make_button_red(AlertDialogBuilder.BUTTON_POSITIVE) + except Exception: + pass + builder.show() + except Exception as e: + logx(f"repos actions: reset error: {e}", False) + + +def add_repository(act, delegate): + repos = delegate.repoManager.getRepositories() + if len(repos) >= 10: + BulletinHelper.show_error(str(strings.max_repositories_allowed)) + return + from .AddSheet import show_add_repo_dialog + show_add_repo_dialog(act, delegate) + + +def edit_repository(act, delegate, repo: dict): + from .AddSheet import show_edit_repo_dialog + show_edit_repo_dialog(act, delegate, repo) + + +def show_card_menu(act, delegate, repo: dict, anchor): + repos = delegate.repoManager.getRepositories() + items = [ + {"icon": "msg_edit", "text": str(strings.repo_edit), + "action": lambda: edit_repository(act, delegate, repo)}, + {"icon": "msg_copy", "text": str(strings.repo_copy_link), + "action": lambda: copy_link(repo)}, + {"icon": "msg_share", "text": str(strings.share_repository), + "action": lambda: share_repository(act, repo)}, + {"icon": "msg_delete", "text": str(strings.remove_repository), "red": True, + "show": len(repos) > 1, + "action": lambda: delete_repository(act, delegate, repo)}, + ] + try: + show_plugin_context_menu(anchor.getRootView(), anchor, items) + except Exception as e: + logx(f"repos actions: card menu error: {e}", False) + + +def show_bulk_menu(act, delegate, anchor): + repos = delegate.repoManager.getRepositories() + any_enabled = any(r.get("enabled", True) for r in repos) + items = [ + {"icon": "msg_retry", "text": str(strings.update_repositories), + "action": lambda: refresh_all(delegate)}, + {"icon": "msg_share", "text": str(strings.export_repositories), + "action": lambda: export_repositories(act, delegate)}, + {"icon": "msg_customize", + "text": str(strings.disable_all_repositories if any_enabled else strings.enable_all_repositories), + "action": lambda: toggle_all(delegate)}, + {"icon": "msg_reset", "text": str(strings.restore_default_repository), + "action": lambda: restore_default(delegate)}, + {"icon": "msg_clear", "text": str(strings.clear_all_except_first), "red": True, + "action": lambda: clear_all_except_first(act, delegate)}, + {"icon": "msg_delete", "text": str(strings.reset_repositories), "red": True, + "action": lambda: reset_repositories(act, delegate)}, + ] + try: + show_plugin_context_menu(anchor.getRootView(), anchor, items) + except Exception as e: + logx(f"repos actions: bulk menu error: {e}", False) diff --git a/packit/src/python/ui/repos/AddSheet.py b/packit/src/python/ui/repos/AddSheet.py new file mode 100644 index 00000000..d1870390 --- /dev/null +++ b/packit/src/python/ui/repos/AddSheet.py @@ -0,0 +1,519 @@ +# pyright: reportMissingImports=false +# SPDX-License-Identifier: GPL-3.0-or-later + +# Adding and editing a source. +# +# Built in the shape of the api-key dialog (SettingsActivity/service/ +# AddKeyDialog.py): dimmed overlay, a card that springs in, bold title, gray +# subtitle, outlined field, one accent button. That dialog's overlay, back +# handling, keyboard tracking and animations are imported rather than copied — +# there is no reason for a second implementation of any of them. +# +# What this one adds: a field can refuse to submit and say why in place (the +# old add dialog dismissed itself and then dropped a bulletin, so a typo cost +# you the whole form), the button carries a spinner while the repomap is being +# fetched, and the same dialog serves editing. + +from packutil import logx +import ctypes + +from android_utils import run_on_ui_thread, OnClickListener +from client_utils import get_last_fragment, run_on_queue +from java import dynamic_proxy + +try: + from elyx import strings +except Exception as e: + import android_utils as _au; _au.log(f"repos dialog: import elyx strings failed: {e}") +try: + from org.telegram.ui.ActionBar import Theme + from org.telegram.ui.Components import LayoutHelper, EditTextBoldCursor, OutlineTextContainerView + from org.telegram.messenger import AndroidUtilities, R as R_tg +except Exception as e: + import android_utils as _au; _au.log(f"repos dialog: import telegram classes failed: {e}") + +from ..settings.service.AddKeyDialog import ( + _register_back_cb, _unregister_back_cb, _animate_in, _animate_out, + _attach_keyboard_listener, _detach_keyboard_listener, +) +from ...utils.Bulletins import factory as _pbf +from ...core.RepositoryManager import REPO_NAME_MAX + +# addRepositoryWithUrl answers in lowercase english; the user gets their own +# language and, where possible, a hint at what to do about it +_REASONS = { + "file not found": "repo_err_not_found", + "forbidden": "repo_err_forbidden", + "unauthorized": "repo_err_forbidden", + "rate limited, try again later": "repo_err_rate_limited", + "redirected": "repo_err_redirect", + "permanently redirected": "repo_err_redirect", + "temporarily redirected": "repo_err_redirect", + "see other": "repo_err_redirect", + "request timeout": "repo_err_timeout", + "gateway timeout": "repo_err_timeout", + "server error": "repo_err_server", + "bad gateway": "repo_err_server", + "service unavailable": "repo_err_server", + "resource gone": "repo_err_not_found", + "invalid json": "repo_err_json", + "missing repometa": "repo_err_meta", + "missing rm_rid": "repo_err_rid", + "missing rm_name": "repo_err_name", + "cache write failed": "repo_err_cache", +} + + +def _c(color: int) -> int: + return ctypes.c_int32(color).value + + +def _theme(key: str, fallback: int = 0): + try: + return Theme.getColor(getattr(Theme, key)) + except Exception: + return fallback + + +def _s(key: str, fallback: str = "") -> str: + try: + return str(strings[key]) + except Exception: + return fallback + + +def _localize_reason(reason: str) -> str: + text = str(reason or "").strip() + low = text.lower() + key = _REASONS.get(low) + if key: + return _s(key, text) + if "connection" in low or "max retries" in low or "resolve" in low: + return _s("repo_err_network", text) + return _s("repo_err_http", "{0}").replace("{0}", text) + + +def _make_field(act, label: str, hint: str, value: str, uri: bool, max_length: int = 0): + from android.util import TypedValue + from android.text import InputType, TextUtils + from android.view import View + + dp = AndroidUtilities.dp + outline = OutlineTextContainerView(act) + outline.setText(label) + outline.animateSelection(0, False) + outline.setClipChildren(True) + outline.setClipToPadding(True) + + edit = EditTextBoldCursor(act) + edit.setHint(hint) + edit.setHintTextColor(_theme("key_windowBackgroundWhiteGrayText")) + edit.setTextColor(_theme("key_windowBackgroundWhiteBlackText")) + edit.setTextSize(TypedValue.COMPLEX_UNIT_DIP, 15) + edit.setBackground(None) + edit.setSingleLine(True) + edit.setHorizontallyScrolling(True) + edit.setInputType( + InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_URI if uri else InputType.TYPE_CLASS_TEXT + ) + if max_length > 0: + # storage clamps the name anyway; the filter is so the field stops + # taking characters instead of quietly dropping them on save + try: + from java import jarray + from android.text import InputFilter + edit.setFilters(jarray(InputFilter)([InputFilter.LengthFilter(max_length)])) + except Exception as e: + logx(f"repos addSheet: length filter unavailable: {e}", True) + if value: + edit.setText(value) + # cursor stays at the start: putting it at the end scrolls a long url so + # that its tail is what you see, and the beginning is the part worth + # reading + try: + edit.setSelection(0) + except Exception: + pass + try: + edit.setCursorColor(_theme("key_featuredStickers_addButton")) + edit.setCursorWidth(1.5) + except Exception: + pass + edit.setPadding(dp(4), dp(14), dp(4), dp(14)) + try: + edit.setEllipsize(TextUtils.TruncateAt.END) + except Exception: + pass + + class _FocusListener(dynamic_proxy(View.OnFocusChangeListener)): + def onFocusChange(self, v, hasFocus): + outline.animateSelection(1 if hasFocus else 0) + + edit.setOnFocusChangeListener(_FocusListener()) + + # A scrolled single-line TextView paints over its own padding, so a long url + # ran out from under the outline and over the border. The inset lives on a + # wrapper that clips to it instead, which the text cannot escape. + from android.widget import FrameLayout as _FrameLayout + holder = _FrameLayout(act) + holder.setPadding(dp(12), 0, dp(12), 0) + holder.setClipToPadding(True) + holder.setClipChildren(True) + holder.addView(edit, LayoutHelper.createFrame(-1, -2)) + + outline.addView(holder, LayoutHelper.createFrame(-1, -2)) + outline.attachEditText(edit) + return outline, edit + + +def _show_form_dialog(act, title: str, subtitle: str, fields: list, button_text: str, on_submit): + """ + fields — [{"label","hint","value","uri","max_length"}] + on_submit(values: list[str], ui) — ui.error(text) / ui.loading(bool) / ui.dismiss() + """ + try: + from android.widget import LinearLayout, TextView, FrameLayout + from android.view import Gravity, ViewGroup + from android.util import TypedValue + from android.graphics.drawable import GradientDrawable + + dp = AndroidUtilities.dp + decor = act.getWindow().getDecorView() + accent = _theme("key_featuredStickers_addButton") + + overlay_ref = [None] + back_cb_ref = [None] + kb_listener_ref = [None] + orig_mode_ref = [None] + busy = [False] + + overlay = FrameLayout(act) + overlay_ref[0] = overlay + overlay.setBackgroundColor(_c(0x99000000)) + overlay.setClickable(True) + overlay.setFocusable(True) + + card = LinearLayout(act) + card.setOrientation(LinearLayout.VERTICAL) + card.setClickable(True) + card.setFocusable(True) + card.setOnClickListener(OnClickListener(lambda v: None)) + card_bg = GradientDrawable() + card_bg.setShape(GradientDrawable.RECTANGLE) + card_bg.setCornerRadius(dp(16)) + card_bg.setColor(_theme("key_dialogBackground")) + card.setBackground(card_bg) + card.setPadding(dp(20), dp(24), dp(20), dp(20)) + + card_lp = FrameLayout.LayoutParams(-1, -2) + card_lp.gravity = Gravity.CENTER + card_lp.leftMargin = dp(32) + card_lp.rightMargin = dp(32) + overlay.addView(card, card_lp) + + def _dismiss(on_end=None): + _unregister_back_cb(back_cb_ref[0]) + back_cb_ref[0] = None + _detach_keyboard_listener(act, decor, kb_listener_ref[0], orig_mode_ref[0]) + kb_listener_ref[0] = None + orig_mode_ref[0] = None + _animate_out(overlay_ref, card, decor, on_end=on_end) + + def _dismiss_from_overlay(v): + if busy[0]: + return + try: + AndroidUtilities.hideKeyboard(edits[0]) + except Exception: + pass + _dismiss() + + overlay.setOnClickListener(OnClickListener(_dismiss_from_overlay)) + + title_tv = TextView(act) + title_tv.setText(title) + title_tv.setTextSize(TypedValue.COMPLEX_UNIT_DIP, 17) + title_tv.setTextColor(_theme("key_dialogTextBlack")) + title_tv.setGravity(Gravity.CENTER_HORIZONTAL) + try: + title_tv.setTypeface(AndroidUtilities.getTypeface("fonts/rmedium.ttf")) + except Exception: + pass + card.addView(title_tv, LayoutHelper.createLinear(-1, -2, 0, 0, 0, 6)) + + subtitle_tv = TextView(act) + subtitle_tv.setText(subtitle) + subtitle_tv.setTextSize(TypedValue.COMPLEX_UNIT_DIP, 13) + subtitle_tv.setTextColor(_theme("key_windowBackgroundWhiteGrayText")) + subtitle_tv.setGravity(Gravity.CENTER_HORIZONTAL) + card.addView(subtitle_tv, LayoutHelper.createLinear(-1, -2, 0, 0, 0, 20)) + + edits = [] + for i, spec in enumerate(fields): + outline, edit = _make_field( + act, spec.get("label", ""), spec.get("hint", ""), + spec.get("value", ""), bool(spec.get("uri")), + int(spec.get("max_length") or 0) + ) + card.addView(outline, LayoutHelper.createLinear(-1, -2, 0, 0, 0, 10)) + edits.append(edit) + + error_tv = TextView(act) + error_tv.setTextSize(TypedValue.COMPLEX_UNIT_DIP, 13) + error_tv.setTextColor(_theme("key_text_RedRegular", _c(0xFFEC5044))) + error_tv.setGravity(Gravity.CENTER_HORIZONTAL) + error_tv.setVisibility(8) # GONE + card.addView(error_tv, LayoutHelper.createLinear(-1, -2, 4, 0, 4, 6)) + + # the client's own button: setLoading() swaps the label for a spinner + # itself, so there is nothing to stack on top of the text + button = None + try: + frag = get_last_fragment() + from org.telegram.ui.Stories.recorder import ButtonWithCounterView + button = ButtonWithCounterView(act, True, frag.getResourceProvider() if frag else None) + button.setRound() + button.setText(button_text, False) + card.addView(button, LayoutHelper.createLinear(-1, 48, 0, 6, 0, 0)) + except Exception as e: + logx(f"repos dialog: counter button unavailable: {e}", True) + button = None + + button_tv = None + if button is None: + button = FrameLayout(act) + button.setClickable(True) + button.setFocusable(True) + try: + button.setBackground(Theme.createSimpleSelectorRoundRectDrawable( + dp(12), accent, _theme("key_featuredStickers_addButtonPressed", accent))) + except Exception: + pass + button_tv = TextView(act) + button_tv.setText(button_text) + button_tv.setTextSize(TypedValue.COMPLEX_UNIT_DIP, 15) + button_tv.setGravity(Gravity.CENTER) + button_tv.setPadding(dp(16), dp(14), dp(16), dp(14)) + button_tv.setTextColor(_theme("key_featuredStickers_buttonText")) + try: + button_tv.setTypeface(AndroidUtilities.getTypeface("fonts/rmedium.ttf")) + except Exception: + pass + button.addView(button_tv, FrameLayout.LayoutParams(-1, -2)) + card.addView(button, LayoutHelper.createLinear(-1, -2, 0, 6, 0, 0)) + + class _Ui: + def error(self, text): + def _apply(): + try: + if not text: + error_tv.setVisibility(8) + return + error_tv.setText(str(text)) + error_tv.setVisibility(0) + error_tv.setAlpha(0.0) + error_tv.animate().alpha(1.0).setDuration(160).start() + except Exception as e: + logx(f"repos dialog: error paint failed: {e}", False) + run_on_ui_thread(_apply) + + def loading(self, value): + busy[0] = bool(value) + + def _apply(): + try: + button.setEnabled(not value) + if button_tv is None: + button.setLoading(bool(value)) + else: + button_tv.setAlpha(0.4 if value else 1.0) + except Exception as e: + logx(f"repos dialog: loading paint failed: {e}", False) + run_on_ui_thread(_apply) + + def dismiss(self, on_end=None): + def _apply(): + try: + AndroidUtilities.hideKeyboard(edits[0] if edits else None) + except Exception: + pass + _dismiss(on_end=on_end) + run_on_ui_thread(_apply) + + ui = _Ui() + + def _submit(v): + if busy[0]: + return + values = [] + for edit in edits: + try: + values.append(str(edit.getText()).strip()) + except Exception: + values.append("") + ui.error(None) + try: + on_submit(values, ui) + except Exception as e: + logx(f"repos dialog: submit error: {e}", False) + ui.loading(False) + ui.error(_s("repo_err_unknown", "{0}").replace("{0}", str(e))) + + button.setOnClickListener(OnClickListener(_submit)) + + overlay.setAlpha(0.0) + card.setAlpha(0.0) + card.setScaleX(0.92) + card.setScaleY(0.92) + + decor.addView(overlay, ViewGroup.LayoutParams(-1, -1)) + back_cb_ref[0] = _register_back_cb(act, lambda: None if busy[0] else _dismiss()) + + listener, orig_mode = _attach_keyboard_listener(act, decor, card) + kb_listener_ref[0] = listener + orig_mode_ref[0] = orig_mode + + def _open(): + if edits: + edits[0].requestFocus() + _animate_in(overlay, card, on_end=lambda: ( + AndroidUtilities.showKeyboard(edits[0]) if edits else None)) + + run_on_ui_thread(_open) + except Exception as e: + logx(f"repos dialog: show error: {e}", False) + + +def _normalize_url(url: str) -> str: + text = str(url or "").strip() + if not text: + return "" + if text.startswith("http://") or text.startswith("https://"): + return text + if "://" in text: + return text # some other scheme, let the validator complain + return "https://" + text + + +def show_add_repo_dialog(act, delegate): + def _submit(values, ui): + url = _normalize_url(values[0]) + if not url: + ui.error(_s("repo_err_empty", "Enter a link")) + return + if not url.startswith(("http://", "https://")): + ui.error(_s("repo_err_scheme", "The link must start with https://")) + return + repos = delegate.repoManager.getRepositories() + if any(str(r.get("url") or "").strip() == url for r in repos): + ui.error(_s("repo_err_duplicate", "Already added")) + return + + ui.loading(True) + + def _task(): + repometa, reason = delegate.repoManager.addRepositoryWithUrl(url) + + def _done(): + ui.loading(False) + if reason: + ui.error(_localize_reason(reason)) + return + ui.dismiss(on_end=lambda: _added(delegate)) + + run_on_ui_thread(_done) + + run_on_queue(_task) + + _show_form_dialog( + act, + _s("repo_add_sheet_title", "New source"), + _s("repo_add_sheet_subtitle", "Paste a link to repomap.json"), + [{"label": _s("repo_add_field_url", "Link"), "hint": "https://…/repomap.json", + "value": "", "uri": True}], + str(strings.add_repository), + _submit, + ) + + +def _added(delegate): + try: + frag = get_last_fragment() + container = frag.getParentActivity().getWindow().getDecorView() + rp = frag.getResourceProvider() + _pbf(container, rp).createSimpleBulletin( + R_tg.raw.shared_link_enter, str(strings.repository_added)).show() + except Exception as e: + logx(f"repos dialog: added bulletin error: {e}", True) + try: + delegate.reload() + except Exception: + pass + + +def show_edit_repo_dialog(act, delegate, repo: dict): + def _submit(values, ui): + name = " ".join(str(values[0] or "").split()) + url = _normalize_url(values[1]) if len(values) > 1 else "" + if not name: + # a source saved without one showed up everywhere as "unnamed", and + # there is nothing here to fall back to — say so instead of storing it + ui.error(_s("repo_err_name_empty", "Enter a name")) + return + if not url: + ui.error(_s("repo_err_empty", "Enter a link")) + return + if not url.startswith(("http://", "https://")): + ui.error(_s("repo_err_scheme", "The link must start with https://")) + return + + idx, repos = delegate._index_of(repo) + if idx < 0: + ui.error(_s("repo_err_unknown", "{0}").replace("{0}", "gone")) + return + if any(i != idx and str(r.get("url") or "").strip() == url for i, r in enumerate(repos)): + ui.error(_s("repo_err_duplicate", "Already added")) + return + + changed_url = str(repo.get("url") or "").strip() != url + if name != str(repo.get("name") or ""): + delegate.repoManager.updateRepoField(idx, "name", name) + if not changed_url: + ui.dismiss(on_end=delegate.reload) + return + + # a new url is a new repomap: validate it before it replaces the old one + ui.loading(True) + + def _task(): + repometa, reason = delegate.repoManager.addRepositoryWithUrl(url) + + def _done(): + ui.loading(False) + if reason: + ui.error(_localize_reason(reason)) + return + # addRepositoryWithUrl appended a fresh entry; drop the old one + fresh_idx, _ = delegate._index_of(repo) + if fresh_idx >= 0: + delegate.repoManager.removeRepository(fresh_idx) + ui.dismiss(on_end=delegate.reload) + + run_on_ui_thread(_done) + + run_on_queue(_task) + + _show_form_dialog( + act, + _s("repo_sheet_edit_title", "Edit source"), + _s("repo_sheet_edit_sub", "Name and link"), + [ + {"label": str(strings.repo_name), "hint": str(strings.repo_name), + "value": str(repo.get("name") or ""), "uri": False, + "max_length": REPO_NAME_MAX}, + {"label": str(strings.repo_url), "hint": "https://…/repomap.json", + "value": str(repo.get("url") or ""), "uri": True}, + ], + _s("save_button", "Save"), + _submit, + ) diff --git a/packit/src/python/ui/repos/Card.py b/packit/src/python/ui/repos/Card.py new file mode 100644 index 00000000..efc121fc --- /dev/null +++ b/packit/src/python/ui/repos/Card.py @@ -0,0 +1,529 @@ +# pyright: reportMissingImports=false +# SPDX-License-Identifier: GPL-3.0-or-later + +# One repository card. +# +# A filled 16dp container, a header row and one row under it. It started out +# outlined, to read as a different kind of thing from the plugin card, but a +# hairline in the divider colour reads as a stray line and not as an edge. +# Everything the card shows comes from the repomap already sitting in +# reposCache — the card never touches the network. + +from packutil import logx +import ctypes + +from android.widget import LinearLayout, TextView, FrameLayout, ImageView +from android.view import View, Gravity +from android.text import TextUtils +from android.util import TypedValue +from android.graphics.drawable import GradientDrawable +from android_utils import OnClickListener +from java import dynamic_proxy + +try: + from org.telegram.messenger import AndroidUtilities + from org.telegram.ui.ActionBar import Theme + from org.telegram.ui.Components import LayoutHelper +except Exception as e: + import android_utils as _au; _au.log(f"repos card: import telegram classes failed: {e}") + AndroidUtilities = None + Theme = None + LayoutHelper = None + +try: + from elyx import strings +except Exception as e: + import android_utils as _au; _au.log(f"repos card: import elyx strings failed: {e}") + +from . import RepoIcon +from ..plugins.helpers.UiHelpers import ( + apply_press_scale_on_target, resolve_icon, +) + + +_ROW_H = 32 # every pill on the card is this tall: chips and round buttons alike + + +def _chip(ctx, text: str, tint: int): + # UiHelpers.make_info_chip fills at a third alpha and paints the label in a + # palette colour. Both are wrong here: the fill has to be solid, and the + # colour has to be the theme's, not a green borrowed from the avatar + # palette that no other pixel on the screen is using. + # + # The geometry is md3's assist chip — 32dp tall, fully rounded — which is + # also the size of the round buttons it shares a card with, so a chip and a + # button standing next to each other line up instead of nearly lining up. + surface = _theme("key_windowBackgroundWhite") + bg = GradientDrawable() + bg.setShape(GradientDrawable.RECTANGLE) + bg.setCornerRadius(float(AndroidUtilities.dp(_ROW_H) / 2)) + bg.setColor(RepoIcon.tonal(tint, surface, 0.16)) + tv = TextView(ctx) + tv.setText(text) + tv.setTextSize(TypedValue.COMPLEX_UNIT_DIP, 12) + tv.setTextColor(_alpha(tint, 0xFF)) + tv.setSingleLine(True) + tv.setGravity(Gravity.CENTER) + tv.setBackground(bg) + tv.setPadding(AndroidUtilities.dp(12), 0, AndroidUtilities.dp(12), 0) + try: + tv.setTypeface(AndroidUtilities.getTypeface("fonts/rmedium.ttf")) + except Exception: + pass + return tv + + +def _chip_lp(right_margin_dp=6): + lp = LinearLayout.LayoutParams(-2, AndroidUtilities.dp(_ROW_H)) + lp.rightMargin = AndroidUtilities.dp(right_margin_dp) + return lp + + +def _c(color: int) -> int: + return ctypes.c_int32(color).value + + +def _alpha(color: int, a: int) -> int: + return _c((a << 24) | (color & 0xFFFFFF)) + + +def _theme(key: str, fallback: int = 0): + try: + return Theme.getColor(getattr(Theme, key)) + except Exception: + return fallback + + +def _round_icon_button(ctx, icon_name: str, tint: int, on_click, + size_dp: int = 32, icon_dp: int = 16, translucent: bool = False): + # Opaque by default: a fill of accent-at-8-percent takes its colour from + # whatever happens to be behind the button, which on an animating card is + # not one thing. The overflow is the exception — it is a neutral grey on the + # card and is meant to sit back. + btn = FrameLayout(ctx) + btn.setClickable(True) + btn.setFocusable(True) + if translucent: + fill, pressed = _alpha(tint, 0x14), _alpha(tint, 0x28) + else: + surface = _theme("key_windowBackgroundWhite") + fill = RepoIcon.tonal(tint, surface, 0.16) + pressed = RepoIcon.tonal(tint, surface, 0.30) + bg = GradientDrawable() + bg.setShape(GradientDrawable.RECTANGLE) + bg.setCornerRadius(float(AndroidUtilities.dp(size_dp) / 2)) + bg.setColor(fill) + try: + btn.setBackground(Theme.createSimpleSelectorRoundRectDrawable( + AndroidUtilities.dp(size_dp) // 2, fill, pressed + )) + except Exception: + btn.setBackground(bg) + + iv = ImageView(ctx) + icon_id = resolve_icon(icon_name) + if icon_id: + iv.setImageResource(icon_id) + iv.setScaleType(ImageView.ScaleType.CENTER_INSIDE) + try: + iv.setColorFilter(tint) + except Exception: + pass + btn.addView(iv, FrameLayout.LayoutParams( + AndroidUtilities.dp(icon_dp), AndroidUtilities.dp(icon_dp), Gravity.CENTER + )) + btn.setOnClickListener(OnClickListener(lambda v: on_click())) + apply_press_scale_on_target(btn, btn) + return btn + + +def _card_background(enabled: bool): + # Filled, no outline. The border was there to set 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 key_windowBackgroundGray is + # what the client's own cards do and it is enough on its own. + surface = _theme("key_windowBackgroundWhite") + bg = GradientDrawable() + bg.setShape(GradientDrawable.RECTANGLE) + bg.setCornerRadius(float(AndroidUtilities.dp(16))) + bg.setColor(surface if enabled else _alpha(surface, 0x80)) + return bg + + +def make_repo_card(ctx, repo: dict, info: dict, callbacks: dict, handle: dict = None): + """ + repo — the stored dict (id / name / url / enabled) + info — read off the ui thread from reposCache: maintainer, telegram, + source, icon_url, plugins, icons, status ("loaded"/"missing") + callbacks — on_toggle(bool, repo), on_menu(anchor, repo), on_open(url) + handle — filled with {"view", "update"}; call update(repo, info) to + repaint this card in place instead of building another one + """ + enabled = bool(repo.get("enabled", True)) + accent = RepoIcon.accent_for(repo) + + card = LinearLayout(ctx) + card.setOrientation(LinearLayout.VERTICAL) + # Tighter top and bottom than the sides. The rows below the header end in + # 32dp circles that already carry their own ring of empty pixels, so a + # square 16dp all round measured as more air than it looked like it needed. + card.setPadding(AndroidUtilities.dp(16), AndroidUtilities.dp(14), + AndroidUtilities.dp(16), AndroidUtilities.dp(12)) + card.setClickable(True) + card.setFocusable(True) + # PluginCell does both of these on itself, and this is why: with exteraGram's + # new switch style the toggle is an md3 pill wider than the 37dp box it is + # laid out in, and Switch centres it, so it hangs over both edges by design. + # Every cell in the client that hosts one stops clipping — copying the box + # and the colours without this is what sheared the ends off ours. The 16dp + # padding has room to spare for the overhang. + card.setClipChildren(False) + card.setClipToPadding(False) + + # ---- header: avatar | name + maintainer | switch + header = LinearLayout(ctx) + header.setOrientation(LinearLayout.HORIZONTAL) + header.setGravity(Gravity.CENTER_VERTICAL) + header.setClipChildren(False) + header.setClipToPadding(False) + + def _icon_lp(): + lp = LinearLayout.LayoutParams(AndroidUtilities.dp(48), AndroidUtilities.dp(48)) + lp.gravity = Gravity.CENTER_VERTICAL + lp.rightMargin = AndroidUtilities.dp(12) + return lp + + icon_url = str(info.get("icon_url") or "") + icon_view = RepoIcon.build_icon_view(ctx, repo, 48, 14, icon_url) + icon_holder = [icon_view] # the avatar is swapped only when its url changes + header.addView(icon_view, _icon_lp()) + + col = LinearLayout(ctx) + col.setOrientation(LinearLayout.VERTICAL) + + name_tv = TextView(ctx) + name_tv.setText(str(repo.get("name") or strings.unnamed)) + name_tv.setTextSize(TypedValue.COMPLEX_UNIT_DIP, 17) + name_tv.setSingleLine(True) + # setSingleLine on its own only clips, and it clips mid-glyph: a name that + # is within the stored limit but still too wide for a narrow screen has to + # end in an ellipsis, not in half a letter + name_tv.setEllipsize(TextUtils.TruncateAt.END) + name_tv.setTextColor(_theme("key_windowBackgroundWhiteBlackText")) + try: + name_tv.setTypeface(AndroidUtilities.getTypeface("fonts/rmedium.ttf")) + except Exception: + try: + name_tv.setTypeface(AndroidUtilities.bold()) + except Exception: + pass + col.addView(name_tv, LayoutHelper.createLinear(-1, -2)) + + # always built, hidden when there is nothing to say: the card is repainted + # in place, and a row that only exists sometimes cannot be + sub_tv = TextView(ctx) + sub_tv.setTextSize(TypedValue.COMPLEX_UNIT_DIP, 13) + sub_tv.setSingleLine(True) + sub_tv.setEllipsize(TextUtils.TruncateAt.END) + sub_tv.setTextColor(_theme("key_windowBackgroundWhiteGrayText")) + col.addView(sub_tv, LayoutHelper.createLinear(-1, -2, 0, 2, 0, 0)) + + def _fill_sub(r, i): + text = str(i.get("maintainer") or "").strip() or _host_of(r.get("url")) + # Set up exactly the way the plugin catalogue sets up its author line + # (ui/plugins/Card.py): fullyFormatText, grey body, + # windowBackgroundWhiteBlueText for the mention, LinkMovementMethod. + # Left to itself the formatter paints mentions in its own colour, which + # is why these came out a teal that appears nowhere else on the screen. + try: + from com.exteragram.messenger.utils.text import LocaleUtils + from android.text.method import LinkMovementMethod + sub_tv.setText(LocaleUtils.fullyFormatText(text)) + sub_tv.setLinkTextColor(_theme("key_windowBackgroundWhiteBlueText")) + sub_tv.setMovementMethod(LinkMovementMethod.getInstance()) + except Exception as e: + logx(f"repos card: maintainer format unavailable: {e}", True) + sub_tv.setText(text) + sub_tv.setVisibility(0 if text else 8) # VISIBLE / GONE + + _fill_sub(repo, info) + + header.addView(col, LayoutHelper.createLinear(0, -2, 1.0, Gravity.CENTER_VERTICAL)) + + switch = _build_switch(ctx, enabled) + if switch is not None: + # Switch draws itself to the size of its view, so the box is not a + # matter of taste: at 56x48 the pill came out half again as big as the + # one the client draws everywhere else. It gets the client's own 37x40 + # back, and the larger touch target moves to a wrapper around it — + # 37x40 is under the 48dp a control should answer to, and this switch + # takes its own taps now. + # + # Explicit params, not LayoutHelper.createLinear(w, h, gravity, …): that + # call has a (w, h, float weight, …) twin, and picking it gives the + # switch a weight instead of a gravity. In a row that already has a + # weighted column the switch then absorbs the overflow and is measured + # narrower than its own track, which shears the ends off the pill. + sw_wrap = FrameLayout(ctx) + sw_wrap.setClipChildren(False) + sw_wrap.setClickable(True) + sw_wrap.setFocusable(True) + sw_wrap.setOnClickListener(OnClickListener(lambda v: _toggle())) + # Switch has no touch handling of its own — the cells that host it drive + # its ripple from their own setPressed, so the wrapper does the same. + # The listener returns False, leaving the click to the normal path. + try: + from android.view import View as _View + + class _Press(dynamic_proxy(_View.OnTouchListener)): + def onTouch(self, v, event): + action = event.getActionMasked() + if action == 0: # DOWN + switch.setDrawRipple(True) + elif action in (1, 3): # UP, CANCEL + switch.setDrawRipple(False) + return False + + sw_wrap.setOnTouchListener(_Press()) + except Exception as e: + logx(f"repos card: switch ripple unavailable: {e}", True) + + # right-aligned inside the wrapper, not centred: the wrapper is 19dp + # wider than the switch purely to be easier to hit, and centring it + # pushed the pill 9dp in from the card's content edge — out of line with + # the overflow button directly below it. The slack goes leftward, which + # is the side a thumb arrives from anyway. + sw_inner = FrameLayout.LayoutParams(AndroidUtilities.dp(37), AndroidUtilities.dp(40)) + sw_inner.gravity = Gravity.RIGHT | Gravity.CENTER_VERTICAL + sw_wrap.addView(switch, sw_inner) + + sw_lp = LinearLayout.LayoutParams(AndroidUtilities.dp(56), AndroidUtilities.dp(48)) + sw_lp.gravity = Gravity.CENTER_VERTICAL + sw_lp.leftMargin = AndroidUtilities.dp(6) + header.addView(sw_wrap, sw_lp) + + card.addView(header, LayoutHelper.createLinear(-1, -2)) + + # One row under the header, not two. Splitting "when it was fetched" from + # the buttons put a line of content against a line of nothing twice over: + # the left half of the card below the avatar was empty for its whole + # height. Everything that is left of a card here is small enough to stand + # on one line — the label takes the slack, the pills sit at the end. + on_open = callbacks.get("on_open") or (lambda _u: None) + + def _btn_lp(right_margin_dp=6): + lp = LinearLayout.LayoutParams(AndroidUtilities.dp(_ROW_H), AndroidUtilities.dp(_ROW_H)) + lp.rightMargin = AndroidUtilities.dp(right_margin_dp) + return lp + + # ---- counts, when there are any: these are the one thing that can run to + # three pills at once, which no single line survives, so they get a line of + # their own and it is simply absent the rest of the time + chips = LinearLayout(ctx) + chips.setOrientation(LinearLayout.HORIZONTAL) + chips.setGravity(Gravity.CENTER_VERTICAL) + + def _fill_chips(is_on, i): + chips.removeAllViews() + # No on/off chip. It said in words what the switch an inch away says by + # being on or off, and two controls reporting one fact is one too many. + # A source whose repomap never downloaded still gets a chip, because + # nothing else on the card says that — it is switched on and gives + # nothing, which the switch cannot show. + if is_on and str(i.get("status") or "") == "missing": + # the one chip that is not the accent: this is a fault, and the + # theme has a colour for those + chips.addView( + _chip(ctx, str(getattr(strings, "repo_card_status_missing", "Not loaded")), + _theme("key_text_RedBold")), _chip_lp()) + plugins = i.get("plugins") + if isinstance(plugins, int): + chips.addView( + _chip(ctx, str(strings.repo_card_plugins).replace("{0}", str(plugins)), accent), + _chip_lp()) + icons_n = i.get("icons") + if isinstance(icons_n, int): + chips.addView( + _chip(ctx, str(strings.repo_card_icons).replace("{0}", str(icons_n)), accent), + _chip_lp()) + chips.setVisibility(0 if chips.getChildCount() else 8) + + card.addView(chips, LayoutHelper.createLinear(-1, -2, 0, 10, 0, 0)) + + # ---- the one row: the installed pill on the left, the buttons on the right + # + # The fetch time used to hold this left side. It was the wrong thing to + # give a whole row to — the pill beside it kept squeezing it down to + # "обновлена т…", and no one opens this screen to read a timestamp. It + # moved into the sheet the card opens, which is where a detail belongs. + footer = LinearLayout(ctx) + footer.setOrientation(LinearLayout.HORIZONTAL) + footer.setGravity(Gravity.CENTER_VERTICAL) + + installed_box = LinearLayout(ctx) + installed_box.setOrientation(LinearLayout.HORIZONTAL) + installed_box.setGravity(Gravity.CENTER_VERTICAL) + footer.addView(installed_box, LayoutHelper.createLinear(-2, -2)) + + footer.addView(View(ctx), LayoutHelper.createLinear(0, 0, 1.0)) + + def _fill_installed(i): + # always drawn, zero included: a source you have taken nothing from is + # a fact worth stating, and a pill that comes and goes makes the row + # jump around as the numbers change + installed_box.removeAllViews() + installed = i.get("installed") + if not isinstance(installed, int) or installed < 0: + installed = 0 + installed_box.addView( + _chip(ctx, str(getattr(strings, "repo_card_installed", "{0} installed")) + .replace("{0}", str(installed)), accent), _chip_lp(8)) + + # own container so a repaint can refill it without touching the overflow + links = LinearLayout(ctx) + links.setOrientation(LinearLayout.HORIZONTAL) + links.setGravity(Gravity.CENTER_VERTICAL) + + def _fill_links(i): + links.removeAllViews() + tg_url = str(i.get("telegram") or "").strip() + src_url = str(i.get("source") or "").strip() + if tg_url: + links.addView( + _round_icon_button(ctx, "msg_channel", accent, lambda u=tg_url: on_open(u)), + _btn_lp()) + if src_url: + links.addView( + _round_icon_button(ctx, "msg_link", accent, lambda u=src_url: on_open(u)), + _btn_lp()) + + _fill_installed(info) + _fill_links(info) + footer.addView(links, LayoutHelper.createLinear(-2, -2)) + + on_menu = callbacks.get("on_menu") + menu_btn = _round_icon_button( + ctx, "ic_ab_other", _theme("key_windowBackgroundWhiteGrayText"), + # state["repo"] and not the dict this card was built from: a repaint + # hands over a freshly parsed one, and the menu prefills its dialogs + lambda: on_menu(menu_holder[0], state["repo"]) if on_menu else None, + translucent=True + ) + menu_holder = [menu_btn] + footer.addView(menu_btn, _btn_lp(0)) + + card.addView(footer, LayoutHelper.createLinear(-1, -2, 0, 10, 0, 0)) + + # the card opens the source's sheet; the switch beside it is what turns the + # source on and off, so a tap meant for one is never the other + on_toggle = callbacks.get("on_toggle") + on_open_card = callbacks.get("on_open_card") + state = {"enabled": enabled, "info": info, "repo": repo, "icon_url": icon_url} + + def _apply_enabled(is_on, animate): + state["enabled"] = is_on + try: + card.setBackground(_card_background(is_on)) + except Exception as e: + logx(f"repos card: background repaint error: {e}", False) + try: + if switch is not None: + switch.setChecked(is_on, animate) + except Exception: + pass + _fill_chips(is_on, state["info"]) + target = 1.0 if is_on else 0.55 + for view in (icon_holder[0], col, chips, installed_box): + try: + if animate: + view.animate().alpha(target).setDuration(160).start() + else: + view.setAlpha(target) + except Exception: + pass + + def _toggle(_v=None): + _apply_enabled(not state["enabled"], True) + if on_toggle: + on_toggle(state["enabled"], state["repo"]) + + def _update(new_repo, new_info): + # Repaint, do not rebuild. The avatar in particular survives: rebuilding + # the card meant a fresh ImageView with nothing in it, so flipping a + # switch made every icon on screen blink. + state["repo"] = new_repo + state["info"] = new_info or {} + try: + name_tv.setText(str(new_repo.get("name") or strings.unnamed)) + _fill_sub(new_repo, state["info"]) + _fill_links(state["info"]) + _fill_installed(state["info"]) + new_url = str(state["info"].get("icon_url") or "") + if new_url != state["icon_url"]: + # only an updated repomap can do this, and then it really is a + # different picture — swap the whole avatar + state["icon_url"] = new_url + try: + header.removeView(icon_holder[0]) + except Exception: + pass + replacement = RepoIcon.build_icon_view(ctx, new_repo, 48, 14, new_url) + replacement.setAlpha(1.0 if state["enabled"] else 0.55) + header.addView(replacement, 0, _icon_lp()) + icon_holder[0] = replacement + now = bool(new_repo.get("enabled", True)) + if now != state["enabled"]: + _apply_enabled(now, False) + else: + # the card is already showing this value — most repaints arrive + # right after its own tap, and re-setting alpha mid-animation + # would snap it + _fill_chips(now, state["info"]) + except Exception as e: + logx(f"repos card: update error: {e}", False) + + card.setOnClickListener(OnClickListener( + lambda _v: on_open_card(state["repo"], state["info"]) if on_open_card else None + )) + apply_press_scale_on_target(card, card) + _apply_enabled(enabled, False) + if isinstance(handle, dict): + handle["view"] = card + handle["update"] = _update + return card + + +def _build_switch(ctx, checked: bool): + # Coloured the way the client colours the switch in its own plugin card + # (PluginCell), and given that cell's box. The card opens a sheet now, so + # the tap that turns a source on and off belongs to the switch — but it + # arrives through the wrapper, which is big enough to aim at. + try: + from org.telegram.ui.Components import Switch as TgSwitch + sw = TgSwitch(ctx) + try: + sw.setColors( + Theme.key_switchTrack, Theme.key_switchTrackChecked, + Theme.key_windowBackgroundWhite, Theme.key_windowBackgroundWhite, + ) + except Exception as e: + logx(f"repos card: switch colors unavailable: {e}", True) + sw.setChecked(checked, False) + # the wrapper around it takes the taps; the switch keeps the client's + # box so it keeps the client's proportions + sw.setClickable(False) + sw.setFocusable(False) + return sw + except Exception as e: + logx(f"repos card: switch unavailable: {e}", False) + return None + + +def _host_of(url) -> str: + try: + text = str(url or "") + if "://" in text: + text = text.split("://", 1)[1] + return text.split("/", 1)[0] + except Exception: + return "" diff --git a/packit/src/python/ui/repos/Fragment.py b/packit/src/python/ui/repos/Fragment.py new file mode 100644 index 00000000..49e46779 --- /dev/null +++ b/packit/src/python/ui/repos/Fragment.py @@ -0,0 +1,572 @@ +# pyright: reportMissingImports=false +# SPDX-License-Identifier: GPL-3.0-or-later + +# The "Sources" screen. +# +# Replaces the settings-list version, where every repository took seven rows and +# its icon was picked out of the host's R.drawable catalogue. Here a repository +# is one card that already knows what it carries, and the icon comes from the +# repomap itself (repometa.rm_icon). +# +# Everything the cards show is read from reposCache off the ui thread; the +# screen makes no network call of its own. + +from packutil import logx +import ctypes +import json +import os + +from java import dynamic_proxy +from android_utils import run_on_ui_thread, OnClickListener +from client_utils import get_last_fragment, run_on_queue + +try: + from elyx import strings +except Exception as e: + import android_utils as _au; _au.log(f"repos fragment: import elyx strings failed: {e}") +try: + from android.widget import FrameLayout, LinearLayout, TextView, ImageView, ScrollView + from android.view import View, Gravity + from android.util import TypedValue + from android.graphics.drawable import GradientDrawable +except Exception as e: + import android_utils as _au; _au.log(f"repos fragment: import android widgets failed: {e}") +try: + from org.telegram.ui.ActionBar import Theme + from org.telegram.ui.Components import LayoutHelper + from org.telegram.messenger import AndroidUtilities, R as R_tg +except Exception as e: + import android_utils as _au; _au.log(f"repos fragment: import telegram classes failed: {e}") +try: + from com.exteragram.messenger.plugins.ui.components.templates import UniversalFragment +except Exception as e: + import android_utils as _au; _au.log(f"repos fragment: import UniversalFragment failed: {e}") + +from . import register, unregister +from .Card import make_repo_card +from ..components.ViewUtils import applyFontToTree +from ...utils import CachedRepos + + +def _c(color: int) -> int: + return ctypes.c_int32(color).value + + +def _alpha(color: int, a: int) -> int: + return _c((a << 24) | (color & 0xFFFFFF)) + + +def _theme(key: str, fallback: int = 0): + try: + return Theme.getColor(getattr(Theme, key)) + except Exception: + return fallback + + +def read_repo_info(repo: dict) -> dict: + """Everything the card needs, straight off disk. No network.""" + info = {"maintainer": "", "telegram": "", "source": "", "icon_url": "", + "plugins": None, "icons": None, "installed": 0, "updated_at": 0.0, + "status": "missing"} + repo_id = str(repo.get("id") or "") + if not repo_id: + return info + + # what the user has taken from this source, and what the catalogues counted + # in it the last time they were opened + try: + from ...utils import RepoStats + info["installed"] = RepoStats.installed_count(repo_id) + counted = RepoStats.read(repo_id) + if isinstance(counted.get("plugins"), int): + info["plugins"] = counted["plugins"] + if isinstance(counted.get("icons"), int): + info["icons"] = counted["icons"] + except Exception as e: + logx(f"repos: stats unavailable for '{repo_id}': {e}", True) + + cached = CachedRepos.read(repo_id) + if not cached: + return info + + meta = cached.get("repometa") or {} + info["maintainer"] = str(meta.get("rm_maintainer") or "") + info["telegram"] = str(meta.get("rm_telegram") or "") + info["source"] = str(meta.get("rm_source") or "") + info["icon_url"] = CachedRepos.icon_url(repo_id) + info["status"] = "loaded" + info["updated_at"] = CachedRepos.mtime(repo_id) + + # a repomap that is itself the plugin list carries the count inline; the + # usual shape only points at it by url, and that count comes from repoStats + plugins = cached.get("plugins") + if isinstance(plugins, list): + info["plugins"] = len(plugins) + icons = cached.get("icons") + if isinstance(icons, list): + info["icons"] = len(icons) + return info + + +class ReposFragment(dynamic_proxy(UniversalFragment.UniversalFragmentDelegate)): + def __init__(self, repoManager): + super().__init__() + self.repoManager = repoManager + self._root = None + self._list = None + self._summary = None + self._alive = [True] + self._fragment = [None] + self._first_build = True + self._handles = [] + self._signature_shown = None + + # ---------------------------------------------------------------- delegate + def onFragmentCreate(self, *_): + register(self) + + def onFragmentDestroy(self, *_): + self._alive[0] = False + unregister(self) + self._handles = [] + self._signature_shown = None + try: + if self._root is not None: + parent = self._root.getParent() + if parent is not None: + parent.removeView(self._root) + self._root = None + except Exception as e: + logx(f"repos fragment: onFragmentDestroy error: {e}", False) + + def beforeCreateView(self): + try: + if self._root is not None: + parent = self._root.getParent() + if parent is not None: + parent.removeView(self._root) + self._root = None + except Exception as e: + logx(f"repos fragment: view cleanup error: {e}", False) + + frag = get_last_fragment() + act = frag.getParentActivity() if frag else None + if not act: + return None + + try: + root = FrameLayout(act) + root.setBackgroundColor(_theme("key_windowBackgroundGray")) + + scroll = ScrollView(act) + scroll.setVerticalScrollBarEnabled(False) + try: + scroll.setFillViewport(True) + except Exception: + pass + + content = LinearLayout(act) + content.setOrientation(LinearLayout.VERTICAL) + content.setPadding(AndroidUtilities.dp(12), AndroidUtilities.dp(8), + AndroidUtilities.dp(12), AndroidUtilities.dp(96)) + + # 44dp, the same strip height the catalogue's toolbar uses + content.addView(self._build_summary_row(act), + LayoutHelper.createLinear(-1, 44, 0, 2, 0, 6)) + + self._list = LinearLayout(act) + self._list.setOrientation(LinearLayout.VERTICAL) + # the container is new, so nothing is on screen to repaint + self._handles = [] + self._signature_shown = None + content.addView(self._list, LayoutHelper.createLinear(-1, -2)) + + scroll.addView(content, ScrollView.LayoutParams(-1, -2)) + root.addView(scroll, FrameLayout.LayoutParams(-1, -1)) + root.addView(self._build_add_button(act), LayoutHelper.createFrame( + -2, -2, Gravity.BOTTOM | Gravity.CENTER_HORIZONTAL, 0, 0, 0, 20)) + + self._root = root + run_on_ui_thread(lambda: self.reload(), 30) + return root + except Exception as e: + logx(f"repos fragment: beforeCreateView error: {e}", False) + return None + + def afterCreateView(self, v): + return None + + def getTitle(self): + try: + return str(strings.repositories) + except Exception: + return "Repositories" + + def onBackPressed(self): + # UniversalFragment negates this before deciding: it does + # `return !delegate.onBackPressed()` and only calls finishFragment when + # that is true. Returning True here therefore swallowed both the button + # and the gesture. False means "nothing to handle, go ahead and close", + # which is what every other fragment in the plugin returns. + return False + + def fillItems(self, items, adapter): + pass + + def onClick(self, item, view, pos, x, y): + pass + + def onLongClick(self, item, view, pos, x, y): + return False + + def onMenuItemClick(self, mid): + if mid == -1: + try: + frag = self._fragment[0] or get_last_fragment() + if frag: + frag.finishFragment() + except Exception: + pass + + # ------------------------------------------------------------------- build + def reload(self): + # cards are rebuilt wholesale: the list is capped at ten entries, so + # diffing would cost more than it saves + if not self._alive[0] or self._list is None: + return + frag = get_last_fragment() + act = frag.getParentActivity() if frag else None + if not act: + return + repos = self.repoManager.getRepositories() + + def _work(): + infos = [read_repo_info(r) for r in repos] + + def _paint(): + if not self._alive[0] or self._list is None: + return + try: + self._render(act, repos, infos) + except Exception as e: + logx(f"repos fragment: render error: {e}", False) + + run_on_ui_thread(_paint) + + run_on_queue(_work) + + def _signature(self, repos): + return [(str(r.get("id") or ""), str(r.get("url") or "")) for r in repos] + + def _render(self, act, repos, infos): + self._summary.setText(self._summary_text(len(repos))) + + # A repaint is not a rebuild. Flipping a switch writes the list back + # through RepositoryManager, which notifies this screen, which used to + # throw every card away and build it again — and a fresh card starts + # with an empty avatar, so every icon on screen blinked. When the same + # sources are still there in the same order, hand each card its new + # values and let it repaint itself. + signature = self._signature(repos) + if signature and signature == self._signature_shown and len(self._handles) == len(repos): + for idx, repo in enumerate(repos): + update = self._handles[idx].get("update") + if update: + update(repo, infos[idx] if idx < len(infos) else {}) + # a repaint makes fresh chip labels, and those have no font yet + applyFontToTree(self._list) + return + + self._list.removeAllViews() + self._handles = [] + self._signature_shown = signature + + if not repos: + self._list.addView(self._build_empty_state(act), LayoutHelper.createLinear(-1, -2)) + self._first_build = False + applyFontToTree(self._root) + return + + for idx, repo in enumerate(repos): + info = infos[idx] if idx < len(infos) else {} + handle = {} + card = make_repo_card(act, repo, info, self._callbacks_for(act, repo), handle) + self._handles.append(handle) + lp = LayoutHelper.createLinear(-1, -2, 0, 0, 0, 8) + self._list.addView(card, lp) + if self._first_build: + self._reveal(card, idx) + + self._first_build = False + applyFontToTree(self._root) + + def _summary_text(self, count: int) -> str: + try: + from ..plugins.helpers.Utils import _format_plural + return str(_format_plural(count, strings.repo_one, strings.repo_few, + strings.repo_many, strings["plural_type"])) + except Exception: + return f"{count}" + + def _reveal(self, card, idx: int): + # md3 enter: fade plus a short rise, staggered down the list + try: + card.setAlpha(0.0) + card.setTranslationY(float(AndroidUtilities.dp(12))) + card.animate().alpha(1.0).translationY(0.0).setStartDelay(idx * 45).setDuration(220).start() + except Exception: + pass + + # --------------------------------------------------------------- callbacks + def _index_of(self, repo: dict): + # never trust a captured index: updateAllCaches drops repositories by + # index while the screen is open + repos = self.repoManager.getRepositories() + repo_id = repo.get("id") + if repo_id: + for i, r in enumerate(repos): + if r.get("id") == repo_id: + return i, repos + url = repo.get("url") + if url: + for i, r in enumerate(repos): + if r.get("url") == url: + return i, repos + return -1, repos + + def _callbacks_for(self, act, repo): + from . import Actions + + # the card passes its own repo dict back: a repaint replaces the one + # captured here with the freshly parsed entry + def _on_toggle(value, current): + idx, _ = self._index_of(current) + if idx < 0: + self.reload() + return + current["enabled"] = value + self.repoManager.updateRepoField(idx, "enabled", value) + + def _on_menu(anchor, current): + Actions.show_card_menu(act, self, current, anchor) + + def _on_open_card(current, info): + from .RepoSheet import show_repo_sheet + show_repo_sheet(act, current, info) + + def _on_open(url): + Actions.open_url(act, url) + + return {"on_toggle": _on_toggle, "on_menu": _on_menu, "on_open": _on_open, + "on_open_card": _on_open_card} + + # ------------------------------------------------------------------ pieces + def _build_summary_row(self, act): + # Built the way the plugin catalogue builds its own toolbar + # (ListView.py): a 44dp frame, the count centred in a 16dp-radius pill + # on the card surface, icon buttons of the same shape on the right. A + # loose grey caption over a floating circle did not read as a control + # strip at all — this is the same component the rest of the plugin uses. + from ..plugins.helpers.UiHelpers import get_theme_colors, apply_press_scale_on_target + colors = get_theme_colors() + card_bg = colors.get("card_bg_color") + card_pressed = colors.get("card_pressed_color") + text_color = colors.get("text_color") + + row = FrameLayout(act) + + # Left, not centred: a count centred over a left-aligned list has + # nothing under it to line up with, and the cards below all start at + # the same edge this now starts at. + self._summary = TextView(act) + self._summary.setTextSize(TypedValue.COMPLEX_UNIT_DIP, 16) + self._summary.setGravity(Gravity.CENTER) + self._summary.setPadding(AndroidUtilities.dp(14), AndroidUtilities.dp(7), + AndroidUtilities.dp(14), AndroidUtilities.dp(7)) + self._summary.setTextColor(text_color) + try: + self._summary.setBackground(Theme.createSimpleSelectorRoundRectDrawable( + AndroidUtilities.dp(16), card_bg, card_bg)) + except Exception as e: + logx(f"repos fragment: summary pill background error: {e}", True) + row.addView(self._summary, FrameLayout.LayoutParams( + -2, -2, Gravity.LEFT | Gravity.CENTER_VERTICAL)) + + # the bulk actions the old screen kept under "Дополнительно". Labelled, + # not a bare icon: an icon on its own says nothing about what is behind + # it, and there is room on this row for the word. + def _menu(v=None): + from . import Actions + Actions.show_bulk_menu(act, self, menu_btn) + + menu_btn = LinearLayout(act) + menu_btn.setOrientation(LinearLayout.HORIZONTAL) + menu_btn.setGravity(Gravity.CENTER_VERTICAL) + menu_btn.setClickable(True) + menu_btn.setFocusable(True) + try: + menu_btn.setBackground(Theme.createSimpleSelectorRoundRectDrawable( + AndroidUtilities.dp(16), card_bg, card_pressed)) + except Exception: + pass + menu_btn.setPadding(AndroidUtilities.dp(12), AndroidUtilities.dp(8), + AndroidUtilities.dp(14), AndroidUtilities.dp(8)) + menu_icon = ImageView(act) + try: + menu_icon.setImageResource(getattr(R_tg.drawable, "msg_customize")) + menu_icon.setColorFilter(text_color) + except Exception: + pass + icon_lp = LinearLayout.LayoutParams(AndroidUtilities.dp(20), AndroidUtilities.dp(20)) + icon_lp.rightMargin = AndroidUtilities.dp(6) + menu_btn.addView(menu_icon, icon_lp) + + menu_label = TextView(act) + menu_label.setText(str(getattr(strings, "repos_manage", "Manage"))) + menu_label.setTextSize(TypedValue.COMPLEX_UNIT_DIP, 14) + menu_label.setTextColor(text_color) + try: + menu_label.setTypeface(AndroidUtilities.bold()) + except Exception: + pass + menu_btn.addView(menu_label, LinearLayout.LayoutParams(-2, -2)) + + menu_btn.setOnClickListener(OnClickListener(_menu)) + apply_press_scale_on_target(menu_btn, menu_btn) + row.addView(menu_btn, FrameLayout.LayoutParams( + -2, -2, Gravity.RIGHT | Gravity.CENTER_VERTICAL)) + return row + + def _build_add_button(self, act): + accent = _theme("key_featuredStickers_addButton") + btn = LinearLayout(act) + btn.setOrientation(LinearLayout.HORIZONTAL) + btn.setGravity(Gravity.CENTER_VERTICAL) + btn.setPadding(AndroidUtilities.dp(18), AndroidUtilities.dp(14), + AndroidUtilities.dp(20), AndroidUtilities.dp(14)) + btn.setClickable(True) + btn.setFocusable(True) + try: + btn.setBackground(Theme.createSimpleSelectorRoundRectDrawable( + AndroidUtilities.dp(28), accent, + _theme("key_featuredStickers_addButtonPressed", accent))) + btn.setElevation(float(AndroidUtilities.dp(10))) + except Exception: + pass + + icon = ImageView(act) + try: + icon.setImageResource(getattr(R_tg.drawable, "msg_add")) + icon.setColorFilter(_theme("key_featuredStickers_buttonText")) + except Exception: + pass + icon_lp = LinearLayout.LayoutParams(AndroidUtilities.dp(20), AndroidUtilities.dp(20)) + icon_lp.gravity = Gravity.CENTER_VERTICAL + icon_lp.rightMargin = AndroidUtilities.dp(8) + btn.addView(icon, icon_lp) + + label = TextView(act) + try: + label.setText(str(strings.add_repository)) + except Exception: + label.setText("Add") + label.setTextSize(TypedValue.COMPLEX_UNIT_DIP, 14) + label.setTextColor(_theme("key_featuredStickers_buttonText")) + try: + label.setTypeface(AndroidUtilities.getTypeface("fonts/rmedium.ttf")) + except Exception: + pass + btn.addView(label, LayoutHelper.createLinear(-2, -2, Gravity.CENTER_VERTICAL)) + + def _add(v): + from . import Actions + Actions.add_repository(act, self) + + btn.setOnClickListener(OnClickListener(_add)) + try: + from ..plugins.helpers.UiHelpers import apply_press_scale + apply_press_scale(btn) + except Exception: + pass + return btn + + def _build_empty_state(self, act): + box = LinearLayout(act) + box.setOrientation(LinearLayout.VERTICAL) + box.setGravity(Gravity.CENTER) + box.setPadding(0, AndroidUtilities.dp(64), 0, AndroidUtilities.dp(24)) + + icon = ImageView(act) + try: + icon.setImageResource(getattr(R_tg.drawable, "msg_folders")) + icon.setColorFilter(_alpha(_theme("key_windowBackgroundWhiteGrayText"), 0x66)) + except Exception: + pass + stub_lp = LinearLayout.LayoutParams(AndroidUtilities.dp(56), AndroidUtilities.dp(56)) + stub_lp.gravity = Gravity.CENTER_HORIZONTAL + stub_lp.bottomMargin = AndroidUtilities.dp(14) + box.addView(icon, stub_lp) + + title = TextView(act) + try: + title.setText(str(strings.repos_empty_title)) + except Exception: + title.setText("No repositories") + title.setTextSize(TypedValue.COMPLEX_UNIT_DIP, 16) + title.setGravity(Gravity.CENTER) + title.setTextColor(_theme("key_windowBackgroundWhiteBlackText")) + try: + title.setTypeface(AndroidUtilities.getTypeface("fonts/rmedium.ttf")) + except Exception: + pass + box.addView(title, LayoutHelper.createLinear(-2, -2, Gravity.CENTER_HORIZONTAL)) + + sub = TextView(act) + try: + sub.setText(str(strings.repos_empty_text)) + except Exception: + sub.setText("") + sub.setTextSize(TypedValue.COMPLEX_UNIT_DIP, 13) + sub.setGravity(Gravity.CENTER) + sub.setTextColor(_theme("key_windowBackgroundWhiteGrayText")) + box.addView(sub, LayoutHelper.createLinear(-2, -2, Gravity.CENTER_HORIZONTAL, 24, 6, 24, 0)) + return box + + +def show_repos_fragment(repoManager): + try: + frag = get_last_fragment() + if not frag: + return + delegate = ReposFragment(repoManager) + new_frag = UniversalFragment(delegate) + frag.presentFragment(new_frag) + delegate._fragment[0] = new_frag + + def _setup(attempt=0): + # the action bar is null for a few frames after presentFragment + try: + action_bar = new_frag.getActionBar() + if not action_bar: + if attempt < 10: + run_on_ui_thread(lambda: _setup(attempt + 1), 120) + return + new_frag.setTitle(str(strings.repositories), False, 0) + action_bar.setBackgroundColor(_theme("key_windowBackgroundGray")) + back_icon = getattr(R_tg.drawable, "ic_ab_back", 0) + if back_icon: + action_bar.setBackButtonImage(back_icon) + action_bar.setBackButtonContentDescription("Back") + back_button = action_bar.getBackButton() + if back_button: + def _on_back(v): + f = get_last_fragment() + if f: + f.finishFragment() + back_button.setOnClickListener(OnClickListener(_on_back)) + except Exception as e: + logx(f"repos fragment: actionbar setup error: {e}", False) + + _setup() + except Exception as e: + logx(f"repos fragment: show_repos_fragment error: {e}", False) diff --git a/packit/src/python/ui/repos/RepoIcon.py b/packit/src/python/ui/repos/RepoIcon.py new file mode 100644 index 00000000..a76a9bf9 --- /dev/null +++ b/packit/src/python/ui/repos/RepoIcon.py @@ -0,0 +1,185 @@ +# pyright: reportMissingImports=false +# SPDX-License-Identifier: GPL-3.0-or-later + +# Repository avatar — the view only. Where the picture comes from, and the +# memory and disk caches it comes through, are network/Storage's business. +# +# repomap declares the icon as a plain image url (repometa.rm_icon), so there is +# nothing to look up in R.drawable any more: the bitmap is drawn over a monogram +# that stands in until it arrives, and stays for repositories that declare none. +# +# The view is a FrameLayout of two layers, monogram below and bitmap above, +# because a Drawable subclass would have to be proxied into java just to paint +# one letter. Late answers are dropped by tag, the same guard utils/Stickers.py +# uses, so a card reused for another repository cannot inherit its avatar. + +from packutil import logx +import ctypes + +from android.widget import FrameLayout, TextView, ImageView +from android.view import Gravity +from android.util import TypedValue +from android.graphics.drawable import GradientDrawable +from android_utils import run_on_ui_thread + +try: + from org.telegram.messenger import AndroidUtilities + from org.telegram.ui.ActionBar import Theme +except Exception as e: + import android_utils as _au; _au.log(f"repoIcon: import telegram classes failed: {e}") + AndroidUtilities = None + Theme = None + +from ...utils import ImagePool +from ...network import Storage +from ...utils import CachedRepos + +def _c(color: int) -> int: + # java setColor(int) rejects python ints >= 0x80000000 + return ctypes.c_int32(color).value + + +def _alpha(color: int, a: int) -> int: + return _c((a << 24) | (color & 0xFFFFFF)) + + +def tonal(accent: int, surface: int, fraction: float) -> int: + """An opaque container colour: accent mixed into the surface behind it. + + Not accent-at-low-alpha. A translucent fill picks up whatever is under it — + the card, then the window, then anything the card is animating over — so + two identical chips on different backgrounds come out different colours, + and overlapping ones stack. Mixing the two colours here gives the same look + as one solid value that owes nothing to what is behind it. + """ + fraction = max(0.0, min(1.0, float(fraction))) + out = 0xFF000000 + for shift in (16, 8, 0): + a = (accent >> shift) & 0xFF + b = (surface >> shift) & 0xFF + out |= int(round(b + (a - b) * fraction)) << shift + return _c(out) + + +def _seed(repo: dict) -> int: + key = str(repo.get("id") or repo.get("url") or repo.get("name") or "") + total = 0 + for ch in key: + total = (total * 31 + ord(ch)) & 0xFFFFFFFF + return total + + +def accent_for(repo: dict) -> int: + """The theme's accent. The repository does not get a say any more. + + This used to pick a colour per repository out of the avatar palette, so + that a source kept its own look. On a theme built from one accent — which + is every Monet theme, and the client's own — a violet or an orange dropped + into it is simply the wrong colour on the screen, however stable it is. + The argument stays so the call sites read the same. + """ + for key in ("key_featuredStickers_addButton", "key_windowBackgroundWhiteBlueText"): + try: + return _c(int(Theme.getColor(getattr(Theme, key)))) + except Exception: + continue + return _c(0xFF2AABEE) + + +def _letter(repo: dict) -> str: + for ch in str(repo.get("name") or ""): + if ch.isalnum(): + return ch.upper() + return "?" + + +def icon_url_for(repo: dict): + return CachedRepos.icon_url(repo) or None + + +def build_icon_view(ctx, repo: dict, size_dp: int = 48, radius_dp: int = 14, url=None): + # monogram now, real icon when it arrives — unless it has already arrived + # once, in which case it is on screen before the card is + size_px = AndroidUtilities.dp(size_dp) + accent = accent_for(repo) + + holder = FrameLayout(ctx) + + try: + surface = _c(int(Theme.getColor(Theme.key_windowBackgroundWhite))) + except Exception: + surface = _c(0xFF1C1C1E) + + mono = TextView(ctx) + mono.setText(_letter(repo)) + mono.setGravity(Gravity.CENTER) + mono.setTextSize(TypedValue.COMPLEX_UNIT_DIP, max(12, int(size_dp * 0.42))) + mono.setTextColor(_alpha(accent, 0xFF)) + try: + mono.setTypeface(AndroidUtilities.getTypeface("fonts/rmedium.ttf")) + except Exception: + try: + mono.setTypeface(AndroidUtilities.bold()) + except Exception: + pass + bg = GradientDrawable() + bg.setShape(GradientDrawable.RECTANGLE) + bg.setCornerRadius(float(AndroidUtilities.dp(radius_dp))) + bg.setColor(tonal(accent, surface, 0.16)) + mono.setBackground(bg) + holder.addView(mono, FrameLayout.LayoutParams(size_px, size_px)) + + image = ImageView(ctx) + image.setScaleType(ImageView.ScaleType.CENTER_CROP) + image.setVisibility(8) # GONE + try: + image.setClipToOutline(True) + image.setBackground(bg.getConstantState().newDrawable().mutate()) + except Exception: + pass + holder.addView(image, FrameLayout.LayoutParams(size_px, size_px)) + + want = f"packit_repoicon_{_seed(repo)}" + holder.setTag(want) + + if url is not None and not str(url).strip(): + # the caller read the cache and there is no icon in it — an empty string + # is an answer, unlike None, so no worker goes and reads it again + return holder + + cached = Storage.peek_icon(str(url or ""), size_px) + if cached is not None: + # straight onto the view, no fade: the icon was already on screen a + # moment ago and fading it back in is exactly what reads as a blink + try: + image.setImageBitmap(cached) + image.setVisibility(0) # VISIBLE + mono.setVisibility(8) + return holder + except Exception as e: + logx(f"repoIcon: cached bind error: {e}", False) + + def _task(): + target = url if url else icon_url_for(repo) + if not target: + return + bmp = Storage.load_icon(target, size_px) + if bmp is None: + return + + def _apply(): + try: + if str(holder.getTag() or "") != want: + return + image.setImageBitmap(bmp) + image.setVisibility(0) # VISIBLE + image.setAlpha(0.0) + image.animate().alpha(1.0).setDuration(160).start() + mono.setVisibility(8) + except Exception as e: + logx(f"repoIcon: bind error: {e}", False) + + run_on_ui_thread(_apply) + + ImagePool.submit(_task) + return holder diff --git a/packit/src/python/ui/repos/RepoSheet.py b/packit/src/python/ui/repos/RepoSheet.py new file mode 100644 index 00000000..b8bf2f71 --- /dev/null +++ b/packit/src/python/ui/repos/RepoSheet.py @@ -0,0 +1,167 @@ +# pyright: reportMissingImports=false +# SPDX-License-Identifier: GPL-3.0-or-later + +# The sheet behind a repository card. +# +# Tapping a card used to flip its switch; the switch does that itself now, and +# the card opens this instead. What goes in it is not decided yet, so for the +# moment it carries the source's identity and nothing else — the shell is real +# so that filling it is a matter of adding views under the header. + +from packutil import logx +import ctypes + +from android.widget import LinearLayout, TextView +from android.view import Gravity +from android.util import TypedValue +from android_utils import run_on_ui_thread +from client_utils import get_last_fragment + +try: + from org.telegram.ui.ActionBar import BottomSheet, Theme + from org.telegram.ui.Components import LayoutHelper + from org.telegram.messenger import AndroidUtilities +except Exception as e: + import android_utils as _au; _au.log(f"repoSheet: import telegram classes failed: {e}") + +try: + from elyx import strings +except Exception as e: + import android_utils as _au; _au.log(f"repoSheet: import elyx strings failed: {e}") + +from . import RepoIcon +from ..components.ViewUtils import applyFontToTree +from ..plugins.helpers.UiHelpers import setup_bottom_sheet, create_rounded_bg + + +def _c(color: int) -> int: + return ctypes.c_int32(color).value + + +def _theme(key: str, fallback: int = 0): + try: + return Theme.getColor(getattr(Theme, key)) + except Exception: + return fallback + + +def _updated_label(mtime) -> str: + # LocaleController already words "just now / N minutes ago / today at …" for + # the client's location updates, in every language it ships, and a repomap + # cache is the same kind of fact — so the wording comes from there rather + # than from four more locale keys of my own. + try: + seconds = int(float(mtime or 0)) + if seconds <= 0: + return "" + from org.telegram.messenger import LocaleController + return str(LocaleController.formatLocationUpdateDate(seconds)) + except Exception as e: + logx(f"repoSheet: updated label unavailable: {e}", True) + return "" + + +def _handle(ctx): + from android.graphics.drawable import GradientDrawable + bar = TextView(ctx) + bg = GradientDrawable() + bg.setShape(GradientDrawable.RECTANGLE) + bg.setCornerRadius(float(AndroidUtilities.dp(2))) + bg.setColor(_c((0x3D << 24) | (_theme("key_sheet_scrollUp") & 0xFFFFFF))) + bar.setBackground(bg) + return bar + + +def show_repo_sheet(act, repo: dict, info: dict = None): + info = info or {} + + def _show(): + try: + frag = get_last_fragment() + sheet = BottomSheet(act, False, frag.getResourceProvider() if frag else None) + setup_bottom_sheet(sheet) + + root = LinearLayout(act) + root.setOrientation(LinearLayout.VERTICAL) + root.setPadding(AndroidUtilities.dp(20), AndroidUtilities.dp(10), + AndroidUtilities.dp(20), AndroidUtilities.dp(20)) + try: + root.setBackground(create_rounded_bg(_theme("key_dialogBackground"))) + except Exception: + root.setBackgroundColor(_theme("key_dialogBackground")) + + handle_lp = LayoutHelper.createLinear(36, 4, Gravity.CENTER_HORIZONTAL, 0, 0, 0, 14) + root.addView(_handle(act), handle_lp) + + header = LinearLayout(act) + header.setOrientation(LinearLayout.HORIZONTAL) + header.setGravity(Gravity.CENTER_VERTICAL) + + icon = RepoIcon.build_icon_view(act, repo, 52, 15, str(info.get("icon_url") or "")) + icon_lp = LinearLayout.LayoutParams(AndroidUtilities.dp(52), AndroidUtilities.dp(52)) + icon_lp.rightMargin = AndroidUtilities.dp(14) + header.addView(icon, icon_lp) + + col = LinearLayout(act) + col.setOrientation(LinearLayout.VERTICAL) + + name = TextView(act) + name.setText(str(repo.get("name") or strings.unnamed)) + name.setTextSize(TypedValue.COMPLEX_UNIT_DIP, 20) + name.setTextColor(_theme("key_dialogTextBlack")) + name.setSingleLine(True) + try: + name.setTypeface(AndroidUtilities.getTypeface("fonts/rmedium.ttf")) + except Exception: + pass + col.addView(name, LayoutHelper.createLinear(-1, -2)) + + sub_text = str(info.get("maintainer") or "").strip() or _host_of(repo.get("url")) + if sub_text: + sub = TextView(act) + sub.setTextSize(TypedValue.COMPLEX_UNIT_DIP, 13) + sub.setTextColor(_theme("key_dialogTextGray2")) + sub.setSingleLine(True) + # here the mention can be tapped: unlike on the card, nothing + # else in this row wants the touch + try: + from com.exteragram.messenger.utils.text import LocaleUtils + from android.text.method import LinkMovementMethod + sub.setText(LocaleUtils.fullyFormatText(sub_text)) + sub.setLinkTextColor(_theme("key_windowBackgroundWhiteBlueText")) + sub.setMovementMethod(LinkMovementMethod.getInstance()) + except Exception as e: + logx(f"repoSheet: maintainer format unavailable: {e}", True) + sub.setText(sub_text) + col.addView(sub, LayoutHelper.createLinear(-1, -2, 0, 3, 0, 0)) + + header.addView(col, LayoutHelper.createLinear(-1, -2)) + root.addView(header, LayoutHelper.createLinear(-1, -2)) + + # off the card and in here, where a detail has room to be read in + # full instead of being squeezed to "обновлена т…" + updated = _updated_label(info.get("updated_at")) + if updated: + upd = TextView(act) + upd.setText(updated) + upd.setTextSize(TypedValue.COMPLEX_UNIT_DIP, 13) + upd.setTextColor(_theme("key_dialogTextGray2")) + root.addView(upd, LayoutHelper.createLinear(-1, -2, 0, 16, 0, 0)) + + sheet.setCustomView(root) + applyFontToTree(root) + sheet.show() + except Exception as e: + logx(f"repoSheet: show error: {e}", False) + + run_on_ui_thread(_show) + + +def _host_of(url) -> str: + try: + text = str(url or "") + if "://" in text: + text = text.split("://", 1)[1] + return text.split("/", 1)[0] + except Exception: + return "" diff --git a/packit/src/python/ui/repos/__init__.py b/packit/src/python/ui/repos/__init__.py new file mode 100644 index 00000000..4f2f7e3d --- /dev/null +++ b/packit/src/python/ui/repos/__init__.py @@ -0,0 +1,40 @@ +# pyright: reportMissingImports=false +# SPDX-License-Identifier: GPL-3.0-or-later + +# Live screens listen here so a repository list changed anywhere else — a +# deeplink adding a source, the startup cache refresh dropping a dead one — +# repaints the open screen. RepositoryManager.setRepositories used to poke +# fragment.rebuildAllItems(), which only ever worked for the settings-list +# screen this one replaces. + +_delegates = [] + + +def register(delegate): + if delegate not in _delegates: + _delegates.append(delegate) + + +def unregister(delegate): + try: + _delegates.remove(delegate) + except ValueError: + pass + + +def notify_repos_changed(): + if not _delegates: + return + from android_utils import run_on_ui_thread + for delegate in list(_delegates): + try: + run_on_ui_thread(delegate.reload) + except Exception: + pass + + +def show_repos_fragment(repoManager): + # imported lazily: the fragment pulls in a good chunk of the ui package and + # nothing needs it until the row is actually tapped + from .Fragment import show_repos_fragment as _show + return _show(repoManager) diff --git a/packit/src/SettingsActivity/debugItems.py b/packit/src/python/ui/settings/DebugItems.py similarity index 97% rename from packit/src/SettingsActivity/debugItems.py rename to packit/src/python/ui/settings/DebugItems.py index 2b57aa77..891fe2d2 100644 --- a/packit/src/SettingsActivity/debugItems.py +++ b/packit/src/python/ui/settings/DebugItems.py @@ -22,7 +22,7 @@ def _test_native_error(): _ = 123 / 0 except Exception as e: logx(f"debugItems: test native error triggered: {e}", False) - from ..nativeLoader import showNativeErrorSheet + from ...core.NativeLoader import showNativeErrorSheet showNativeErrorSheet("libpackitdb.so", str(e)) @@ -31,7 +31,7 @@ def _migrate_achievements(): import os import ctypes import zlib - from ..ui.AchievementsActivity.service.AchivementsEngine import ( + from ..achievements.service.AchivementsEngine import ( _get_current_account_id, _get_configs_dir, _save_account, _db_to_dict, _lib, _BUF_SIZE ) @@ -263,7 +263,7 @@ def _dump_class_info(class_name: str, methods: bool = True, fields: bool = True) def _check_build_info(): try: - from ..utils.buildInfo import ( + from ...utils.BuildInfo import ( getBuildClientName, getBuildClientPkg, getCurrClientName, getCurrClientPkg, getBuildStaticVersion, getClientVersion @@ -281,7 +281,7 @@ def _check_build_info(): def _migrate_installdate_to_b64(): try: import os, json, base64 - from ..utils.localConfig import _get_install_date_path + from ...utils.LocalConfig import _get_install_date_path path = _get_install_date_path() if not os.path.exists(path): _show_bulletin("InstallDate not found") @@ -327,11 +327,11 @@ def show_debug_menu(): return def _trigger_startup_sheet(): - from ..ui.pluginsUpdates.startupSheet import check_and_show_startup_updates + from ..updates.StartupSheet import check_and_show_startup_updates check_and_show_startup_updates() def _update_repos_cache(): - from ..RepositoryManager import RepositoryManager + from ...core.RepositoryManager import RepositoryManager RepositoryManager().updateAllCaches( on_complete=lambda: _show_bulletin("Repos cache updated") ) diff --git a/packit/src/SettingsActivity/deeplinks.py b/packit/src/python/ui/settings/Deeplinks.py similarity index 96% rename from packit/src/SettingsActivity/deeplinks.py rename to packit/src/python/ui/settings/Deeplinks.py index 2c0336ec..e0a6f282 100644 --- a/packit/src/SettingsActivity/deeplinks.py +++ b/packit/src/python/ui/settings/Deeplinks.py @@ -7,7 +7,7 @@ from elyx import strings except Exception as e: import android_utils as _au; _au.log(f"import elyx import strings failed: {e}") - from ..utils.importFailed import showImportFailedAlert as _sifa; _sifa() + from ...utils.ImportFailed import showImportFailedAlert as _sifa; _sifa() from client_utils import get_last_fragment from android.content import Intent from android.net import Uri @@ -15,8 +15,8 @@ from org.telegram.messenger import ApplicationLoader except Exception as e: import android_utils as _au; _au.log(f"import org.telegram.messenger import ApplicationLoader failed: {e}") - from ..utils.importFailed import showImportFailedAlert as _sifa; _sifa() -from ..ui.DeeplinkBottomSheets import show_deeplink_sheet + from ...utils.ImportFailed import showImportFailedAlert as _sifa; _sifa() +from ..dialogs.DeeplinkBottomSheets import show_deeplink_sheet class DeeplinksSettings: diff --git a/packit/src/SettingsActivity/docs.py b/packit/src/python/ui/settings/Docs.py similarity index 92% rename from packit/src/SettingsActivity/docs.py rename to packit/src/python/ui/settings/Docs.py index 94551271..d0b94329 100644 --- a/packit/src/SettingsActivity/docs.py +++ b/packit/src/python/ui/settings/Docs.py @@ -11,52 +11,52 @@ from android.widget import LinearLayout, TextView, ImageView, FrameLayout except Exception as e: import android_utils as _au; _au.log(f"import android.widget failed: {e}") - from ..utils.importFailed import showImportFailedAlert as _sifa; _sifa() + from ...utils.ImportFailed import showImportFailedAlert as _sifa; _sifa() try: from android.graphics.drawable import GradientDrawable except Exception as e: import android_utils as _au; _au.log(f"import android.graphics.drawable import GradientDrawable failed: {e}") - from ..utils.importFailed import showImportFailedAlert as _sifa; _sifa() + from ...utils.ImportFailed import showImportFailedAlert as _sifa; _sifa() try: from android.view import Gravity except Exception as e: import android_utils as _au; _au.log(f"import android.view import Gravity failed: {e}") - from ..utils.importFailed import showImportFailedAlert as _sifa; _sifa() + from ...utils.ImportFailed import showImportFailedAlert as _sifa; _sifa() try: from android.util import TypedValue except Exception as e: import android_utils as _au; _au.log(f"import android.util import TypedValue failed: {e}") - from ..utils.importFailed import showImportFailedAlert as _sifa; _sifa() + from ...utils.ImportFailed import showImportFailedAlert as _sifa; _sifa() try: from org.telegram.messenger import AndroidUtilities, R as R_tg except Exception as e: import android_utils as _au; _au.log(f"import org.telegram.messenger failed: {e}") - from ..utils.importFailed import showImportFailedAlert as _sifa; _sifa() + from ...utils.ImportFailed import showImportFailedAlert as _sifa; _sifa() try: from org.telegram.ui.Components import LayoutHelper except Exception as e: import android_utils as _au; _au.log(f"import org.telegram.ui.Components failed: {e}") - from ..utils.importFailed import showImportFailedAlert as _sifa; _sifa() + from ...utils.ImportFailed import showImportFailedAlert as _sifa; _sifa() try: from org.telegram.ui.ActionBar import Theme except Exception as e: import android_utils as _au; _au.log(f"import org.telegram.ui.ActionBar import Theme failed: {e}") - from ..utils.importFailed import showImportFailedAlert as _sifa; _sifa() + from ...utils.ImportFailed import showImportFailedAlert as _sifa; _sifa() try: from androidx.core.content import ContextCompat except Exception as e: import android_utils as _au; _au.log(f"import androidx.core.content import ContextCompat failed: {e}") - from ..utils.importFailed import showImportFailedAlert as _sifa; _sifa() + from ...utils.ImportFailed import showImportFailedAlert as _sifa; _sifa() try: from org.telegram.messenger.browser import Browser except Exception as e: import android_utils as _au; _au.log(f"import org.telegram.messenger.browser import Browser failed: {e}") - from ..utils.importFailed import showImportFailedAlert as _sifa; _sifa() + from ...utils.ImportFailed import showImportFailedAlert as _sifa; _sifa() try: from elyx import strings, settings except Exception as e: import android_utils as _au; _au.log(f"import elyx import strings, settings failed: {e}") - from ..utils.importFailed import showImportFailedAlert as _sifa; _sifa() + from ...utils.ImportFailed import showImportFailedAlert as _sifa; _sifa() def _makeBanner(context, icon_name, title_text, subtitle_text): @@ -163,7 +163,7 @@ def _openEnlightenment(self, view, *_): BulletinHelper.show_info(strings.enlighten_11, fragment) settings.set_setting("enlighten_clicks", 0) try: - from ..ui.AchievementsActivity.service.AchivementsEngine import unlock_secret + from ..achievements.service.AchivementsEngine import unlock_secret logx(f"docs._openEnlightenment: calling unlock_secret enlightened", True) unlock_secret("enlightened") logx(f"docs._openEnlightenment: unlock_secret done", True) diff --git a/packit/src/SettingsActivity/profile.py b/packit/src/python/ui/settings/Profile.py similarity index 98% rename from packit/src/SettingsActivity/profile.py rename to packit/src/python/ui/settings/Profile.py index 8f9e48df..eaeb32ec 100644 --- a/packit/src/SettingsActivity/profile.py +++ b/packit/src/python/ui/settings/Profile.py @@ -2,20 +2,20 @@ # SPDX-License-Identifier: GPL-3.0-or-later from packutil import logx -from ..utils.bulletins import factory as _pbf +from ...utils.Bulletins import factory as _pbf from ui.settings import Header, Text, Divider, Custom from ui.bulletin import BulletinHelper from client_utils import get_last_fragment -from ..ui.AchievementsActivity.fragment import show_achievements +from ..achievements.Fragment import show_achievements import threading import time try: from elyx import strings, settings except Exception as e: import android_utils as _au; _au.log(f"import elyx import strings, settings failed: {e}") - from ..utils.importFailed import showImportFailedAlert as _sifa; _sifa() -from ..ui.AchievementsActivity.service.AchivementsEngine import get_all_with_progress, get_stats + from ...utils.ImportFailed import showImportFailedAlert as _sifa; _sifa() +from ..achievements.service.AchivementsEngine import get_all_with_progress, get_stats def _get_greeting(first_name: str) -> str: @@ -180,7 +180,7 @@ def _show_achievements(self, view): def _do_export(self, include_local_config: bool, include_achievements: bool, include_saved_plugins: bool): try: - from ..ChatActivity.export.bin.writer import build_binary, _rand_suffix + from ...integrations.chat.export.bin.Writer import build_binary, _rand_suffix from android_utils import run_on_ui_thread from java import jclass, dynamic_proxy from java.io import File, FileOutputStream @@ -625,7 +625,7 @@ def onShare(): pass try: - from ..ui.viewUtils import applyFontToTree + from ..components.ViewUtils import applyFontToTree applyFontToTree(outer) except Exception: pass @@ -657,7 +657,7 @@ def _make_stats_card(self, context): level, xp_a, xp_b = s["level_info"] try: - from ..utils.localConfig import days_since_install + from ...utils.LocalConfig import days_since_install days = days_since_install() except Exception: days = 0 diff --git a/packit/src/SettingsActivity/settings.py b/packit/src/python/ui/settings/Settings.py similarity index 98% rename from packit/src/SettingsActivity/settings.py rename to packit/src/python/ui/settings/Settings.py index c4c8093c..dee6bcab 100644 --- a/packit/src/SettingsActivity/settings.py +++ b/packit/src/python/ui/settings/Settings.py @@ -10,22 +10,22 @@ from org.telegram.messenger import ApplicationLoader, AndroidUtilities, R except Exception as e: import android_utils as _au; _au.log(f"import org.telegram.messenger import ApplicationLoader, AndroidUtilities failed: {e}") - from ..utils.importFailed import showImportFailedAlert as _sifa; _sifa() + from ...utils.ImportFailed import showImportFailedAlert as _sifa; _sifa() try: from org.telegram.ui.ActionBar import Theme, BottomSheet except Exception as e: import android_utils as _au; _au.log(f"import org.telegram.ui.ActionBar import Theme failed: {e}") - from ..utils.importFailed import showImportFailedAlert as _sifa; _sifa() + from ...utils.ImportFailed import showImportFailedAlert as _sifa; _sifa() try: from org.telegram.ui.Components import LayoutHelper except Exception as e: import android_utils as _au; _au.log(f"import org.telegram.ui.Components import LayoutHelper failed: {e}") - from ..utils.importFailed import showImportFailedAlert as _sifa; _sifa() + from ...utils.ImportFailed import showImportFailedAlert as _sifa; _sifa() try: from elyx import strings, settings except Exception as e: import android_utils as _au; _au.log(f"import elyx import strings, settings failed: {e}") - from ..utils.importFailed import showImportFailedAlert as _sifa; _sifa() + from ...utils.ImportFailed import showImportFailedAlert as _sifa; _sifa() from android.widget import LinearLayout, TextView, FrameLayout from android.view import Gravity from android.net import Uri @@ -42,8 +42,8 @@ from typing import List, Any, Callable from dataclasses import dataclass, field -from ..ui.FontPickerBottomSheet import showFontPicker -from ..ui.FontManager import getSelectedFilename +from ..dialogs.FontPickerBottomSheet import showFontPicker +from ..components.FontManager import getSelectedFilename from extera_utils.classes import Base, java_subclass, joverride def _reload_plugin_settings(): @@ -1561,13 +1561,13 @@ def _open_pill_stack_settings(self, view): def _open_files_browser(self): try: - from ..ui.FilesActivity.fragment import show_files_browser + from ..files.Fragment import show_files_browser show_files_browser(plugin=self.plugin) except Exception as e: logx(f"OtherSettings: _open_files_browser error: {e}", False) def _getCacheDir(self) -> str: - from ..utils.paths import getCacheRoot + from ...utils.Paths import getCacheRoot return getCacheRoot() def _killProcess(self, *_): @@ -1646,7 +1646,7 @@ def _onClearPluginCacheClick(self, view, update_callback=None): def onConfirm(b, w): b.dismiss() try: - from ..utils.paths import getCacheRoot + from ...utils.Paths import getCacheRoot plugin_cache_dir = getCacheRoot() + "/.cache/plugins" if os.path.exists(plugin_cache_dir): shutil.rmtree(plugin_cache_dir) @@ -1701,7 +1701,7 @@ def onRestart(b, w): def _open_card_editor(self): try: - from .SubSettings.PluginCardEditor import build_card_editor_page + from .subsettings.PluginCardEditor import build_card_editor_page return build_card_editor_page() except Exception as e: logx(f"OtherSettings: _open_card_editor error: {e}", False) @@ -1709,7 +1709,7 @@ def _open_card_editor(self): def _open_interface_page(self): try: - from .SubSettings.interface import build_interface_page + from .subsettings.Interface import build_interface_page return build_interface_page(self, self._getContext()) except Exception as e: logx(f"OtherSettings: _open_interface_page error: {e}", False) @@ -1717,7 +1717,7 @@ def _open_interface_page(self): def _open_sfx_page(self): try: - from .SubSettings.sfx import build_sfx_page + from .subsettings.Sfx import build_sfx_page return build_sfx_page(self, self._getContext()) except Exception as e: logx(f"OtherSettings: _open_sfx_page error: {e}", False) @@ -1725,7 +1725,7 @@ def _open_sfx_page(self): def _open_comps_page(self): try: - from .SubSettings.comps import build_comps_page + from .subsettings.Comps import build_comps_page return build_comps_page(self, self._getContext()) except Exception as e: logx(f"OtherSettings: _open_comps_page error: {e}", False) @@ -1733,7 +1733,7 @@ def _open_comps_page(self): def _open_hotkeys_page(self): try: - from .SubSettings.hotkeys import build_hotkeys_page + from .subsettings.Hotkeys import build_hotkeys_page return build_hotkeys_page(self, self._getContext()) except Exception as e: logx(f"OtherSettings: _open_hotkeys_page error: {e}", False) @@ -1741,7 +1741,7 @@ def _open_hotkeys_page(self): def _open_plugin_profile_page(self): try: - from .SubSettings.pluginProfile import build_plugin_profile_page + from .subsettings.PluginProfile import build_plugin_profile_page return build_plugin_profile_page() except Exception as e: logx(f"OtherSettings: _open_plugin_profile_page error: {e}", False) @@ -1749,7 +1749,7 @@ def _open_plugin_profile_page(self): def _open_inline_page(self): try: - from .SubSettings.inline import build_inline_page + from .subsettings.Inline import build_inline_page return build_inline_page(self, _fmt_inline_str, _reload_plugin_settings, _open_url) except Exception as e: logx(f"OtherSettings: _open_inline_page error: {e}", False) @@ -1757,7 +1757,7 @@ def _open_inline_page(self): def _open_file_settings_page(self): try: - from .SubSettings.fileSettings import build_file_settings_page + from .subsettings.FileSettings import build_file_settings_page return build_file_settings_page(self) except Exception as e: logx(f"OtherSettings: _open_file_settings_page error: {e}", False) @@ -1765,7 +1765,7 @@ def _open_file_settings_page(self): def _open_misc_page(self): try: - from .SubSettings.misc import build_misc_page + from .subsettings.Misc import build_misc_page return build_misc_page(self) except Exception as e: logx(f"OtherSettings: _open_misc_page error: {e}", False) @@ -1773,7 +1773,7 @@ def _open_misc_page(self): def _open_apikeys_page(self): try: - from .SubSettings.apikeys import build_apikeys_page + from .subsettings.Apikeys import build_apikeys_page return build_apikeys_page() except Exception as e: logx(f"OtherSettings: _open_apikeys_page error: {e}", False) @@ -1781,7 +1781,7 @@ def _open_apikeys_page(self): def _open_updplugins_page(self): try: - from .SubSettings.updplugins import build_updplugins_page + from .subsettings.Updplugins import build_updplugins_page return build_updplugins_page(self) except Exception as e: logx(f"OtherSettings: _open_updplugins_page error: {e}", False) @@ -1789,7 +1789,7 @@ def _open_updplugins_page(self): def _open_debug_page(self): try: - from .SubSettings.debug import build_debug_page + from .subsettings.Debug import build_debug_page return build_debug_page() except Exception as e: logx(f"OtherSettings: _open_debug_page error: {e}", False) @@ -1797,7 +1797,7 @@ def _open_debug_page(self): def _onClearIgnoreListClick(self, view): try: - from ..ui.pluginsUpdates.clearIgnoreListDialog import show_clear_ignore_list_dialog + from ..updates.ClearIgnoreListDialog import show_clear_ignore_list_dialog frag = get_last_fragment() act = frag.getParentActivity() if frag else None if not act: @@ -1943,7 +1943,7 @@ def build(self): red=True )) - from ..utils.paths import getCacheRoot + from ...utils.Paths import getCacheRoot pluginCacheDir = getCacheRoot() + "/.cache/plugins" pluginCacheCard, pluginCacheUpdateFunc = _buildCacheCard(ctx, pluginCacheDir, lambda v: self._onClearPluginCacheClick(v, pluginCacheUpdateFunc), title=strings.clear_plugin_cache) if pluginCacheCard is not None: diff --git a/packit/src/SettingsActivity/utilities.py b/packit/src/python/ui/settings/Utilities.py similarity index 95% rename from packit/src/SettingsActivity/utilities.py rename to packit/src/python/ui/settings/Utilities.py index b4c0ebad..a14a06e6 100644 --- a/packit/src/SettingsActivity/utilities.py +++ b/packit/src/python/ui/settings/Utilities.py @@ -14,7 +14,7 @@ from elyx import strings except Exception as e: import android_utils as _au; _au.log(f"import elyx import strings failed: {e}") - from ..utils.importFailed import showImportFailedAlert as _sifa; _sifa() + from ...utils.ImportFailed import showImportFailedAlert as _sifa; _sifa() def _calcPluginsDirSize(): @@ -79,7 +79,7 @@ def _reloadSettings(self): logx(f"utilities._reloadSettings: {e}", False) def _on_export(self, selected_files, export_settings, export_locally): - from .service.pluginsExport import buildArchive + from .service.PluginsExport import buildArchive archive_name = self._archive_name.strip() or "plugins" buildArchive(selected_files, export_settings, export_locally, archive_name) @@ -111,7 +111,7 @@ def dismiss_spinner(): def load_and_show(): try: - from ..ui.ExportBottomSheet import loadPlugins, show as showExportSheet + from ..dialogs.ExportBottomSheet import loadPlugins, show as showExportSheet plugins = loadPlugins() run_on_ui_thread(lambda: (dismiss_spinner(), showExportSheet(plugins, self._on_export))) except Exception as e: diff --git a/packit/src/ui/PluginListActivity/helpers/__init__.py b/packit/src/python/ui/settings/__init__.py similarity index 100% rename from packit/src/ui/PluginListActivity/helpers/__init__.py rename to packit/src/python/ui/settings/__init__.py diff --git a/packit/src/SettingsActivity/service/AddKeyDialog.py b/packit/src/python/ui/settings/service/AddKeyDialog.py similarity index 100% rename from packit/src/SettingsActivity/service/AddKeyDialog.py rename to packit/src/python/ui/settings/service/AddKeyDialog.py diff --git a/packit/src/SettingsActivity/service/fastExpandableHook.py b/packit/src/python/ui/settings/service/FastExpandableHook.py similarity index 100% rename from packit/src/SettingsActivity/service/fastExpandableHook.py rename to packit/src/python/ui/settings/service/FastExpandableHook.py diff --git a/packit/src/SettingsActivity/service/pluginsExport.py b/packit/src/python/ui/settings/service/PluginsExport.py similarity index 98% rename from packit/src/SettingsActivity/service/pluginsExport.py rename to packit/src/python/ui/settings/service/PluginsExport.py index 80129445..7c9ddb0d 100644 --- a/packit/src/SettingsActivity/service/pluginsExport.py +++ b/packit/src/python/ui/settings/service/PluginsExport.py @@ -2,7 +2,7 @@ # SPDX-License-Identifier: GPL-3.0-or-later from packutil import logx -from ...utils.bulletins import factory as _pbf +from ....utils.Bulletins import factory as _pbf import os import json import zipfile @@ -36,7 +36,7 @@ def _resolvePluginsDir() -> str | None: def _resolveLocalConfigPath() -> str | None: try: - from ...utils.paths import getConfigsDir + from ....utils.Paths import getConfigsDir return os.path.join(getConfigsDir(), "localConfig.json") except Exception as e: logx(f"pluginsExport._resolveLocalConfigPath: {e}", False) @@ -79,7 +79,7 @@ def _readPluginMeta(filepath: str) -> dict: def _buildConfigScl(selected_files: list, export_settings: bool, export_locally: bool, plugins_dir: str, local_cfg_path: str | None) -> str: - from ...scl.scl import Doc + from ....scl.Scl import Doc doc = Doc.new() doc.set("type", "local" if export_locally else "external") doc.set("settings", export_settings) @@ -89,7 +89,7 @@ def _buildConfigScl(selected_files: list, export_settings: bool, export_locally: def _buildLocalScl(selected_files: list, plugins_dir: str) -> str: # list of plugin descriptors: id, name, path (relative to archive root), version - from ...scl.scl import Doc + from ....scl.Scl import Doc doc = Doc.new() listBuilder = doc.newList() diff --git a/packit/src/ui/PluginListActivity/sheets/__init__.py b/packit/src/python/ui/settings/service/__init__.py similarity index 100% rename from packit/src/ui/PluginListActivity/sheets/__init__.py rename to packit/src/python/ui/settings/service/__init__.py diff --git a/packit/src/SettingsActivity/SubSettings/apikeys.py b/packit/src/python/ui/settings/subsettings/Apikeys.py similarity index 96% rename from packit/src/SettingsActivity/SubSettings/apikeys.py rename to packit/src/python/ui/settings/subsettings/Apikeys.py index 53aeb91e..03c7da9f 100644 --- a/packit/src/SettingsActivity/SubSettings/apikeys.py +++ b/packit/src/python/ui/settings/subsettings/Apikeys.py @@ -41,8 +41,8 @@ def _get_gemini_key_preview() -> "str | None": # returns "AB..xyz" preview or None if key not set try: import ctypes - from ...nativeLoader import loadPackitKey - from ...utils.paths import getKeysDir + from ....core.NativeLoader import loadPackitKey + from ....utils.Paths import getKeysDir lib = loadPackitKey() if not lib: @@ -83,8 +83,8 @@ def _get_gemini_key_preview() -> "str | None": def _save_gemini_key(keyValue: str): try: import ctypes - from ...nativeLoader import loadPackitKey - from ...utils.paths import getKeysDir + from ....core.NativeLoader import loadPackitKey + from ....utils.Paths import getKeysDir import os keysDir = getKeysDir() @@ -117,8 +117,8 @@ def _save_gemini_key(keyValue: str): def _delete_gemini_key(): try: - from ...nativeLoader import loadPackitKey - from ...utils.paths import getKeysDir + from ....core.NativeLoader import loadPackitKey + from ....utils.Paths import getKeysDir lib = loadPackitKey() if not lib: @@ -142,7 +142,7 @@ def _delete_gemini_key(): def _has_gemini_cache() -> bool: try: import json, os - from ...utils.paths import getGeminiCachePath + from ....utils.Paths import getGeminiCachePath path = getGeminiCachePath() if not os.path.exists(path): return False @@ -166,7 +166,7 @@ def _on_confirm(b, w): b.dismiss() try: import os - from ...utils.paths import getGeminiCachePath + from ....utils.Paths import getGeminiCachePath path = getGeminiCachePath() if os.path.exists(path): os.remove(path) diff --git a/packit/src/SettingsActivity/SubSettings/comps.py b/packit/src/python/ui/settings/subsettings/Comps.py similarity index 100% rename from packit/src/SettingsActivity/SubSettings/comps.py rename to packit/src/python/ui/settings/subsettings/Comps.py diff --git a/packit/src/SettingsActivity/SubSettings/debug.py b/packit/src/python/ui/settings/subsettings/Debug.py similarity index 99% rename from packit/src/SettingsActivity/SubSettings/debug.py rename to packit/src/python/ui/settings/subsettings/Debug.py index 2f4ad81d..31639beb 100644 --- a/packit/src/SettingsActivity/SubSettings/debug.py +++ b/packit/src/python/ui/settings/subsettings/Debug.py @@ -214,7 +214,7 @@ def pair_row(left, right): ) try: - from ...ui.viewUtils import applyFontToTree + from ...components.ViewUtils import applyFontToTree applyFontToTree(outer) except Exception: pass @@ -271,7 +271,7 @@ def _onWriteLogsChange(enabled): def _sendLatestLog(view): try: - from ...utils.paths import getCacheRoot, getLogShareCachePath + from ....utils.Paths import getCacheRoot, getLogShareCachePath log_path = getCacheRoot() + "/latestlog.txt" logx(f"sendLatestLog: log_path={log_path}", True) if not os.path.exists(log_path): @@ -353,7 +353,7 @@ def _copyLatestLogPath(view): def _getLatestLogPath(): try: - from ...utils.paths import getCacheRoot + from ....utils.Paths import getCacheRoot return getCacheRoot() + "/latestlog.txt" except Exception: return None diff --git a/packit/src/SettingsActivity/SubSettings/fileSettings.py b/packit/src/python/ui/settings/subsettings/FileSettings.py similarity index 100% rename from packit/src/SettingsActivity/SubSettings/fileSettings.py rename to packit/src/python/ui/settings/subsettings/FileSettings.py diff --git a/packit/src/SettingsActivity/SubSettings/hotkeys.py b/packit/src/python/ui/settings/subsettings/Hotkeys.py similarity index 100% rename from packit/src/SettingsActivity/SubSettings/hotkeys.py rename to packit/src/python/ui/settings/subsettings/Hotkeys.py diff --git a/packit/src/SettingsActivity/SubSettings/inline.py b/packit/src/python/ui/settings/subsettings/Inline.py similarity index 96% rename from packit/src/SettingsActivity/SubSettings/inline.py rename to packit/src/python/ui/settings/subsettings/Inline.py index cac9996f..3b796771 100644 --- a/packit/src/SettingsActivity/SubSettings/inline.py +++ b/packit/src/python/ui/settings/subsettings/Inline.py @@ -3,7 +3,7 @@ from ui.settings import Header, Switch, Divider, Input, Text from elyx import strings -from ...ChatActivity.inline import inlineState +from ....integrations.chat.inline import InlineState def build_inline_page(other_settings, fmt_inline_str, reload_plugin_settings, open_url): @@ -16,7 +16,7 @@ def build_inline_page(other_settings, fmt_inline_str, reload_plugin_settings, op default=True, icon="msg_search", link_alias="inline_search_enabled", - on_change=lambda v: inlineState.update_state(v) + on_change=lambda v: InlineState.update_state(v) ), Input( key="inline_search_command", diff --git a/packit/src/SettingsActivity/SubSettings/interface.py b/packit/src/python/ui/settings/subsettings/Interface.py similarity index 100% rename from packit/src/SettingsActivity/SubSettings/interface.py rename to packit/src/python/ui/settings/subsettings/Interface.py diff --git a/packit/src/SettingsActivity/SubSettings/misc.py b/packit/src/python/ui/settings/subsettings/Misc.py similarity index 100% rename from packit/src/SettingsActivity/SubSettings/misc.py rename to packit/src/python/ui/settings/subsettings/Misc.py diff --git a/packit/src/SettingsActivity/SubSettings/PluginCardEditor.py b/packit/src/python/ui/settings/subsettings/PluginCardEditor.py similarity index 99% rename from packit/src/SettingsActivity/SubSettings/PluginCardEditor.py rename to packit/src/python/ui/settings/subsettings/PluginCardEditor.py index b5df227f..99e6b620 100644 --- a/packit/src/SettingsActivity/SubSettings/PluginCardEditor.py +++ b/packit/src/python/ui/settings/subsettings/PluginCardEditor.py @@ -2,7 +2,7 @@ # SPDX-License-Identifier: GPL-3.0-or-later from packutil import logx -from ...utils.bulletins import factory as _pbf +from ....utils.Bulletins import factory as _pbf import ctypes from android.view import View, Gravity from android.widget import LinearLayout, TextView, FrameLayout, ScrollView, ImageView, SeekBar @@ -1532,7 +1532,7 @@ def on_change(v): md3 = None try: - from ...ui.md3Slider import createMd3Slider + from ...components.Md3Slider import createMd3Slider md3 = createMd3Slider(ctx, min_val, max_val, val, on_change) except Exception as e: logx(f"PCE: md3 slider create error: {e}", False) diff --git a/packit/src/SettingsActivity/SubSettings/pluginProfile.py b/packit/src/python/ui/settings/subsettings/PluginProfile.py similarity index 100% rename from packit/src/SettingsActivity/SubSettings/pluginProfile.py rename to packit/src/python/ui/settings/subsettings/PluginProfile.py diff --git a/packit/src/SettingsActivity/SubSettings/sfx.py b/packit/src/python/ui/settings/subsettings/Sfx.py similarity index 95% rename from packit/src/SettingsActivity/SubSettings/sfx.py rename to packit/src/python/ui/settings/subsettings/Sfx.py index aca2881e..c181d37d 100644 --- a/packit/src/SettingsActivity/SubSettings/sfx.py +++ b/packit/src/python/ui/settings/subsettings/Sfx.py @@ -26,7 +26,7 @@ def _reload(): def _make_expandable(other_settings, ctx): try: from android_utils import OnClickListener - from ...dexLoader import sfxExpandableCreate + from ....core.DexLoader import sfxExpandableCreate checked_count = sum( 1 for key, _, default in _SFX_CHILDREN if settings.get(key, default) @@ -72,7 +72,7 @@ def switch_click(view): def _make_child(ctx, key, text, default): try: - from ...dexLoader import sfxChildCreate + from ....core.DexLoader import sfxChildCreate item = sfxChildCreate( ctx, @@ -100,7 +100,7 @@ def _make_volume_slider(ctx): try: from java import dynamic_proxy from java.lang.reflect import InvocationHandler - from ...dexLoader import sfxVolumeSliderCreate + from ....core.DexLoader import sfxVolumeSliderCreate class _VolumeChange(dynamic_proxy(InvocationHandler)): def invoke(self, proxy, method, args): @@ -120,7 +120,7 @@ def invoke(self, proxy, method, args): _VolumeChange(), ) if view is None: - from ...ui.md3Slider import createMd3Slider + from ...components.Md3Slider import createMd3Slider def on_change(value): settings.set("sfx_volume", int(value), reload_settings=False) diff --git a/packit/src/SettingsActivity/SubSettings/updplugins.py b/packit/src/python/ui/settings/subsettings/Updplugins.py similarity index 100% rename from packit/src/SettingsActivity/SubSettings/updplugins.py rename to packit/src/python/ui/settings/subsettings/Updplugins.py diff --git a/packit/src/ui/__init__.py b/packit/src/python/ui/settings/subsettings/__init__.py similarity index 100% rename from packit/src/ui/__init__.py rename to packit/src/python/ui/settings/subsettings/__init__.py diff --git a/packit/src/ui/suggest/fragment.py b/packit/src/python/ui/suggest/Fragment.py similarity index 97% rename from packit/src/ui/suggest/fragment.py rename to packit/src/python/ui/suggest/Fragment.py index 46aa6888..bc83fac0 100644 --- a/packit/src/ui/suggest/fragment.py +++ b/packit/src/python/ui/suggest/Fragment.py @@ -2,7 +2,7 @@ # SPDX-License-Identifier: GPL-3.0-or-later from packutil import logx -from ...utils.bulletins import factory as _pbf +from ...utils.Bulletins import factory as _pbf from android.view import Gravity, View from android.widget import FrameLayout, ImageView, LinearLayout, ScrollView, TextView from android.util import TypedValue @@ -56,7 +56,7 @@ def _apply_ripple(view, corner_dp=12, bounded=True): except Exception: pass existing = view.getBackground() - from ...utils.ripple import safe_ripple as _safe_ripple + from ...utils.Ripple import safe_ripple as _safe_ripple ripple = _safe_ripple( ColorStateList.valueOf(ripple_color), existing, @@ -986,39 +986,24 @@ def _worker(): pass repometa = None try: - from ...utils.paths import getRepoCachePath - import json as _json - - # suggest_config may carry rm_rid indirectly; scan all caches - # find by checking suggest_config origin — passed as is, so use paths util - cache_dir_path = getRepoCachePath("") - import os as _os - cache_dir = _os.path.dirname(cache_dir_path) - for fname in (_os.listdir(cache_dir) if _os.path.isdir(cache_dir) else []): - if not fname.endswith(".json"): - continue - fpath = _os.path.join(cache_dir, fname) + from ...network import Storage + from ...utils import CachedRepos + + # which repository carries this plugin is not recorded here, so + # every cached one is asked in turn + for rm_rid, _cached in CachedRepos.all_cached(): try: - with open(fpath, "r", encoding="utf-8") as f: - cached = _json.load(f) - repomap = cached.get("repomap", {}) - plugins_url = repomap.get("plugins", "") + plugins_url = CachedRepos.plugins_url(rm_rid) if not plugins_url: continue - import requests as _req - r = _req.get(plugins_url, timeout=10) - if r.status_code != 200: + entries, error = Storage.fetch_plugins(plugins_url) + if error: continue - data = r.json() - plugins = data.get("plugins", {}) repo_plugin = None - if isinstance(plugins, dict): - repo_plugin = plugins.get(plugin_id) - elif isinstance(plugins, list): - for item in plugins: - if isinstance(item, dict) and item.get("id") == plugin_id: - repo_plugin = item - break + for item in entries: + if item.get("id") == plugin_id: + repo_plugin = item + break if repo_plugin is not None: repo_version = repo_plugin.get("version", "?") meta_version = meta.get("version", "?") @@ -1055,30 +1040,13 @@ def _load_forked_plugins(repo_data: dict) -> list: rm_rid = repometa.get("rm_rid") if isinstance(repometa, dict) else None if not rm_rid: return [] - from ...utils.paths import getRepoCachePath - path = getRepoCachePath(rm_rid) - if not _os.path.exists(path): - return [] - with open(path, "r", encoding="utf-8") as f: - cached = _json.load(f) - plugins_url = cached.get("repomap", {}).get("plugins", "") + from ...network import Storage + from ...utils import CachedRepos + plugins_url = CachedRepos.plugins_url(rm_rid) if not plugins_url: return [] - r = _req.get(plugins_url, timeout=10) - if r.status_code != 200: - return [] - data = r.json() - plugins_raw = data.get("plugins", []) - plugins = [] - if isinstance(plugins_raw, dict): - for pid, info in plugins_raw.items(): - if isinstance(info, dict): - plugins.append({"id": pid, **info}) - elif isinstance(plugins_raw, list): - for item in plugins_raw: - if isinstance(item, dict) and item.get("id"): - plugins.append(item) - return plugins + plugins, error = Storage.fetch_plugins(plugins_url) + return [] if error else plugins except Exception as e: logx(f"suggest: _load_forked_plugins error: {e}", False) return [] @@ -1198,7 +1166,7 @@ def _make_forked_popup(act, plugins: list, on_select): icon_lp = LL.LayoutParams(icon_size_px, icon_size_px) icon_lp.rightMargin = dp(12) row.addView(icon_view, icon_lp) - from ...utils.stickers import load_sticker + from ...utils.Stickers import load_sticker load_sticker(icon_view, icon_str, icon_size_dp) except Exception as e: logx(f"suggest: popup icon error: {e}", False) @@ -1524,20 +1492,13 @@ def onFragmentCreate(self, *_): rm_rid = repometa.get("rm_rid") self._rm_rid = rm_rid or "default" if rm_rid: - import json, os - from ...utils.paths import getRepoCachePath - path = getRepoCachePath(rm_rid) - if os.path.exists(path): - with open(path, "r", encoding="utf-8") as f: - data = json.load(f) - sp = data.get("suggest_plugins") - if isinstance(sp, dict): - self._suggest_config = sp - logx(f"suggest: loaded suggest_plugins for {rm_rid}", True) - else: - logx(f"suggest: suggest_plugins missing in cache for {rm_rid}", True) + from ...utils import CachedRepos + sp = CachedRepos.suggest_config(rm_rid) + if sp is not None: + self._suggest_config = sp + logx(f"suggest: loaded suggest_plugins for {rm_rid}", True) else: - logx(f"suggest: cache file not found for {rm_rid}", True) + logx(f"suggest: no suggest_plugins cached for {rm_rid}", True) else: sp = self._repo_data.get("suggest_plugins") if isinstance(self._repo_data, dict) else None if isinstance(sp, dict): @@ -2935,7 +2896,7 @@ def _show_forked_selected_card(self, act, plugin: dict): icon_lp = LinearLayout.LayoutParams(icon_size_px, icon_size_px) icon_lp.rightMargin = dp(10) card.addView(icon_view, icon_lp) - from ...utils.stickers import load_sticker + from ...utils.Stickers import load_sticker load_sticker(icon_view, icon_str, icon_size_dp) except Exception as e: logx(f"suggest: forked selected icon error: {e}", False) @@ -3513,23 +3474,16 @@ def _task(): # app cache is preferred (isInternalUri lets those through), but # some ROMs deny writes there — get_cache_dir() pointed straight # at it and the whole submit died with EACCES. - import shutil, tempfile - from ...utils.paths import getStagingDir, isInternalPath - staging_dir = getStagingDir() + from ...utils.Paths import stageFileForUpload, isInternalPath def _stage_for_upload(src: str, display_name: str) -> str: suffix = "" dot = display_name.rfind(".") if dot >= 0: suffix = display_name[dot:] - tmp = tempfile.NamedTemporaryFile( - delete=False, suffix=suffix, - dir=staging_dir - ) - tmp.close() - shutil.copy2(src, tmp.name) - logx(f"suggest._task: staged {src} -> {tmp.name}", True) - return tmp.name + dst = stageFileForUpload(src, suffix) + logx(f"suggest._task: staged {src} -> {dst}", True) + return dst staged_main = None staged_extras = [] diff --git a/packit/src/ui/pluginsUpdates/__init__.py b/packit/src/python/ui/suggest/__init__.py similarity index 100% rename from packit/src/ui/pluginsUpdates/__init__.py rename to packit/src/python/ui/suggest/__init__.py diff --git a/packit/src/ui/pluginsUpdates/clearIgnoreListDialog.py b/packit/src/python/ui/updates/ClearIgnoreListDialog.py similarity index 99% rename from packit/src/ui/pluginsUpdates/clearIgnoreListDialog.py rename to packit/src/python/ui/updates/ClearIgnoreListDialog.py index 2ba143b0..9510539f 100644 --- a/packit/src/ui/pluginsUpdates/clearIgnoreListDialog.py +++ b/packit/src/python/ui/updates/ClearIgnoreListDialog.py @@ -174,7 +174,7 @@ def _make_btn(act, text: str, accent: bool): def _get_index_path(pkg: str, rm_rid: str) -> str: - from ...utils.paths import getRepoIndexPath + from ...utils.Paths import getRepoIndexPath return getRepoIndexPath(rm_rid) @@ -421,7 +421,7 @@ def _on_cancel_click(v): card.setScaleY(0.92) try: - from ..viewUtils import applyFontToTree + from ..components.ViewUtils import applyFontToTree applyFontToTree(card) except Exception: pass diff --git a/packit/src/ui/pluginsUpdates/fragment.py b/packit/src/python/ui/updates/Fragment.py similarity index 94% rename from packit/src/ui/pluginsUpdates/fragment.py rename to packit/src/python/ui/updates/Fragment.py index b59615ca..78eb3552 100644 --- a/packit/src/ui/pluginsUpdates/fragment.py +++ b/packit/src/python/ui/updates/Fragment.py @@ -2,7 +2,7 @@ # SPDX-License-Identifier: GPL-3.0-or-later from packutil import logx -from ...utils.netQueue import run_io +from ...utils.NetQueue import run_io import json import os import threading @@ -19,22 +19,22 @@ from org.telegram.ui.ActionBar import Theme except Exception as e: logx(f"pluginsUpdates: import Theme failed: {e}", False) - from ...utils.importFailed import showImportFailedAlert as _sifa; _sifa() + from ...utils.ImportFailed import showImportFailedAlert as _sifa; _sifa() try: from org.telegram.ui.Components import LayoutHelper except Exception as e: logx(f"pluginsUpdates: import LayoutHelper failed: {e}", False) - from ...utils.importFailed import showImportFailedAlert as _sifa; _sifa() + from ...utils.ImportFailed import showImportFailedAlert as _sifa; _sifa() try: from org.telegram.messenger import AndroidUtilities, ApplicationLoader except Exception as e: logx(f"pluginsUpdates: import AndroidUtilities/ApplicationLoader failed: {e}", False) - from ...utils.importFailed import showImportFailedAlert as _sifa; _sifa() + from ...utils.ImportFailed import showImportFailedAlert as _sifa; _sifa() try: from com.exteragram.messenger.plugins.ui.components.templates import UniversalFragment except Exception as e: logx(f"pluginsUpdates: import UniversalFragment failed: {e}", False) - from ...utils.importFailed import showImportFailedAlert as _sifa; _sifa() + from ...utils.ImportFailed import showImportFailedAlert as _sifa; _sifa() from android_utils import OnClickListener @@ -43,19 +43,14 @@ from elyx import settings, strings except Exception as e: logx(f"pluginsUpdates: import elyx.settings failed: {e}", False) - from ...utils.importFailed import showImportFailedAlert as _sifa; _sifa() + from ...utils.ImportFailed import showImportFailedAlert as _sifa; _sifa() def _get_index_path(pkg: str, rm_rid: str) -> str: - from ...utils.paths import getRepoIndexPath + from ...utils.Paths import getRepoIndexPath return getRepoIndexPath(rm_rid) -def _get_repo_cache_path(pkg: str, rm_rid: str) -> str: - from ...utils.paths import getRepoCachePath - return getRepoCachePath(rm_rid) - - def _get_repos() -> list: try: raw = settings.get("repositories", "[]") @@ -82,42 +77,18 @@ def _read_index(pkg: str, rm_rid: str) -> list: def _get_repo_plugins_url(pkg: str, rm_rid: str, fallback_url: str) -> str: - # resolves plugins url from cached repomap, falls back to repo url - cache_path = _get_repo_cache_path(pkg, rm_rid) - if os.path.exists(cache_path): - try: - with open(cache_path, "r", encoding="utf-8") as f: - cached = json.load(f) - resolved = cached.get("repomap", {}).get("plugins") - if resolved: - return resolved - except Exception as e: - logx(f"pluginsUpdates: _get_repo_plugins_url error for '{rm_rid}': {e}", False) - return fallback_url + from ...utils import CachedRepos + return CachedRepos.plugins_url(rm_rid, fallback_url) def _fetch_repo_plugins(url: str) -> dict: - # returns dict: plugin_id plugin_info - try: - r = requests.get(url, timeout=20, headers={"User-Agent": "PackIt/1.0"}) - if r.status_code != 200: - logx(f"pluginsUpdates: HTTP {r.status_code} for {url}", True) - return {} - config = r.json() - raw = config.get("plugins", {}) - result = {} - if isinstance(raw, dict): - for pid, info in raw.items(): - if isinstance(info, dict): - result[pid] = info - elif isinstance(raw, list): - for item in raw: - if isinstance(item, dict) and item.get("id"): - result[item["id"]] = item - return result - except Exception as e: - logx(f"pluginsUpdates: _fetch_repo_plugins error for '{url}': {e}", False) + # {plugin_id: plugin_info} — this screen looks plugins up by id + from ...network import Storage + entries, error = Storage.fetch_plugins(url) + if error: + logx(f"pluginsUpdates: {error} for {url}", True) return {} + return {entry["id"]: entry for entry in entries if entry.get("id")} def _version_tuple(v: str): @@ -207,7 +178,7 @@ def _check_updates(pkg: str) -> list: repo_app_ver = str(repo_info.get("app_version") or "") if repo_app_ver: try: - from ...utils.app_version import check_app_version + from ...utils.AppVersion import check_app_version if not check_app_version(repo_app_ver): continue except Exception as e: @@ -361,7 +332,7 @@ def onFragmentCreate(self, *_): def onFragmentDestroy(self, *_): self._alive[0] = False try: - from ...core import remove_install_listener + from ...core.Core import remove_install_listener for fn in list(self._active_listeners): remove_install_listener(fn) self._active_listeners.clear() @@ -670,7 +641,7 @@ def _make_icon_btn(res_name: str): def _open_ignore_list_dialog(self): try: - from .clearIgnoreListDialog import show_clear_ignore_list_dialog + from .ClearIgnoreListDialog import show_clear_ignore_list_dialog show_clear_ignore_list_dialog(self._act, on_close=self._on_refresh_click) except Exception as e: logx(f"pluginsUpdates: _open_ignore_list_dialog error: {e}", False) @@ -692,7 +663,7 @@ def task(): run_on_queue(task) try: - from .hideAllDialog import show_hide_all_dialog + from .HideAllDialog import show_hide_all_dialog show_hide_all_dialog(self._act, on_confirm) except Exception as e: logx(f"pluginsUpdates: _on_ignore_all_click error: {e}", False) @@ -954,7 +925,7 @@ def task(): try: # purge stale entries first try: - from ...utils.installIndex import purge_missing + from ...utils.InstallIndex import purge_missing purge_missing() except Exception as e: logx(f"pluginsUpdates: purge_missing error: {e}", False) @@ -983,7 +954,7 @@ def task(): pass def _open_catalog(): try: - from ..PluginListActivity.fragment import InstallUI + from ..plugins.Fragment import InstallUI InstallUI(self._plugin).open() except Exception as e: logx(f"pluginsUpdates: _open_catalog error: {e}", False) @@ -1271,7 +1242,7 @@ def _make_update_card(self, act, item: dict): icon_lp = LinearLayout.LayoutParams(dp(icon_size_dp), dp(icon_size_dp)) icon_lp.rightMargin = dp(12) top_row.addView(icon_view, icon_lp) - from ...utils.stickers import load_sticker + from ...utils.Stickers import load_sticker load_sticker(icon_view, icon_str, icon_size_dp) except Exception as e: logx(f"pluginsUpdates: icon init error for '{pid}': {e}", False) @@ -1456,8 +1427,8 @@ def task(): def on_ui(): try: - from ..PluginListActivity.fragment import InstallUI - from ..PluginActivity.fragment import show_plugin_profile + from ..plugins.Fragment import InstallUI + from ..plugin.Fragment import show_plugin_profile install_ui = InstallUI(plugin) all_plugins = [{"id": k, **v} for k, v in repo_plugins.items() if isinstance(v, dict)] show_plugin_profile(plugin_data, install_ui, all_plugins=all_plugins, repo_id=repo_id) @@ -1474,7 +1445,7 @@ def on_ui(): def _show_ignore_dialog(self, pid: str, repo_id: str, repo_version: str, card_view): try: - from .hideDialog import show_hide_dialog + from .HideDialog import show_hide_dialog def on_apply(forever: bool): self._apply_ignore(pid, repo_id, repo_version, forever, card_view) @@ -1564,40 +1535,23 @@ def set_btn_state(state: str): def task(): try: - from ...deeplinks.install import _resolvePluginsUrl - from ...core import install_plugin - import requests as _requests + from ...core.Core import install_plugin + from ...network import Storage + from ...utils import CachedRepos - plugins_url = _resolvePluginsUrl(repo) + plugins_url = CachedRepos.plugins_url(repo) if not plugins_url: logx(f"pluginsUpdates: _install_update no plugins url for '{repo_id}'", True) run_on_ui_thread(lambda: set_btn_state("idle")) return - r = _requests.get(plugins_url, timeout=20, headers={"User-Agent": "PackIt/1.0"}) - if r.status_code != 200: - logx(f"pluginsUpdates: _install_update HTTP {r.status_code}", True) + all_plugins, error = Storage.fetch_plugins(plugins_url) + if error: + logx(f"pluginsUpdates: _install_update {error}", True) run_on_ui_thread(lambda: set_btn_state("idle")) return - data = r.json() - plugins_raw = data.get("plugins", {}) - - plugin = None - all_plugins = [] - if isinstance(plugins_raw, dict): - for _pid, info in plugins_raw.items(): - if isinstance(info, dict): - all_plugins.append({"id": _pid, **info}) - info = plugins_raw.get(pid) - if isinstance(info, dict): - plugin = {"id": pid, **info} - elif isinstance(plugins_raw, list): - all_plugins = [p for p in plugins_raw if isinstance(p, dict)] - for p in plugins_raw: - if isinstance(p, dict) and p.get("id") == pid: - plugin = p - break + plugin = next((p for p in all_plugins if p.get("id") == pid), None) if not plugin: logx(f"pluginsUpdates: _install_update plugin '{pid}' not found in repo", True) @@ -1610,7 +1564,7 @@ def on_finish(ok): if not ok: run_on_ui_thread(lambda: set_btn_state("idle")) - from ...core import add_install_listener, remove_install_listener + from ...core.Core import add_install_listener, remove_install_listener listener_ref = [None] @@ -2057,13 +2011,14 @@ def set_btn_state(state: str): def task(): try: - from ...deeplinks.install import _resolvePluginsUrl - from ...core import install_plugin_silent - from ...utils.paths import getPluginsDir + from ...core.Core import install_plugin_silent + from ...utils.Paths import getPluginsDir + from ...network import Storage + from ...utils import CachedRepos import requests as _requests import os as _os - plugins_url = _resolvePluginsUrl(repo) + plugins_url = CachedRepos.plugins_url(repo) if not plugins_url: logx(f"pluginsUpdates: _install_update_silent no plugins url for '{repo_id}'", True) run_on_ui_thread(lambda: set_btn_state("idle")) @@ -2071,26 +2026,15 @@ def task(): on_done() return - r = _requests.get(plugins_url, timeout=20, headers={"User-Agent": "PackIt/1.0"}) - if r.status_code != 200: - logx(f"pluginsUpdates: _install_update_silent plugins list HTTP {r.status_code} for '{pid}'", True) + entries, error = Storage.fetch_plugins(plugins_url) + if error: + logx(f"pluginsUpdates: _install_update_silent {error} for '{pid}'", True) run_on_ui_thread(lambda: set_btn_state("idle")) if on_done: on_done() return - data = r.json() - plugins_raw = data.get("plugins", {}) - plugin = None - if isinstance(plugins_raw, dict): - info = plugins_raw.get(pid) - if isinstance(info, dict): - plugin = {"id": pid, **info} - elif isinstance(plugins_raw, list): - for p in plugins_raw: - if isinstance(p, dict) and p.get("id") == pid: - plugin = p - break + plugin = next((p for p in entries if p.get("id") == pid), None) if not plugin: logx(f"pluginsUpdates: _install_update_silent plugin '{pid}' not found in repo", True) @@ -2182,7 +2126,7 @@ def _on_update_all_click(self): # elyx plugins install in parallel via existing _install_update (shows install dialog) # non-elyx plugins install sequentially via _install_update_silent: done[i] → start[i+1] - from ...core import _is_elyx_plugin + from ...core.Core import _is_elyx_plugin elyx_items = [(item, btn, icon) for item, btn, icon in pending if _is_elyx_plugin(item)] silent_items = [(item, btn, icon) for item, btn, icon in pending if not _is_elyx_plugin(item)] diff --git a/packit/src/ui/pluginsUpdates/hideAllDialog.py b/packit/src/python/ui/updates/HideAllDialog.py similarity index 99% rename from packit/src/ui/pluginsUpdates/hideAllDialog.py rename to packit/src/python/ui/updates/HideAllDialog.py index b10d7ce8..6ed9a123 100644 --- a/packit/src/ui/pluginsUpdates/hideAllDialog.py +++ b/packit/src/python/ui/updates/HideAllDialog.py @@ -246,7 +246,7 @@ def _dismiss(on_end=None): card.setScaleY(0.92) try: - from ..viewUtils import applyFontToTree + from ..components.ViewUtils import applyFontToTree applyFontToTree(card) except Exception: pass diff --git a/packit/src/ui/pluginsUpdates/hideDialog.py b/packit/src/python/ui/updates/HideDialog.py similarity index 99% rename from packit/src/ui/pluginsUpdates/hideDialog.py rename to packit/src/python/ui/updates/HideDialog.py index 63d28c4d..6a022024 100644 --- a/packit/src/ui/pluginsUpdates/hideDialog.py +++ b/packit/src/python/ui/updates/HideDialog.py @@ -309,7 +309,7 @@ def _open_mode_picker(): card.setScaleY(0.92) try: - from ..viewUtils import applyFontToTree + from ..components.ViewUtils import applyFontToTree applyFontToTree(card) except Exception: pass diff --git a/packit/src/ui/pluginsUpdates/startupSheet.py b/packit/src/python/ui/updates/StartupSheet.py similarity index 98% rename from packit/src/ui/pluginsUpdates/startupSheet.py rename to packit/src/python/ui/updates/StartupSheet.py index 563928e4..b428f8cc 100644 --- a/packit/src/ui/pluginsUpdates/startupSheet.py +++ b/packit/src/python/ui/updates/StartupSheet.py @@ -30,7 +30,7 @@ except Exception as e: logx(f"startupSheet: import elyx failed: {e}", False) -from .fragment import ( +from .Fragment import ( _get_repos, _check_updates, _filter_ignored, _ignore_until_next, _ignore_forever, ) @@ -127,7 +127,7 @@ def _make_item_card(act, item: dict, plugin_ref, on_action): icon_lp = LinearLayout.LayoutParams(dp(icon_size_dp), dp(icon_size_dp)) icon_lp.rightMargin = dp(12) header_row.addView(icon_view, icon_lp) - from ...utils.stickers import load_sticker + from ...utils.Stickers import load_sticker load_sticker(icon_view, icon_str, icon_size_dp) except Exception as e: logx(f"startupSheet: icon error for '{pid}': {e}", False) @@ -631,9 +631,9 @@ def _do_install(item: dict, plugin, set_btn_state=None): def task(): try: - from ...deeplinks.install import _resolvePluginsUrl - from ...utils.paths import getPluginsDir - from ...core import install_plugin_silent + from ...deeplinks.Install import _resolvePluginsUrl + from ...utils.Paths import getPluginsDir + from ...core.Core import install_plugin_silent import requests as _req import os @@ -712,7 +712,7 @@ def check_and_show_startup_updates(plugin=None): def task(): try: try: - from ...utils.installIndex import purge_missing + from ...utils.InstallIndex import purge_missing purge_missing() except Exception as e: logx(f"startupSheet: purge_missing error: {e}", False) diff --git a/packit/src/ui/suggest/__init__.py b/packit/src/python/ui/updates/__init__.py similarity index 100% rename from packit/src/ui/suggest/__init__.py rename to packit/src/python/ui/updates/__init__.py diff --git a/packit/src/utils/app_version.py b/packit/src/python/utils/AppVersion.py similarity index 100% rename from packit/src/utils/app_version.py rename to packit/src/python/utils/AppVersion.py diff --git a/packit/src/utils/buildInfo.py b/packit/src/python/utils/BuildInfo.py similarity index 100% rename from packit/src/utils/buildInfo.py rename to packit/src/python/utils/BuildInfo.py diff --git a/packit/src/utils/bulletins.py b/packit/src/python/utils/Bulletins.py similarity index 100% rename from packit/src/utils/bulletins.py rename to packit/src/python/utils/Bulletins.py diff --git a/packit/src/python/utils/CachedRepos.py b/packit/src/python/utils/CachedRepos.py new file mode 100644 index 00000000..10cfa99c --- /dev/null +++ b/packit/src/python/utils/CachedRepos.py @@ -0,0 +1,185 @@ +# pyright: reportMissingImports=false +# SPDX-License-Identifier: GPL-3.0-or-later + +# /packit/reposCache/{rm_rid}.json — the repomap a repository last +# served, and everything read out of it. +# +# The file is written whenever a repomap is fetched and read by nearly every +# screen: the plugin catalogue and the icon catalogue resolve their list urls +# through it, the sources screen draws a card from it, the report dialog takes +# its reasons from it, autocomplete and the deeplinks all start here. Reading it +# is the single most copied piece of code in the plugin, so it lives in one +# place and nowhere else. +# +# Nothing here touches the network. What fills the file is network/Storage. + +from packutil import logx +import json +import os + +from . import Jsonx as _jsonx +from .Paths import getRepoCachePath, getReposCacheDir + + +def path(rm_rid) -> str: + return getRepoCachePath(str(rm_rid or "")) + + +def read(rm_rid): + """The cached repomap for a repository, or None. + + Parsed leniently: the official repomap has shipped with a trailing comma + more than once, and a screen that cannot draw a repository because of one + character is worse than a screen that tolerates it. + """ + rm_rid = str(rm_rid or "") + if not rm_rid: + return None + file_path = path(rm_rid) + try: + if not os.path.isfile(file_path): + return None + with open(file_path, "r", encoding="utf-8") as f: + data = _jsonx.loads(f.read()) + return data if isinstance(data, dict) else None + except Exception as e: + logx(f"cachedRepos: unreadable cache for '{rm_rid}': {e}", True) + return None + + +def write(rm_rid, data) -> bool: + rm_rid = str(rm_rid or "") + if not rm_rid or not isinstance(data, dict): + return False + try: + os.makedirs(getReposCacheDir(), exist_ok=True) + with open(path(rm_rid), "w", encoding="utf-8") as f: + json.dump(data, f, indent=2, ensure_ascii=False) + return True + except Exception as e: + logx(f"cachedRepos: cannot write cache for '{rm_rid}': {e}", False) + return False + + +def forget(rm_rid) -> bool: + """Drop a repository's cached repomap. True when there was one to drop.""" + rm_rid = str(rm_rid or "") + if not rm_rid: + return False + try: + file_path = path(rm_rid) + if not os.path.isfile(file_path): + return False + os.remove(file_path) + return True + except Exception as e: + logx(f"cachedRepos: cannot delete cache for '{rm_rid}': {e}", False) + return False + + +def mtime(rm_rid) -> float: + try: + return os.path.getmtime(path(rm_rid)) + except Exception: + return 0.0 + + +def all_cached() -> list: + """[(rm_rid, repomap), …] for every repository with a cache on disk.""" + out = [] + try: + cache_dir = getReposCacheDir() + if not os.path.isdir(cache_dir): + return out + for name in sorted(os.listdir(cache_dir)): + if not name.endswith(".json"): + continue + # the installed-plugin index and the counts live in this directory + # too, and neither of them is a repomap + if name.endswith("-index.json") or name.endswith("-stats.json"): + continue + rm_rid = name[:-len(".json")] + data = read(rm_rid) + if isinstance(data, dict): + out.append((rm_rid, data)) + except Exception as e: + logx(f"cachedRepos: cannot list the cache: {e}", False) + return out + + +# ----------------------------------------------------------- what is inside + +def _repo_id_of(repo) -> str: + if isinstance(repo, dict): + return str(repo.get("id") or "") + return str(repo or "") + + +def repometa(repo) -> dict: + data = read(_repo_id_of(repo)) + meta = data.get("repometa") if isinstance(data, dict) else None + return meta if isinstance(meta, dict) else {} + + +def section_url(repo, key: str, fallback: str = "") -> str: + """repomap. out of the cache, falling back to the stored url. + + `repo` is either the stored dict or a bare rm_rid. The fallback matters: + a repomap that is itself the plugin list has no repomap section at all, + and then the repository's own url is the list. + """ + if isinstance(repo, dict) and not fallback: + fallback = str(repo.get("url") or "").strip() + data = read(_repo_id_of(repo)) + repomap = data.get("repomap") if isinstance(data, dict) else None + if isinstance(repomap, dict): + url = str(repomap.get(key) or "").strip() + if url: + return url + return fallback + + +def plugins_url(repo, fallback: str = "") -> str: + return section_url(repo, "plugins", fallback) + + +def icons_url(repo, fallback: str = "") -> str: + return section_url(repo, "icons", fallback) + + +def icon_url(repo) -> str: + """repometa.rm_icon, but only when it is a picture. + + Repositories written before rm_icon was a link put an R.drawable name here. + Nothing resolves those any more, so anything that is not http(s) is no icon. + """ + url = str(repometa(repo).get("rm_icon") or "").strip() + return url if url.lower().startswith(("http://", "https://")) else "" + + +def reasons(repo) -> list: + data = read(_repo_id_of(repo)) + block = data.get("reasons") if isinstance(data, dict) else None + items = block.get("reasons") if isinstance(block, dict) else None + if not isinstance(items, list): + return [] + return [str(r) for r in items if r] + + +def report_settings(repo): + """(forum_username, topic_msg_id), or (None, None) when the repo has none.""" + data = read(_repo_id_of(repo)) + block = data.get("reasons") if isinstance(data, dict) else None + values = block.get("settings") if isinstance(block, dict) else None + if isinstance(values, list) and len(values) >= 2: + try: + return str(values[0]), int(values[1]) + except Exception: + return None, None + return None, None + + +def suggest_config(repo): + data = read(_repo_id_of(repo)) + block = data.get("suggest_plugins") if isinstance(data, dict) else None + return block if isinstance(block, dict) else None diff --git a/packit/src/utils/copy.py b/packit/src/python/utils/Copy.py similarity index 88% rename from packit/src/utils/copy.py rename to packit/src/python/utils/Copy.py index ba04476a..c8f591c1 100644 --- a/packit/src/utils/copy.py +++ b/packit/src/python/utils/Copy.py @@ -3,19 +3,19 @@ from packutil import logx -from ..utils.bulletins import factory as _pbf +from .Bulletins import factory as _pbf try: from org.telegram.messenger import AndroidUtilities, R as R_tg except Exception as e: import android_utils as _au; _au.log(f"import org.telegram.messenger import AndroidUtilities, R as R_tg failed: {e}") - from ..utils.importFailed import showImportFailedAlert as _sifa; _sifa() + from .ImportFailed import showImportFailedAlert as _sifa; _sifa() from client_utils import get_last_fragment from hook_utils import find_class try: from elyx import strings except Exception as e: import android_utils as _au; _au.log(f"import elyx import strings failed: {e}") - from ..utils.importFailed import showImportFailedAlert as _sifa; _sifa() + from .ImportFailed import showImportFailedAlert as _sifa; _sifa() BulletinFactory = find_class("org.telegram.ui.Components.BulletinFactory") diff --git a/packit/src/utils/drawable.py b/packit/src/python/utils/Drawable.py similarity index 100% rename from packit/src/utils/drawable.py rename to packit/src/python/utils/Drawable.py diff --git a/packit/src/utils/globalState.py b/packit/src/python/utils/GlobalState.py similarity index 100% rename from packit/src/utils/globalState.py rename to packit/src/python/utils/GlobalState.py diff --git a/packit/src/utils/hashUtil.py b/packit/src/python/utils/HashUtil.py similarity index 98% rename from packit/src/utils/hashUtil.py rename to packit/src/python/utils/HashUtil.py index 7c784a50..60b58e04 100644 --- a/packit/src/utils/hashUtil.py +++ b/packit/src/python/utils/HashUtil.py @@ -27,7 +27,7 @@ def _getBitHashLib(): if _libLoaded: return _lib _libLoaded = True - from ..nativeLoader import loadBitHash + from ..core.NativeLoader import loadBitHash _lib = loadBitHash() if _lib is not None: logx("hashutil: libbithash.so loaded successfully!", True) diff --git a/packit/src/python/utils/ImagePool.py b/packit/src/python/utils/ImagePool.py new file mode 100644 index 00000000..a4aa6e6c --- /dev/null +++ b/packit/src/python/utils/ImagePool.py @@ -0,0 +1,99 @@ +# pyright: reportMissingImports=false +# SPDX-License-Identifier: GPL-3.0-or-later + +# Shared worker pool and decoder for remote images. +# +# One thread per image starves the CPU and the network the moment a screen +# opens a dozen of them at once — the icon catalog learned that the hard way and +# grew a small fixed pool. Repository icons need exactly the same thing, so the +# pool and the decode step live here instead of being copied a second time. + +from packutil import logx +import threading + +_WORKERS = 4 +_queue = None +_queue_lock = threading.Lock() + + +def submit(task): + # runs task() on one of the pool threads; tasks are served in order + global _queue + with _queue_lock: + if _queue is None: + import queue + _queue = queue.Queue() + + def _worker(): + while True: + fn = _queue.get() + try: + fn() + except Exception as e: + logx(f"imagePool: worker error: {e}", True) + finally: + _queue.task_done() + + for _ in range(_WORKERS): + threading.Thread(target=_worker, daemon=True).start() + _queue.put(task) + + +def fetch(url: str, timeout: int = 15): + # downloads the bytes of an image, or None + try: + import requests + r = requests.get(url, timeout=timeout, headers={ + "User-Agent": "PackIt/1.0 (Android; github.com/shareui/packit)" + }) + if r.status_code != 200: + logx(f"imagePool: HTTP {r.status_code} for {url}", True) + return None + return r.content + except Exception as e: + logx(f"imagePool: fetch error for {url}: {e}", False) + return None + + +def decode(data, px: int, is_svg: bool = False): + # bytes -> Bitmap scaled for a px-sized slot, or None + if not data: + return None + from hook_utils import find_class + try: + if is_svg: + SVG = find_class("com.caverock.androidsvg.SVG") + ByteArrayInputStream = find_class("java.io.ByteArrayInputStream") + Bitmap = find_class("android.graphics.Bitmap") + Canvas = find_class("android.graphics.Canvas") + svg = SVG.getFromInputStream(ByteArrayInputStream(data)) + # force the render size; the viewBox scales to it + svg.setDocumentWidth(px) + svg.setDocumentHeight(px) + bmp = Bitmap.createBitmap(px, px, Bitmap.Config.ARGB_8888) + svg.renderToCanvas(Canvas(bmp)) + return bmp + + BitmapFactory = find_class("android.graphics.BitmapFactory") + opts = BitmapFactory.Options() + opts.inJustDecodeBounds = True + BitmapFactory.decodeByteArray(data, 0, len(data), opts) + if opts.outWidth > 0 and opts.outHeight > 0 and px > 0: + opts.inSampleSize = max(1, min(opts.outWidth // px, opts.outHeight // px)) + opts.inJustDecodeBounds = False + return BitmapFactory.decodeByteArray(data, 0, len(data), opts) + except Exception as e: + logx(f"imagePool: decode error: {e}", False) + return None + + +def looks_like_svg(url: str, data=None) -> bool: + try: + if str(url).lower().split("?", 1)[0].endswith(".svg"): + return True + if data: + head = bytes(data[:256]).lstrip() + return head.startswith(b" str: - from .paths import getRepoIndexPath + from .Paths import getRepoIndexPath return getRepoIndexPath(rm_rid) @@ -30,7 +30,7 @@ def _hash_matches(p: dict) -> bool: return True local_path = str(p.get("local_path") or "") try: - from .hashUtil import matchesStoredHash + from .HashUtil import matchesStoredHash return matchesStoredHash( local_path, sha256=str(p.get("hash") or ""), @@ -147,7 +147,7 @@ def commit_pending(): return try: - from .paths import getPluginsDir, getRepoIndexPath + from .Paths import getPluginsDir, getRepoIndexPath except Exception as e: logx(f"installIndex: cannot import paths: {e}", False) return @@ -184,7 +184,7 @@ def commit_pending(): bithash_val = "" if file_exists: try: - from .hashUtil import _hashFileSha256, _hashFileBithash, _getBitHashLib + from .HashUtil import _hashFileSha256, _hashFileBithash, _getBitHashLib hash_val = _hashFileSha256(candidate_path) if _getBitHashLib() is not None: bithash_val = _hashFileBithash(candidate_path) @@ -242,7 +242,7 @@ def commit_elyx_pending(plugin_info: dict, rm_rid: str, original_path: str = "") return try: - from .paths import getPackitArchivesDir + from .Paths import getPackitArchivesDir except Exception as e: logx(f"installIndex.elyx: cannot import paths: {e}", False) return @@ -290,7 +290,7 @@ def commit_elyx_pending(plugin_info: dict, rm_rid: str, original_path: str = "") hash_source = local_path if local_path and local_path != "Unknown" else "" if hash_source: try: - from .hashUtil import _hashFileSha256, _hashFileBithash, _getBitHashLib + from .HashUtil import _hashFileSha256, _hashFileBithash, _getBitHashLib hash_val = _hashFileSha256(hash_source) if _getBitHashLib() is not None: bithash_val = _hashFileBithash(hash_source) diff --git a/packit/src/python/utils/Jsonx.py b/packit/src/python/utils/Jsonx.py new file mode 100644 index 00000000..7df8d728 --- /dev/null +++ b/packit/src/python/utils/Jsonx.py @@ -0,0 +1,76 @@ +# pyright: reportMissingImports=false +# SPDX-License-Identifier: GPL-3.0-or-later + +# Forgiving parser for repository files. +# +# A repomap.json is written by hand, and a trailing comma before a closing +# brace is the mistake people make — javascript and python both accept it, +# json does not. The official repository shipped one and every client then +# refused to add or refresh it, reporting "no metadata" for a file that was +# otherwise perfectly fine. +# +# So: parse strictly, and only if that fails strip trailing commas and try +# once more. The stripping walks the text instead of running a regex over it, +# because a naive pattern also eats the comma in a string like "a, }" and +# would quietly change data. + +from packutil import logx +import json + + +def _strip_trailing_commas(text: str) -> str: + out = [] + in_string = False + escaped = False + pending = -1 # index in `out` of a comma waiting to see what follows + + for ch in text: + if in_string: + out.append(ch) + if escaped: + escaped = False + elif ch == "\\": + escaped = True + elif ch == '"': + in_string = False + continue + + if ch == '"': + pending = -1 + in_string = True + out.append(ch) + continue + + if ch == ",": + pending = len(out) + out.append(ch) + continue + + if ch in " \t\r\n": + out.append(ch) + continue + + if ch in "}]" and pending >= 0: + out[pending] = "" # the comma was trailing after all + pending = -1 + out.append(ch) + + return "".join(out) + + +def loads(text): + """json.loads that tolerates trailing commas. Raises like json.loads does.""" + try: + return json.loads(text) + except ValueError as strict_error: + repaired = _strip_trailing_commas(text) + if repaired == text: + raise + value = json.loads(repaired) + logx(f"jsonx: accepted a file with trailing commas ({strict_error})", False) + return value + + +def loads_response(response): + """Same, for a requests response.""" + return loads(response.text) diff --git a/packit/src/utils/localConfig.py b/packit/src/python/utils/LocalConfig.py similarity index 95% rename from packit/src/utils/localConfig.py rename to packit/src/python/utils/LocalConfig.py index adaaad06..367d16cf 100644 --- a/packit/src/utils/localConfig.py +++ b/packit/src/python/utils/LocalConfig.py @@ -10,16 +10,16 @@ from elyx import assets except Exception as e: import android_utils as _au; _au.log(f"import elyx import assets failed: {e}") - from ..utils.importFailed import showImportFailedAlert as _sifa; _sifa() + from .ImportFailed import showImportFailedAlert as _sifa; _sifa() try: from org.telegram.messenger import ApplicationLoader except Exception as e: import android_utils as _au; _au.log(f"import org.telegram.messenger import ApplicationLoader failed: {e}") - from ..utils.importFailed import showImportFailedAlert as _sifa; _sifa() + from .ImportFailed import showImportFailedAlert as _sifa; _sifa() def _get_configs_dir() -> str: - from .paths import getConfigsDir + from .Paths import getConfigsDir return getConfigsDir() @@ -28,7 +28,7 @@ def _get_config_path() -> str: def _get_cache_dir() -> str: - from .paths import getCacheRoot + from .Paths import getCacheRoot return getCacheRoot() diff --git a/packit/src/utils/markdown.py b/packit/src/python/utils/Markdown.py similarity index 100% rename from packit/src/utils/markdown.py rename to packit/src/python/utils/Markdown.py diff --git a/packit/src/utils/media.py b/packit/src/python/utils/Media.py similarity index 92% rename from packit/src/utils/media.py rename to packit/src/python/utils/Media.py index 13e5e4f6..c25ced96 100644 --- a/packit/src/utils/media.py +++ b/packit/src/python/utils/Media.py @@ -9,7 +9,7 @@ from elyx import settings except Exception as e: import android_utils as _au; _au.log(f"import elyx import settings failed: {e}") - from ..utils.importFailed import showImportFailedAlert as _sifa; _sifa() + from .ImportFailed import showImportFailedAlert as _sifa; _sifa() from java import dynamic_proxy @@ -21,7 +21,7 @@ def playSound(soundPath: str, soundKey: str = None, check_pending: bool = True, if check_pending: try: - from ..ui.AchievementsActivity.service.AchivementsEngine import is_achievement_pending + from ..ui.achievements.service.AchivementsEngine import is_achievement_pending if is_achievement_pending(): return except Exception: diff --git a/packit/src/utils/netQueue.py b/packit/src/python/utils/NetQueue.py similarity index 100% rename from packit/src/utils/netQueue.py rename to packit/src/python/utils/NetQueue.py diff --git a/packit/src/utils/paths.py b/packit/src/python/utils/Paths.py similarity index 61% rename from packit/src/utils/paths.py rename to packit/src/python/utils/Paths.py index aa9579b8..97c45b60 100644 --- a/packit/src/utils/paths.py +++ b/packit/src/python/utils/Paths.py @@ -1,7 +1,7 @@ # pyright: reportMissingImports=false # SPDX-License-Identifier: GPL-3.0-or-later -from android_utils import log +from packutil import logx def _filesDir() -> str: from org.telegram.messenger import ApplicationLoader @@ -36,7 +36,7 @@ def getPackitArchivesDir() -> str: return _filesDir() + "/plugins/ElyxPlugins/packit" def getBitHashSoPath() -> str: - from ..nativeLoader import detectArch + from ..core.NativeLoader import detectArch return _filesDir() + f"/plugins/ElyxPlugins/shareui_packit/packit/native/{detectArch()}/libbithash.so" def getRepoCachePath(repoId: str) -> str: @@ -51,6 +51,16 @@ def getIconPackTmpPath(packId: str) -> str: def getClassesCachePath() -> str: return _filesDir() + "/packit/.cache/classes/icons.json" +def getRepoIconCacheDir() -> str: + return _filesDir() + "/packit/.cache/repoIcons" + +def getRepoIconCachePath(url: str) -> str: + # repomap declares rm_icon as a plain image url, so the file name is a hash + # of it: the url can be any length and carries query strings + import hashlib + digest = hashlib.sha1(str(url).encode("utf-8", "ignore")).hexdigest()[:20] + return getRepoIconCacheDir() + f"/{digest}.img" + def getKeysDir() -> str: return _filesDir() + "/packit/.secret/keys" @@ -68,6 +78,11 @@ def _externalCacheDir() -> str: _stagingDir = None def _isWritableDir(path: str) -> bool: + # probes with the same call stageFileForUpload uses. It used to differ from + # the real write and the mismatch cost a whole feature: the probe's plain + # open() succeeded on the emulated external volume while the actual staging + # went through tempfile + copy2, which that volume refuses — so the dir was + # declared usable and every upload then died with EACCES. if not path: return False try: @@ -79,7 +94,7 @@ def _isWritableDir(path: str) -> bool: os.remove(probe) return True except Exception as e: - log(f"paths: {path} not writable: {e}") + logx(f"paths: {path} not writable: {e}", False) return False def getStagingDir() -> str: @@ -100,9 +115,43 @@ def getStagingDir() -> str: break else: _stagingDir = _cacheDir() - log(f"paths: staging dir = {_stagingDir}") + logx(f"paths: staging dir = {_stagingDir}", True) return _stagingDir + +def stageFileForUpload(src: str, suffix: str = "") -> str: + # Copies src to a place Telegram will read and returns the new path. + # + # Plain open() + byte copy on purpose. tempfile.NamedTemporaryFile opens + # with O_EXCL|O_NOFOLLOW at mode 0600 and shutil.copy2 chmods the result + # afterwards; the FUSE-emulated external volume refuses those even in a + # directory where an ordinary write works (which is how the log export + # writes to the very same folder). That EACCES aborted the whole + # suggestion upload before anything was sent. + # + # If the external dir refuses the copy anyway, fall back to the internal + # cache — always writable, and callers hook isInternalUri for such paths. + import os + import shutil + import uuid + name = f"packit_{uuid.uuid4().hex[:12]}{suffix}" + last = None + for directory in (getStagingDir(), _cacheDir()): + dst = os.path.join(directory, name) + try: + os.makedirs(directory, exist_ok=True) + shutil.copyfile(src, dst) + logx(f"paths: staged {src} -> {dst}", True) + return dst + except Exception as e: + last = e + logx(f"paths: staging into {directory} failed: {e}", False) + try: + os.unlink(dst) + except Exception: + pass + raise last if last is not None else Exception("staging failed") + def isInternalPath(path: str) -> bool: # matches what isInternalUri() rejects: the app's own private storage return bool(path) and (str(path).startswith("/data/") or "/files/" in str(path)) diff --git a/packit/src/python/utils/RepoStats.py b/packit/src/python/utils/RepoStats.py new file mode 100644 index 00000000..70170913 --- /dev/null +++ b/packit/src/python/utils/RepoStats.py @@ -0,0 +1,79 @@ +# pyright: reportMissingImports=false +# SPDX-License-Identifier: GPL-3.0-or-later + +# What a repository turned out to hold, remembered from the last time something +# actually looked. +# +# repomap does not carry counts — repomap.plugins and repomap.icons are urls, +# not lists — so the sources screen has no way to say how big a source is +# without downloading a file that can run to hundreds of kilobytes. The +# catalogues download it anyway, every time they open, so they leave the number +# behind here and the sources screen reads it for free. A source nobody has +# opened yet simply has no number, which is honest: nothing has counted it. + +from packutil import logx +import json +import os + +_FILE = "{}-stats.json" + + +def _path(rm_rid: str) -> str: + from .Paths import getReposCacheDir + return os.path.join(getReposCacheDir(), _FILE.format(rm_rid)) + + +def read(rm_rid: str) -> dict: + rm_rid = str(rm_rid or "") + if not rm_rid: + return {} + try: + path = _path(rm_rid) + if not os.path.isfile(path): + return {} + with open(path, "r", encoding="utf-8") as f: + data = json.load(f) + return data if isinstance(data, dict) else {} + except Exception as e: + logx(f"repoStats: read error for '{rm_rid}': {e}", True) + return {} + + +def remember(rm_rid: str, **counts): + """remember(rid, plugins=1011) — only the keys passed are touched.""" + rm_rid = str(rm_rid or "") + if not rm_rid: + return + clean = {k: int(v) for k, v in counts.items() if isinstance(v, int) and v >= 0} + if not clean: + return + try: + data = read(rm_rid) + if all(data.get(k) == v for k, v in clean.items()): + return # same numbers as last time, no write + data.update(clean) + path = _path(rm_rid) + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, "w", encoding="utf-8") as f: + json.dump(data, f, ensure_ascii=False) + except Exception as e: + logx(f"repoStats: write error for '{rm_rid}': {e}", True) + + +def installed_count(rm_rid: str) -> int: + # the per-repository install index the installer keeps + rm_rid = str(rm_rid or "") + if not rm_rid: + return 0 + try: + from .Paths import getRepoIndexPath + path = getRepoIndexPath(rm_rid) + if not os.path.isfile(path): + return 0 + with open(path, "r", encoding="utf-8") as f: + data = json.load(f) + plugins = data.get("installed_plugins") if isinstance(data, dict) else None + return len(plugins) if isinstance(plugins, list) else 0 + except Exception as e: + logx(f"repoStats: index read error for '{rm_rid}': {e}", True) + return 0 diff --git a/packit/src/utils/ripple.py b/packit/src/python/utils/Ripple.py similarity index 100% rename from packit/src/utils/ripple.py rename to packit/src/python/utils/Ripple.py diff --git a/packit/src/utils/search.py b/packit/src/python/utils/Search.py similarity index 99% rename from packit/src/utils/search.py rename to packit/src/python/utils/Search.py index 18a75b45..5f3e4e79 100644 --- a/packit/src/utils/search.py +++ b/packit/src/python/utils/Search.py @@ -27,7 +27,7 @@ def _load_native() -> bool: except Exception: pass - from ..nativeLoader import loadSearch + from ..core.NativeLoader import loadSearch lib = loadSearch() if lib is None: logx("search: failed to load libsearch.so, using python fallback", True) diff --git a/packit/src/utils/share.py b/packit/src/python/utils/Share.py similarity index 98% rename from packit/src/utils/share.py rename to packit/src/python/utils/Share.py index 620ba725..521ece20 100644 --- a/packit/src/utils/share.py +++ b/packit/src/python/utils/Share.py @@ -2,7 +2,7 @@ # SPDX-License-Identifier: GPL-3.0-or-later from packutil import logx -from ..utils.bulletins import factory as _pbf +from .Bulletins import factory as _pbf import os import requests import threading @@ -75,7 +75,7 @@ def dismiss_spinner(): # stage in EXTERNAL app cache — Telegram's isInternalUri() refuses to # send files from internal storage (getCacheDir -> /data/user/0/...), # which surfaced as "attachment not supported" or a silent no-op - from .paths import getShareCachePath + from .Paths import getShareCachePath file_path = getShareCachePath(filename) os.makedirs(os.path.dirname(file_path), exist_ok=True) logx(f"share: downloading {link} -> {file_path}", True) diff --git a/packit/src/python/utils/Stickers.py b/packit/src/python/utils/Stickers.py new file mode 100644 index 00000000..d70af48e --- /dev/null +++ b/packit/src/python/utils/Stickers.py @@ -0,0 +1,314 @@ +# pyright: reportMissingImports=false +# SPDX-License-Identifier: GPL-3.0-or-later + +# Central sticker loader for the whole plugin. +# +# Resolving the set 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 network fetch +# * a resolved set lands in stickerSetsByName, so every later view for the +# same pack binds synchronously +# * the view carries a "packit_sticker__" tag and a callback +# whose tag no longer matches is dropped, so a recycled row cannot end up +# showing the previous row's icon +# +# The older route — loadStickersByEmojiOrName + NotificationCenter. +# diceStickersDidLoad — is kept only as a fallback. It dedups by pack name +# through loadingDiceStickerSets and posts the notification just for sets that +# resolve, so a screen that opens many icons at once (the export sheet lists +# every installed plugin) could sit unbound with nothing left to wake it. +# +# Binding copies org.telegram.ui.Components.StickerImageView: +# setImage(location, filter, "tgs", svgThumb, set) — the "tgs" ext makes +# animated stickers actually animate, the svg thumb stands in while the media +# downloads, and passing the set as parentObject lets the image receiver +# resolve the document. Until the set itself arrives there is no document and +# therefore no thumb, so a neutral block stands in underneath — the same idea +# as the placeholder a chat sticker shows while its media downloads. +# +# All sites call load_sticker(view, "pack/index", size_dp). + +from packutil import logx + +_CAP = 256 + +# Callbacks handed to getStickerSet, held until they answer. Strong refs on +# purpose: everything here that outlives the call has to be owned on the python +# side (a weakref would point at the chaquopy wrapper, which dies as soon as the +# caller's local goes out of scope even though the java object is alive). +_inflight = [] + +# Fallback route only: [view, pack, index, size_dp] waiting for +# diceStickersDidLoad, served by the single global observer below. +_pending = [] +_global_obs = None + + +def _account() -> int: + try: + from org.telegram.messenger import UserConfig + return int(UserConfig.selectedAccount) + except Exception: + return 0 + + +def _parse(icon_str): + try: + if not icon_str or "/" not in icon_str: + return None, 0 + pack, idx = icon_str.split("/", 1) + return pack, int(idx) + except Exception: + return None, 0 + + +def _tag(pack, idx) -> str: + return f"packit_sticker_{pack}_{idx}" + + +def _tag_matches(view, want) -> bool: + # the host guards setPlaceholderImageByIndex the same way, so a late answer + # for a view that has since been rebound is ignored instead of overwriting it + try: + tag = view.getTag() + except Exception: + return True + return tag is None or str(tag) == want + + +def _resolve_set(mdc, pack): + ss = None + try: + ss = mdc.getStickerSetByName(pack) + except Exception: + pass + if not ss: + try: + ss = mdc.getStickerSetByEmojiOrName(pack) + except Exception: + pass + return ss + + +def _set_placeholder(view, size_dp): + try: + import ctypes + from org.telegram.messenger import AndroidUtilities + from org.telegram.ui.ActionBar import Theme + from android.graphics.drawable import GradientDrawable + color = Theme.getColor(Theme.key_emptyListPlaceholder) + r = (color >> 16) & 0xFF + g = (color >> 8) & 0xFF + b = color & 0xFF + block = GradientDrawable() + block.setShape(GradientDrawable.RECTANGLE) + block.setCornerRadius(float(AndroidUtilities.dp(max(4, int(size_dp) // 6)))) + block.setColor(ctypes.c_int32((0x33 << 24) | (r << 16) | (g << 8) | b).value) + view.setBackground(block) + except Exception as e: + logx(f"stickers: placeholder error: {e}", False) + + +def _clear_placeholder(view): + try: + view.setBackground(None) + except Exception: + pass + + +def _bind(view, ss, idx, size_dp) -> bool: + # binds document #idx of an already resolved set + if ss is None: + return False + try: + docs = getattr(ss, "documents", None) + if docs is None or idx < 0 or docs.size() <= idx: + return False + doc = docs.get(idx) + if doc is None: + return False + except Exception: + return False + + from org.telegram.messenger import ImageLocation, DocumentObject + from org.telegram.ui.ActionBar import Theme + from java import jfloat + svg = None + try: + svg = DocumentObject.getSvgThumb(doc, Theme.key_emptyListPlaceholder, jfloat(0.2)) + if svg is not None: + svg.overrideWidthAndHeight(512, 512) + except Exception: + svg = None + view.setImage( + ImageLocation.getForDocument(doc), + f"{size_dp}_{size_dp}", + "tgs", svg, ss, + ) + # from here the image receiver owns the visuals (svg thumb first, sticker + # once downloaded), so our stand-in has to go + _clear_placeholder(view) + return True + + +def _apply_now(view, pack, idx, size_dp) -> bool: + # binds the sticker if its set is already in memory; returns True on success + from org.telegram.messenger import MediaDataController + mdc = MediaDataController.getInstance(_account()) + return _bind(view, _resolve_set(mdc, pack), idx, size_dp) + + +def _request_set(view, pack, idx, size_dp) -> bool: + # the host's own plugin-icon route: one callback per view, answered from + # memory, from the cache or from the network. Returns False if the route is + # unavailable, so the caller can fall back to the notification one. + try: + from elyxcore import gen + from android_utils import run_on_ui_thread + from org.telegram.messenger import MediaDataController, Utilities + from hook_utils import find_class + + ShortName = find_class("org.telegram.tgnet.TLRPC$TL_inputStickerSetShortName") + if ShortName is None: + return False + + want = _tag(pack, idx) + holder = {} + + def _on_loaded(ss): + try: + _inflight.remove(holder.get("cb")) + except Exception: + pass + + def _apply(): + try: + if not _tag_matches(view, want): + return + if not _bind(view, ss, idx, size_dp): + logx(f"stickers: '{pack}/{idx}' unresolved (set missing or too short)", False) + except Exception as e: + logx(f"stickers: bind error for '{pack}/{idx}': {e}", False) + + run_on_ui_thread(_apply) + + cb = gen(Utilities.Callback, "run")(_on_loaded) + holder["cb"] = cb + _inflight.append(cb) + if len(_inflight) > _CAP: + del _inflight[:len(_inflight) - _CAP] + + inp = ShortName() + inp.short_name = pack + mdc = MediaDataController.getInstance(_account()) + try: + mdc.getStickerSet(inp, 0, False, cb) + except TypeError: + from java.lang import Integer as JInteger + mdc.getStickerSet(inp, JInteger(0), False, cb) + return True + except Exception as e: + logx(f"stickers: getStickerSet route unavailable ({e}), using notifications", False) + return False + + +def _flush(name): + # fallback route: bind every pending view whose set just loaded + survivors = [] + for entry in _pending: + view, pack, idx, size_dp = entry + if name is not None and pack != name: + survivors.append(entry) + continue + try: + if not _bind(view, _resolve_set(_mdc(), pack), idx, size_dp): + survivors.append(entry) + except Exception as e: + logx(f"stickers: flush apply error: {e}", False) + bound = len(_pending) - len(survivors) + _pending[:] = survivors + if bound: + logx(f"stickers: bound {bound} pending view(s) for '{name}'", True) + + +def _mdc(): + from org.telegram.messenger import MediaDataController + return MediaDataController.getInstance(_account()) + + +def _ensure_observer(): + global _global_obs + if _global_obs is not None: + return + try: + from hook_utils import find_class + from org.telegram.messenger import NotificationCenter + from java import dynamic_proxy + from android_utils import run_on_ui_thread + Delegate = find_class("org.telegram.messenger.NotificationCenter$NotificationCenterDelegate") + + class _Obs(dynamic_proxy(Delegate)): + def didReceivedNotification(self, id, acc, *args): + try: + if id != NotificationCenter.diceStickersDidLoad: + return + name = str(args[0]) if args else None + run_on_ui_thread(lambda: _flush(name)) + except Exception as e: + logx(f"stickers: observer error: {e}", False) + + obs = _Obs() + NotificationCenter.getInstance(_account()).addObserver(obs, NotificationCenter.diceStickersDidLoad) + _global_obs = obs + except Exception as e: + logx(f"stickers: addObserver failed: {e}", False) + + +def _load_via_notification(view, pack, idx, size_dp): + try: + _mdc().loadStickersByEmojiOrName(pack, False, True) + except Exception as e: + logx(f"stickers: loadStickersByEmojiOrName error: {e}", False) + _pending.append([view, pack, idx, size_dp]) + if len(_pending) > _CAP: + del _pending[:len(_pending) - _CAP] + _ensure_observer() + + +def load_sticker(view, icon_str, size_dp=130): + # binds "pack/index" into `view` (a BackupImageView), loading the set if it + # is not in memory yet — no polling, no fixed delays. + try: + pack, idx = _parse(icon_str) + if not pack: + return + try: + view.setTag(_tag(pack, idx)) + except Exception: + pass + if _apply_now(view, pack, idx, size_dp): + return + # set isn't loaded: show a stand-in and ask for it + _set_placeholder(view, size_dp) + if _request_set(view, pack, idx, size_dp): + return + _load_via_notification(view, pack, idx, size_dp) + except Exception as e: + logx(f"stickers: load_sticker error: {e}", False) + + +def make_sticker_view(context, icon_str, size_dp=130, round_radius_dp=0): + # convenience: build a BackupImageView already bound to the sticker + from org.telegram.ui.Components import BackupImageView + from org.telegram.messenger import AndroidUtilities + view = BackupImageView(context) + if round_radius_dp: + try: + view.setRoundRadius(AndroidUtilities.dp(round_radius_dp)) + except Exception: + pass + load_sticker(view, icon_str, size_dp) + return view diff --git a/packit/src/utils/translation.py b/packit/src/python/utils/Translation.py similarity index 97% rename from packit/src/utils/translation.py rename to packit/src/python/utils/Translation.py index 463c00c9..0e123af3 100644 --- a/packit/src/utils/translation.py +++ b/packit/src/python/utils/Translation.py @@ -3,7 +3,7 @@ from packutil import logx -from ..utils.bulletins import factory as _pbf +from .Bulletins import factory as _pbf from client_utils import get_last_fragment from hook_utils import find_class from java import dynamic_proxy @@ -22,12 +22,12 @@ from org.telegram.messenger import AndroidUtilities, R as R_tg except Exception as e: import android_utils as _au; _au.log(f"import org.telegram.messenger import AndroidUtilities, R as R_tg failed: {e}") - from ..importFailed import showImportFailedAlert as _sifa; _sifa() + from .ImportFailed import showImportFailedAlert as _sifa; _sifa() try: from elyx import strings except Exception as e: import android_utils as _au; _au.log(f"import elyx import strings failed: {e}") - from ..importFailed import showImportFailedAlert as _sifa; _sifa() + from .ImportFailed import showImportFailedAlert as _sifa; _sifa() from android.net import Uri try: from org.telegram.messenger.browser import Browser @@ -259,7 +259,7 @@ def on_close(v): translate_sheet.setCustomView(root) try: - from ..ui.viewUtils import applyFontToTree + from ..ui.components.ViewUtils import applyFontToTree applyFontToTree(root) except Exception: pass diff --git a/packit/src/utils/__init__.py b/packit/src/python/utils/__init__.py similarity index 100% rename from packit/src/utils/__init__.py rename to packit/src/python/utils/__init__.py diff --git a/packit/src/utils/stickers.py b/packit/src/utils/stickers.py deleted file mode 100644 index 254f4f57..00000000 --- a/packit/src/utils/stickers.py +++ /dev/null @@ -1,165 +0,0 @@ -# pyright: reportMissingImports=false -# SPDX-License-Identifier: GPL-3.0-or-later - -# Central sticker loader for the whole plugin. -# -# Mirrors org.telegram.ui.Components.StickerImageView (the host's own widget for -# "show sticker N from pack "): -# * resolve the set: getStickerSetByName -> getStickerSetByEmojiOrName -# * setImage(location, filter, "tgs", svgThumb, set) — the "tgs" ext makes -# animated stickers actually animate, the SVG thumb is a placeholder while -# the media downloads, and passing the set as parentObject lets the image -# receiver resolve the document -# * when the set is not cached yet: fire loadStickersByEmojiOrName and wait -# for NotificationCenter.diceStickersDidLoad, then bind — instead of the old -# per-site polling with time.sleep()/postDelayed, which showed the sticker -# late (fixed delay) or never (a single retry that raced the download). -# -# A single global diceStickersDidLoad observer serves every pending view; views -# are held weakly, so nothing leaks and dead views drop themselves on the next -# event. All sites call load_sticker(view, "pack/index", size_dp). - -from packutil import logx -import weakref - -# pending binds waiting for their set to load: each is -# (weakref(view), pack, index, size_dp). Served by the single global observer. -_pending = [] -_global_obs = None - - -def _account() -> int: - try: - from org.telegram.messenger import UserConfig - return int(UserConfig.selectedAccount) - except Exception: - return 0 - - -def _parse(icon_str): - try: - if not icon_str or "/" not in icon_str: - return None, 0 - pack, idx = icon_str.split("/", 1) - return pack, int(idx) - except Exception: - return None, 0 - - -def _resolve_set(mdc, pack): - ss = None - try: - ss = mdc.getStickerSetByName(pack) - except Exception: - pass - if not ss: - try: - ss = mdc.getStickerSetByEmojiOrName(pack) - except Exception: - pass - return ss - - -def _apply_now(view, pack, idx, size_dp) -> bool: - # binds the sticker if its set is cached; returns True on success - from org.telegram.messenger import MediaDataController, ImageLocation, DocumentObject - from org.telegram.ui.ActionBar import Theme - from java import jfloat - mdc = MediaDataController.getInstance(_account()) - ss = _resolve_set(mdc, pack) - if ss is None or getattr(ss, "documents", None) is None or ss.documents.size() <= idx: - return False - doc = ss.documents.get(idx) - svg = None - try: - svg = DocumentObject.getSvgThumb(doc, Theme.key_emptyListPlaceholder, jfloat(0.2)) - if svg is not None: - svg.overrideWidthAndHeight(512, 512) - except Exception: - svg = None - view.setImage( - ImageLocation.getForDocument(doc), - f"{size_dp}_{size_dp}", - "tgs", svg, ss, - ) - return True - - -def _flush(name): - # re-bind every pending view whose set just loaded; prune bound/dead ones - survivors = [] - for ref, pack, idx, size_dp in _pending: - view = ref() - if view is None: - continue # view gone -> drop - if name is not None and pack != name: - survivors.append((ref, pack, idx, size_dp)) - continue - try: - if not _apply_now(view, pack, idx, size_dp): - survivors.append((ref, pack, idx, size_dp)) - except Exception as e: - logx(f"stickers: flush apply error: {e}", False) - _pending[:] = survivors - - -def _ensure_observer(): - global _global_obs - if _global_obs is not None: - return - try: - from hook_utils import find_class - from org.telegram.messenger import NotificationCenter - from java import dynamic_proxy - from android_utils import run_on_ui_thread - Delegate = find_class("org.telegram.messenger.NotificationCenter$NotificationCenterDelegate") - - class _Obs(dynamic_proxy(Delegate)): - def didReceivedNotification(self, id, acc, *args): - try: - if id != NotificationCenter.diceStickersDidLoad: - return - name = str(args[0]) if args else None - run_on_ui_thread(lambda: _flush(name)) - except Exception as e: - logx(f"stickers: observer error: {e}", False) - - obs = _Obs() - NotificationCenter.getInstance(_account()).addObserver(obs, NotificationCenter.diceStickersDidLoad) - _global_obs = obs - except Exception as e: - logx(f"stickers: addObserver failed: {e}", False) - - -def load_sticker(view, icon_str, size_dp=130): - # binds "pack/index" into `view` (a BackupImageView). If the set is not - # cached, triggers the load and binds on diceStickersDidLoad — no polling. - try: - pack, idx = _parse(icon_str) - if not pack: - return - if _apply_now(view, pack, idx, size_dp): - return - try: - from org.telegram.messenger import MediaDataController - MediaDataController.getInstance(_account()).loadStickersByEmojiOrName(pack, False, True) - except Exception as e: - logx(f"stickers: loadStickersByEmojiOrName error: {e}", False) - _pending.append((weakref.ref(view), pack, idx, size_dp)) - _ensure_observer() - except Exception as e: - logx(f"stickers: load_sticker error: {e}", False) - - -def make_sticker_view(context, icon_str, size_dp=130, round_radius_dp=0): - # convenience: build a BackupImageView already bound to the sticker - from org.telegram.ui.Components import BackupImageView - from org.telegram.messenger import AndroidUtilities - view = BackupImageView(context) - if round_radius_dp: - try: - view.setRoundRadius(AndroidUtilities.dp(round_radius_dp)) - except Exception: - pass - load_sticker(view, icon_str, size_dp) - return view diff --git a/refmap.yml b/refmap.yml index afac9a29..ecd1ca8f 100644 --- a/refmap.yml +++ b/refmap.yml @@ -1,5 +1,5 @@ metainfo: packit/meta.yml -main: packit/src/BasePlugin.py +main: packit/src/python/BasePlugin.py strings: packit/locales assets: packit/res elyxbuilder: packit/.elyxbuilder diff --git a/scripts/linux/kotlin-build.sh b/scripts/linux/kotlin-build.sh index dc12d571..a09747df 100644 --- a/scripts/linux/kotlin-build.sh +++ b/scripts/linux/kotlin-build.sh @@ -1,10 +1,16 @@ #!/usr/bin/env bash # SPDX-License-Identifier: GPL-3.0-or-later # -# Builds the self-written Kotlin dexes (sources in /kotlin/) into -# packit/dex/.dex. Reflection + Xposed based, so it compiles against -# android.jar + the compile-only Xposed stubs in kotlin/stubs (never shipped), -# and R8 tree-shakes kotlin-stdlib so the dex stays tiny. +# Builds the self-written Kotlin (sources in packit/src/kotlin) into a single +# packit/dex/packit.dex holding all of kawaii.packetik. Reflection + Xposed +# based, so it compiles against android.jar + the compile-only Xposed stubs in +# packit/src/kotlin/stubs (never shipped), and R8 tree-shakes kotlin-stdlib so +# the dex stays small. +# +# One dex, not one per package: R8 emits a single classes.dex from all of the +# sources anyway, and this script used to copy that same file out four times +# under four names. Four identical 55K blobs shipped, and DexLoader built a +# separate class loader over each — the same bytecode resident four times. # # Toolchain discovery order: # 1. environment variables (ANDROID_HOME / ANDROID_SDK_ROOT, KOTLINC, ...) @@ -13,22 +19,14 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" -SRC_DIR="$REPO_ROOT/kotlin/src" -STUB_DIR="$REPO_ROOT/kotlin/stubs" +SRC_DIR="$REPO_ROOT/packit/src/kotlin/src" +STUB_DIR="$REPO_ROOT/packit/src/kotlin/stubs" OUT_DIR="$REPO_ROOT/kotlin-build" -DEX_OUT_BASE="$REPO_ROOT/packit/dex" +DEX_OUT="$REPO_ROOT/packit/dex/packit.dex" MIN_API=26 -# what to build: "=" -PACKAGES=( - "badges=kawaii.packetik.badges.BadgesNative" - "openfile=kawaii.packetik.openfile.OpenFileNative" - "catalog=kawaii.packetik.catalog.CatalogChromeNative" - "sfx=kawaii.packetik.sfx.SfxNative" -) - die() { echo "error: $*" >&2; exit 1; } info() { echo "[kotlin-build] $*"; } @@ -134,13 +132,14 @@ java -cp "$D8_JAR" com.android.tools.r8.R8 \ "$KOTLIN_STDLIB" [[ -f "$OUT_DIR/dex/classes.dex" ]] || die "R8 produced no classes.dex" +if [[ -f "$OUT_DIR/dex/classes2.dex" ]]; then + die "R8 split the output across several dex files. + packit.dex is loaded as one file, so the method count has to stay under the limit." +fi -# NOTE: .dex is arch-independent Dalvik bytecode; a single copy serves all ABIs. -for entry in "${PACKAGES[@]}"; do - name="${entry%%=*}" - mkdir -p "$DEX_OUT_BASE" - cp "$OUT_DIR/dex/classes.dex" "$DEX_OUT_BASE/$name.dex" - info "-> $DEX_OUT_BASE/$name.dex ($(wc -c < "$DEX_OUT_BASE/$name.dex") bytes)" -done +# NOTE: .dex is arch-independent Dalvik bytecode; one copy serves all ABIs. +mkdir -p "$(dirname "$DEX_OUT")" +cp "$OUT_DIR/dex/classes.dex" "$DEX_OUT" +info "-> $DEX_OUT ($(wc -c < "$DEX_OUT") bytes)" info "done."