diff --git a/kotlin/src/kawaii/packetik/catalog/CatalogChromeNative.kt b/kotlin/src/kawaii/packetik/catalog/CatalogChromeNative.kt new file mode 100644 index 00000000..57747690 --- /dev/null +++ b/kotlin/src/kawaii/packetik/catalog/CatalogChromeNative.kt @@ -0,0 +1,291 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package kawaii.packetik.catalog + +import android.content.Context +import android.content.res.ColorStateList +import android.graphics.Typeface +import android.graphics.drawable.Drawable +import android.graphics.drawable.GradientDrawable +import android.graphics.drawable.RippleDrawable +import android.util.TypedValue +import android.view.Gravity +import android.view.View +import android.widget.FrameLayout +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.ScrollView +import android.widget.TextView + +/** + * Static chrome skeleton of the plugins catalog, built entirely on the java + * side: the same tree costs hundreds of python->java bridge calls when built + * from Python and used to stall catalog entry for ~half a second. + * + * Python receives the tree, looks up the interactive children by tag + * (search_slot, clear_btn, search_btn, ai_pill, subtitle, tag_filter_btn, + * sort_btn, scroll), inserts the telegram EditTextBoldCursor into + * search_slot and wires all listeners/logic itself. + */ +object CatalogChromeNative { + + private fun dp(ctx: Context, v: Float): Int = + (ctx.resources.displayMetrics.density * v + 0.5f).toInt() + + private fun rounded(color: Int, radiusPx: Float, strokeW: Int = 0, strokeColor: Int = 0): GradientDrawable { + val d = GradientDrawable() + d.shape = GradientDrawable.RECTANGLE + d.cornerRadius = radiusPx + d.setColor(color) + if (strokeW > 0) d.setStroke(strokeW, strokeColor) + return d + } + + private fun selector(base: Int, pressed: Int, radiusPx: Float): Drawable { + val content = rounded(base, radiusPx) + return try { + RippleDrawable(ColorStateList.valueOf(pressed), content, null) + } catch (t: Throwable) { + content + } + } + + private fun iconButton( + ctx: Context, bg: Drawable, iconRes: Int, iconTint: Int, padPx: Int, tag: String + ): FrameLayout { + val btn = FrameLayout(ctx) + btn.tag = tag + btn.isClickable = true + btn.isFocusable = true + btn.background = bg + btn.setPadding(padPx, padPx, padPx, padPx) + val icon = ImageView(ctx) + if (iconRes != 0) icon.setImageResource(iconRes) + icon.setColorFilter(iconTint) + icon.scaleType = ImageView.ScaleType.CENTER + btn.addView(icon, FrameLayout.LayoutParams(dp(ctx, 20f), dp(ctx, 20f), Gravity.CENTER)) + return btn + } + + private fun searchPill( + ctx: Context, cardBg: Int, accent: Int, accentPressed: Int, buttonText: Int, + textColor: Int, iconClear: Int, iconSearch: Int, showSearchBtn: Boolean + ): FrameLayout { + val searchContainer = FrameLayout(ctx) + searchContainer.background = + rounded(cardBg, dp(ctx, 50f).toFloat(), dp(ctx, 2f), accent) + searchContainer.setPadding(dp(ctx, 16f), dp(ctx, 5f), dp(ctx, 8f), dp(ctx, 5f)) + + val searchRow = LinearLayout(ctx) + searchRow.orientation = LinearLayout.HORIZONTAL + searchRow.gravity = Gravity.CENTER_VERTICAL + + val searchSlot = FrameLayout(ctx) + searchSlot.tag = "search_slot" + searchRow.addView(searchSlot, LinearLayout.LayoutParams(-1, dp(ctx, 36f), 1f)) + + val clearBtn = iconButton( + ctx, selector(0x00000000, 0x1F000000, dp(ctx, 25f).toFloat()), + iconClear, textColor, dp(ctx, 8f), "clear_btn" + ) + clearBtn.visibility = View.GONE + clearBtn.alpha = 0f + searchRow.addView(clearBtn, LinearLayout.LayoutParams(dp(ctx, 52f), dp(ctx, 36f), 0f)) + + val searchBtn = iconButton( + ctx, selector(accent, accentPressed, dp(ctx, 25f).toFloat()), + iconSearch, buttonText, dp(ctx, 8f), "search_btn" + ) + if (!showSearchBtn) searchBtn.visibility = View.GONE + searchRow.addView(searchBtn, LinearLayout.LayoutParams(dp(ctx, 52f), dp(ctx, 36f), 0f)) + + searchContainer.addView(searchRow, FrameLayout.LayoutParams(-1, -2)) + return searchContainer + } + + private fun resultsScroll(ctx: Context, mainBg: Int): ScrollView { + val scroll = ScrollView(ctx) + scroll.tag = "scroll" + scroll.isFillViewport = true + scroll.isVerticalScrollBarEnabled = false + scroll.setBackgroundColor(mainBg) + scroll.setFadingEdgeLength(dp(ctx, 24f)) + scroll.isVerticalFadingEdgeEnabled = true + try { + scroll.isNestedScrollingEnabled = true + } catch (t: Throwable) { + } + return scroll + } + + // icons catalog: search pill + [repo | count | sort] header + scroll. + // tags: search_slot, clear_btn, search_btn, repo_btn, subtitle, sort_btn, + // scroll + @JvmStatic + fun createIconsChrome( + ctx: Context, + mainBg: Int, + cardBg: Int, + cardPressed: Int, + textColor: Int, + accent: Int, + accentPressed: Int, + buttonText: Int, + iconClear: Int, + iconSearch: Int, + iconRepo: Int, + iconSort: Int, + subtitleText: String, + showSearchBtn: Boolean + ): LinearLayout { + val main = LinearLayout(ctx) + main.orientation = LinearLayout.VERTICAL + main.setPadding(dp(ctx, 16f), 0, dp(ctx, 16f), dp(ctx, 14f)) + + val searchContainer = searchPill( + ctx, cardBg, accent, accentPressed, buttonText, textColor, + iconClear, iconSearch, showSearchBtn + ) + val scLp = LinearLayout.LayoutParams(-1, -2) + scLp.bottomMargin = dp(ctx, 8f) + main.addView(searchContainer, scLp) + + val header = FrameLayout(ctx) + val hLp = LinearLayout.LayoutParams(-1, dp(ctx, 44f)) + hLp.topMargin = dp(ctx, 4f) + hLp.bottomMargin = dp(ctx, 12f) + main.addView(header, hLp) + + val repoBtn = iconButton( + ctx, selector(cardBg, cardPressed, dp(ctx, 16f).toFloat()), + iconRepo, textColor, dp(ctx, 8f), "repo_btn" + ) + header.addView( + repoBtn, + FrameLayout.LayoutParams(-2, -2, Gravity.LEFT or Gravity.CENTER_VERTICAL) + ) + + val subtitle = TextView(ctx) + subtitle.tag = "subtitle" + subtitle.setTextSize(TypedValue.COMPLEX_UNIT_DIP, 16f) + subtitle.text = subtitleText + subtitle.gravity = Gravity.CENTER + subtitle.setPadding(dp(ctx, 12f), dp(ctx, 7f), dp(ctx, 12f), dp(ctx, 7f)) + subtitle.isClickable = false + subtitle.isFocusable = false + subtitle.background = rounded(cardBg, dp(ctx, 16f).toFloat()) + subtitle.setTextColor(textColor) + header.addView(subtitle, FrameLayout.LayoutParams(-2, -2, Gravity.CENTER)) + + val sortBtn = iconButton( + ctx, selector(cardBg, cardPressed, dp(ctx, 16f).toFloat()), + iconSort, textColor, dp(ctx, 8f), "sort_btn" + ) + header.addView( + sortBtn, + FrameLayout.LayoutParams(-2, -2, Gravity.RIGHT or Gravity.CENTER_VERTICAL) + ) + + main.addView(resultsScroll(ctx, mainBg), LinearLayout.LayoutParams(-1, 0, 1f)) + return main + } + + @JvmStatic + fun createPluginsChrome( + ctx: Context, + mainBg: Int, + cardBg: Int, + cardPressed: Int, + textColor: Int, + accent: Int, + accentPressed: Int, + buttonText: Int, + iconClear: Int, + iconSearch: Int, + iconAi: Int, + iconFilter: Int, + iconSort: Int, + aiLabel: String, + subtitleText: String, + showSearchBtn: Boolean, + boldTypeface: Typeface? + ): LinearLayout { + val bold = boldTypeface ?: Typeface.DEFAULT_BOLD + + val main = LinearLayout(ctx) + main.orientation = LinearLayout.VERTICAL + main.setPadding(dp(ctx, 16f), 0, dp(ctx, 16f), dp(ctx, 14f)) + + // python drops the telegram EditTextBoldCursor into the tagged slot + val searchContainer = searchPill( + ctx, cardBg, accent, accentPressed, buttonText, textColor, + iconClear, iconSearch, showSearchBtn + ) + val scLp = LinearLayout.LayoutParams(-1, -2) + scLp.bottomMargin = dp(ctx, 8f) + main.addView(searchContainer, scLp) + + // -------- header row: AI pill / centered count / filter + sort + val header = FrameLayout(ctx) + val hLp = LinearLayout.LayoutParams(-1, dp(ctx, 44f)) + hLp.topMargin = dp(ctx, 2f) + hLp.bottomMargin = dp(ctx, 6f) + main.addView(header, hLp) + + val aiPill = LinearLayout(ctx) + aiPill.tag = "ai_pill" + aiPill.orientation = LinearLayout.HORIZONTAL + aiPill.gravity = Gravity.CENTER_VERTICAL + aiPill.isClickable = true + aiPill.isFocusable = true + aiPill.background = selector(cardBg, cardPressed, dp(ctx, 16f).toFloat()) + aiPill.setPadding(dp(ctx, 12f), dp(ctx, 8f), dp(ctx, 12f), dp(ctx, 8f)) + val aiIcon = ImageView(ctx) + if (iconAi != 0) aiIcon.setImageResource(iconAi) + aiIcon.setColorFilter(textColor) + val aiIconLp = LinearLayout.LayoutParams(dp(ctx, 20f), dp(ctx, 20f)) + aiIconLp.rightMargin = dp(ctx, 6f) + aiPill.addView(aiIcon, aiIconLp) + val aiText = TextView(ctx) + aiText.text = aiLabel + aiText.setTextSize(TypedValue.COMPLEX_UNIT_DIP, 14f) + aiText.typeface = bold + aiText.setTextColor(textColor) + aiPill.addView(aiText, LinearLayout.LayoutParams(-2, -2)) + header.addView( + aiPill, + FrameLayout.LayoutParams(-2, -2, Gravity.LEFT or Gravity.CENTER_VERTICAL) + ) + + val subtitle = TextView(ctx) + subtitle.tag = "subtitle" + subtitle.setTextSize(TypedValue.COMPLEX_UNIT_DIP, 16f) + subtitle.text = subtitleText + subtitle.gravity = Gravity.CENTER + subtitle.setPadding(dp(ctx, 12f), dp(ctx, 7f), dp(ctx, 12f), dp(ctx, 7f)) + subtitle.isClickable = false + subtitle.isFocusable = false + subtitle.background = rounded(cardBg, dp(ctx, 16f).toFloat()) + subtitle.setTextColor(textColor) + header.addView(subtitle, FrameLayout.LayoutParams(-2, -2, Gravity.CENTER)) + + val tagBtn = iconButton( + ctx, selector(cardBg, cardPressed, dp(ctx, 16f).toFloat()), + iconFilter, textColor, dp(ctx, 8f), "tag_filter_btn" + ) + val tagLp = FrameLayout.LayoutParams(-2, -2, Gravity.RIGHT or Gravity.CENTER_VERTICAL) + tagLp.rightMargin = dp(ctx, 40f) + header.addView(tagBtn, tagLp) + + val sortBtn = iconButton( + ctx, selector(cardBg, cardPressed, dp(ctx, 16f).toFloat()), + iconSort, textColor, dp(ctx, 8f), "sort_btn" + ) + header.addView( + sortBtn, + FrameLayout.LayoutParams(-2, -2, Gravity.RIGHT or Gravity.CENTER_VERTICAL) + ) + + main.addView(resultsScroll(ctx, mainBg), LinearLayout.LayoutParams(-1, 0, 1f)) + return main + } +} diff --git a/packit/dex/catalog.dex b/packit/dex/catalog.dex new file mode 100644 index 00000000..97f1fb37 Binary files /dev/null and b/packit/dex/catalog.dex differ diff --git a/packit/locales/strings_be.json b/packit/locales/strings_be.json index 661e9c43..18d0a605 100644 --- a/packit/locales/strings_be.json +++ b/packit/locales/strings_be.json @@ -827,6 +827,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 — гатова. Інкогніта актывавана, акуляры надзеты, ніхто нічога не бачыў.", "tags_section_title": "Тэгі", "apply_button": "Ужыць", "authors_section_title": "Аўтары", diff --git a/packit/locales/strings_de.json b/packit/locales/strings_de.json index 44ca611b..21397574 100644 --- a/packit/locales/strings_de.json +++ b/packit/locales/strings_de.json @@ -827,6 +827,8 @@ "achiev_hint_secret_utils_rule": "Die Installation wird in den Dienstprogrammen durchgeführt. Ihr Ansprechpartner ist kpm. Da die Utils in dieser Variante nicht ausgewählt wurden", "achiev_title_secret_aytist": "Du hast einen Amethyst gefunden!", "achiev_hint_secret_aytist": "Sie haben wirklich einen Amethyst gefunden, jetzt müssen Sie nicht mehr für den Rest Ihres Lebens arbeiten ...", + "achiev_title_secret_opsec": "opsec installiert", + "achiev_hint_secret_opsec": "sudo packit install opsec — fertig. Inkognito aktiviert, Brille auf, niemand hat etwas gesehen.", "tags_section_title": "Schlagworte", "apply_button": "Anwenden", "authors_section_title": "Autoren", diff --git a/packit/locales/strings_en.json b/packit/locales/strings_en.json index 54703c84..eacced66 100644 --- a/packit/locales/strings_en.json +++ b/packit/locales/strings_en.json @@ -827,6 +827,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.", "tags_section_title": "Tags", "apply_button": "Apply", "authors_section_title": "Authors", diff --git a/packit/locales/strings_ru.json b/packit/locales/strings_ru.json index 2973b652..1564bb5e 100644 --- a/packit/locales/strings_ru.json +++ b/packit/locales/strings_ru.json @@ -827,6 +827,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 — готово. Инкогнито активировано, очки надеты, никто ничего не видел.", "tags_section_title": "Теги", "apply_button": "Применить", "authors_section_title": "Авторы", diff --git a/packit/meta.yml b/packit/meta.yml index 46935502..fdbe1afe 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.0.0-rc.643" +version: "0.0.0-rc.648" author: "@packitX" app_version: ">=12.8.1" sdk_version: ">=1.4.4.6" diff --git a/packit/res/achievList.json b/packit/res/achievList.json index e1775a15..4f9652bf 100644 --- a/packit/res/achievList.json +++ b/packit/res/achievList.json @@ -970,5 +970,17 @@ "title_key": "achiev_title_secret_connect_is_bullshit", "hint_key": "achiev_hint_secret_connect_is_bullshit", "category_key": "achiev_cat_unknown" + }, + { + "id": "secret_opsec", + "category": "Unknown achievements", + "title": "opsec installed", + "goal": 1, + "hint": "sudo packit install opsec \u2014 done. Incognito activated, glasses on, nobody saw anything.", + "icon": "msg_secret", + "playSound": false, + "title_key": "achiev_title_secret_opsec", + "hint_key": "achiev_hint_secret_opsec", + "category_key": "achiev_cat_unknown" } -] \ No newline at end of file +] diff --git a/packit/src/DialogsActivity/updatesWidget.py b/packit/src/DialogsActivity/updatesWidget.py index c2cab73e..e3582586 100644 --- a/packit/src/DialogsActivity/updatesWidget.py +++ b/packit/src/DialogsActivity/updatesWidget.py @@ -2,6 +2,7 @@ # SPDX-License-Identifier: GPL-3.0-or-later from packutil import logx +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 @@ -214,7 +215,7 @@ def task(): logx(f"UpdatesWidget: prefetch error: {e}", False) run_on_ui_thread(lambda: _register_pill(plugin)) - run_on_queue(task) + run_io(task) def _get_prefs(): diff --git a/packit/src/RepositoryManager.py b/packit/src/RepositoryManager.py index dd6e5481..2ae65263 100644 --- a/packit/src/RepositoryManager.py +++ b/packit/src/RepositoryManager.py @@ -2,6 +2,7 @@ # SPDX-License-Identifier: GPL-3.0-or-later from packutil import logx +from .utils.netQueue import run_serial_io import os import json import requests @@ -228,7 +229,7 @@ def task(): } repos.append(newRepo) self.setRepositories(repos) - run_on_queue(task) + run_serial_io(task) else: repos = self.getRepositories() newRepo = { @@ -297,7 +298,7 @@ def task(): fragment = get_last_fragment() if fragment and hasattr(fragment, "rebuildAllItems"): fragment.rebuildAllItems() - run_on_queue(task) + run_serial_io(task) def resetRepositories(self): def task(): @@ -314,7 +315,7 @@ def task(): fragment = get_last_fragment() if fragment and hasattr(fragment, "rebuildAllItems"): fragment.rebuildAllItems() - run_on_queue(task) + run_serial_io(task) def clearAllExceptFirst(self): repos = self.getRepositories() @@ -391,4 +392,4 @@ def task(): except Exception as e: logx(f"updateAllCaches: on_complete error: {e}", False) - run_on_queue(task) \ No newline at end of file + run_serial_io(task) \ No newline at end of file diff --git a/packit/src/SettingsActivity/SubSettings/PluginCardEditor.py b/packit/src/SettingsActivity/SubSettings/PluginCardEditor.py index 23cf60b0..5e8a86ab 100644 --- a/packit/src/SettingsActivity/SubSettings/PluginCardEditor.py +++ b/packit/src/SettingsActivity/SubSettings/PluginCardEditor.py @@ -72,6 +72,7 @@ } _BUTTON_DEFAULTS = { + "relocate_install": False, "relocate_copy_link": False, "relocate_share": False, "relocate_code": False, @@ -572,6 +573,7 @@ def _build(self): self._action_buttons = {} for setting_key, icon_name in [ + ("relocate_install", "msg_add"), ("relocate_copy_link", "msg_copy"), ("relocate_share", "msg_share"), ("relocate_code", "msg_view_file"), @@ -811,7 +813,7 @@ def _wire(self, view, key): class Tap(dynamic_proxy(View.OnClickListener)): def __init__(self, k): super().__init__(); self.k = k def onClick(self, v): - if self.k in ['details', 'more', 'relocate_copy_link', 'relocate_share', 'relocate_code', + if self.k in ['details', 'more', 'relocate_install', 'relocate_copy_link', 'relocate_share', 'relocate_code', 'relocate_download', 'relocate_translate', 'relocate_report']: buttons_wrapper = preview.elements.get('buttons_wrapper') if buttons_wrapper: @@ -996,7 +998,7 @@ def _apply_visibility(self, animated=False): view_btn.setAlpha(0.5) relocate_keys = [ - "relocate_copy_link", "relocate_share", "relocate_code", + "relocate_install", "relocate_copy_link", "relocate_share", "relocate_code", "relocate_download", "relocate_translate", "relocate_report" ] enabled_relocate_count = sum(1 for key in relocate_keys if _gs(key)) @@ -1063,7 +1065,7 @@ def refresh(self): logx(f"PCE: Error updating details button color: {e}", False) relocate_keys = [ - "relocate_copy_link", "relocate_share", "relocate_code", + "relocate_install", "relocate_copy_link", "relocate_share", "relocate_code", "relocate_download", "relocate_translate", "relocate_report" ] enabled_relocate_count = sum(1 for key in relocate_keys if _gs(key)) @@ -1253,6 +1255,7 @@ def _fill_settings(self, key, ctx): self._check_details_button(ctx, "show_details_button", strings.show_details_button) self._divider(ctx) for setting_key, label, icon in [ + ("relocate_install", strings.msg_one_plugin_install, "msg_add"), ("relocate_copy_link", strings.copy_link, "msg_copy"), ("relocate_share", strings.share, "msg_share"), ("relocate_code", strings.code, "msg_view_file"), @@ -1380,7 +1383,7 @@ def _check_relocate_button(self, ctx, key, label): class CellClick(dynamic_proxy(View.OnClickListener)): def onClick(self, v): relocate_keys = [ - "relocate_copy_link", "relocate_share", "relocate_code", + "relocate_install", "relocate_copy_link", "relocate_share", "relocate_code", "relocate_download", "relocate_translate", "relocate_report" ] enabled_count = sum(1 for k in relocate_keys if _gs(k)) @@ -1433,7 +1436,7 @@ def _check_details_button(self, ctx, key, label): class CellClick(dynamic_proxy(View.OnClickListener)): def onClick(self, v): relocate_keys = [ - "relocate_copy_link", "relocate_share", "relocate_code", + "relocate_install", "relocate_copy_link", "relocate_share", "relocate_code", "relocate_download", "relocate_translate", "relocate_report" ] enabled_count = sum(1 for k in relocate_keys if _gs(k)) diff --git a/packit/src/dexLoader.py b/packit/src/dexLoader.py index 9aec23b5..fc43efe9 100644 --- a/packit/src/dexLoader.py +++ b/packit/src/dexLoader.py @@ -138,6 +138,71 @@ def _ia(lst): return None +_CATALOG_CLASS = "kawaii.packetik.catalog.CatalogChromeNative" + + +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, + ai_label, subtitle_text, show_search_btn, bold_typeface): + # builds the plugins-catalog chrome skeleton on the java side and returns + # its root LinearLayout (children are looked up by tag), or None on + # failure -> caller builds the chrome in Python as before. + try: + if context is None: + from org.telegram.messenger import ApplicationLoader + context = ApplicationLoader.applicationContext + cls = _loadClass("catalog", _CATALOG_CLASS, context) + if cls is None: + return None + from java import jint + + def _i(v): + return jint(int(v)) + + return _callStatic( + cls, "createPluginsChrome", + context, + _i(main_bg), _i(card_bg), _i(card_pressed), _i(text_color), + _i(accent), _i(accent_pressed), _i(button_text), + _i(icon_clear), _i(icon_search), _i(icon_ai), _i(icon_filter), _i(icon_sort), + str(ai_label), str(subtitle_text), bool(show_search_btn), bold_typeface, + ) + except Exception as e: + logx(f"dexLoader: catalogChromeCreate error: {e}", False) + return None + + +def catalogIconsChromeCreate(context, main_bg, card_bg, card_pressed, text_color, + accent, accent_pressed, button_text, + icon_clear, icon_search, icon_repo, icon_sort, + subtitle_text, show_search_btn): + # icons-catalog chrome skeleton; see catalogChromeCreate above. + try: + if context is None: + from org.telegram.messenger import ApplicationLoader + context = ApplicationLoader.applicationContext + cls = _loadClass("catalog", _CATALOG_CLASS, context) + if cls is None: + return None + from java import jint + + def _i(v): + return jint(int(v)) + + return _callStatic( + cls, "createIconsChrome", + context, + _i(main_bg), _i(card_bg), _i(card_pressed), _i(text_color), + _i(accent), _i(accent_pressed), _i(button_text), + _i(icon_clear), _i(icon_search), _i(icon_repo), _i(icon_sort), + str(subtitle_text), bool(show_search_btn), + ) + except Exception as e: + logx(f"dexLoader: catalogIconsChromeCreate error: {e}", False) + return None + + def openFileCancel(view): try: cls = _loaded.get("openfile") diff --git a/packit/src/other/text.py b/packit/src/other/text.py index f5b1df96..0fea0a6b 100644 --- a/packit/src/other/text.py +++ b/packit/src/other/text.py @@ -8,6 +8,7 @@ _TRIGGER_TALKING = "ты про себя?" _TRIGGER_UTILS = "кстати тебя врядли выложат в utilits. ты пофакту, повторил kpm. а как бы в utils правило второй вариант нельзя выкладыватьб" _TRIGGER_CONNECT = "коннект хуйня" +_TRIGGER_OPSEC = "sudo packit install opsec" def check_message(text: str): @@ -22,5 +23,7 @@ def check_message(text: str): unlock_secret("utils_rule") elif lower == _TRIGGER_CONNECT: unlock_secret("connect_is_bullshit") + elif lower == _TRIGGER_OPSEC: + unlock_secret("opsec") except Exception as e: logx(f"[text] check_message error: {e}", False) \ No newline at end of file diff --git a/packit/src/ui/AchievementsActivity/service/AchivementsEngine.py b/packit/src/ui/AchievementsActivity/service/AchivementsEngine.py index 0171dde9..6e52f45d 100644 --- a/packit/src/ui/AchievementsActivity/service/AchivementsEngine.py +++ b/packit/src/ui/AchievementsActivity/service/AchivementsEngine.py @@ -252,6 +252,7 @@ def load_account_data_for_import(account_id: str, account_data: dict): "secret_utils_rule": 6700, "secret_aytist": 5800, "secret_connect_is_bullshit": 5000, + "secret_opsec": 1337, } @@ -291,7 +292,7 @@ def get_level_info(data: dict) -> tuple: "days_2555": 2555, "days_2920": 2920, "days_3285": 3285, "days_3650": 3650, } -_SECRET_ACHIEVEMENTS = {"secret_premium", "secret_terraria", "secret_identity", "secret_curiosity", "secret_subscriber", "secret_enlightened", "secret_talking_about_you", "secret_utils_rule", "secret_aytist", "secret_connect_is_bullshit"} +_SECRET_ACHIEVEMENTS = {"secret_premium", "secret_terraria", "secret_identity", "secret_curiosity", "secret_subscriber", "secret_enlightened", "secret_talking_about_you", "secret_utils_rule", "secret_aytist", "secret_connect_is_bullshit", "secret_opsec"} def sync_completed(data: dict) -> tuple: diff --git a/packit/src/ui/IconsListActivity/fragment.py b/packit/src/ui/IconsListActivity/fragment.py index 6629d858..13af5a50 100644 --- a/packit/src/ui/IconsListActivity/fragment.py +++ b/packit/src/ui/IconsListActivity/fragment.py @@ -2,6 +2,7 @@ # SPDX-License-Identifier: GPL-3.0-or-later from packutil import logx +from ...utils.netQueue import run_io import json import threading import re @@ -92,6 +93,338 @@ def _worker(): _preview_queue.put(task) + +def _icons_perform_search(self, act): + try: + query = self.search.getText().toString() + if query != self.last_search_query: + self.last_search_query = query + self.build_list(query) + imm = act.getSystemService("input_method") + imm.hideSoftInputFromWindow(self.search.getWindowToken(), 0) + except Exception: + pass + + +class _IconsSearchTextWatcher(dynamic_proxy(TextWatcher)): + def __init__(self, outer, clear_btn_ref, act): + super().__init__() + self.outer = outer + self.clear_btn = clear_btn_ref + self._live_timer = None + self._act = act + + def _show_live_spinner(self): + try: + if getattr(self.outer, '_live_search_spinner', None) is None: + from org.telegram.ui.Components import CircularProgressDrawable + _size = 122 + _color = Theme.getColor(Theme.key_featuredStickers_addButton) + try: + _d = CircularProgressDrawable(float(_size), float(AndroidUtilities.dp(8)), _color) + except Exception: + _d = CircularProgressDrawable(_color) + try: + _d.size = float(_size) + _d.thickness = float(AndroidUtilities.dp(8)) + except Exception: + pass + _d.setBounds(0, 0, _size, _size) + _spinner_iv = ImageView(self._act) + _spinner_iv.setImageDrawable(_d) + _spinner_iv.setScaleType(ImageView.ScaleType.FIT_CENTER) + spinner_container = FrameLayout(self._act) + spinner_container.setLayoutParams(FrameLayout.LayoutParams(-1, -1)) + _lp = FrameLayout.LayoutParams(_size, _size, Gravity.CENTER) + spinner_container.addView(_spinner_iv, _lp) + self.outer._live_search_spinner = spinner_container + self.outer.content_view.addView(spinner_container, FrameLayout.LayoutParams(-1, -1)) + else: + self.outer._live_search_spinner.setAlpha(1.0) + self.outer._live_search_spinner.setVisibility(View.VISIBLE) + try: + self.outer.results_container.setVisibility(View.INVISIBLE) + except Exception: + pass + except Exception: + pass + + def _hide_live_spinner(self): + try: + spinner = getattr(self.outer, '_live_search_spinner', None) + if spinner is None: + return + spinner.animate().alpha(0.0).setDuration(150).withEndAction( + lambda: spinner.setVisibility(View.GONE) + ).start() + except Exception: + pass + try: + self.outer.results_container.setVisibility(View.VISIBLE) + except Exception: + pass + + def _schedule_live_search(self, query): + prev = self._live_timer + if prev is not None: + try: + prev.cancel() + except Exception: + pass + outer = self.outer + + def _do_search(): + # scoring runs on background thread after debounce + try: + if query != outer.last_search_query: + q = query.strip() + icons = outer.icons or [] + search_index = outer.search_index + sort_type = outer.current_sort_type + + if not q: + filtered = list(icons) + else: + isRussian = False + try: + from java.util import Locale + isRussian = Locale.getDefault().getLanguage() == "ru" + except Exception: + pass + fuzzy = settings.get("fuzzy_search", False) + scored = [] + for icon in icons: + s = search_mod.score(icon, q, search_index, isRussian, fuzzy) + if s[0] < 6: + scored.append((s, icon)) + scored.sort(key=lambda x: x[0]) + filtered = [icon for _, icon in scored] + + if not q: + if sort_type == "alpha_az": + filtered.sort(key=lambda i: str(i.get("name") or i.get("id") or "").lower()) + elif sort_type == "alpha_za": + filtered.sort(key=lambda i: str(i.get("name") or i.get("id") or "").lower(), reverse=True) + elif sort_type == "authors": + filtered.sort(key=lambda i: str(i.get("author") or "").lower()) + + def _ui(q=q, filtered=filtered): + try: + outer.last_search_query = q + outer.filtered_icons = filtered + outer.is_loading = True + outer.results_container.removeAllViews() + outer.visible_icons = [] + outer._card_registry = [] + outer._ticker_started = False + outer._preview_epoch += 1 + if hasattr(outer, "subtitle"): + outer.subtitle.setText(strings["icons_count"].format(len(filtered))) + if not filtered: + outer._show_empty_state() + else: + outer._load_initial_batch() + self._hide_live_spinner() + except Exception: + pass + run_on_ui_thread(_ui) + else: + run_on_ui_thread(lambda: self._hide_live_spinner()) + except Exception: + run_on_ui_thread(lambda: self._hide_live_spinner()) + + t = threading.Timer(0.3, _do_search) + self._live_timer = t + t.start() + + def afterTextChanged(self, s): + text = s.toString() + if text and len(text) > 0: + self.clear_btn.setVisibility(View.VISIBLE) + try: + self.clear_btn.animate().alpha(1.0).setDuration(200).start() + except Exception: + pass + else: + try: + self.clear_btn.animate().alpha(0.0).setDuration(200).withEndAction( + lambda: self.clear_btn.setVisibility(View.GONE)).start() + except Exception: + self.clear_btn.setVisibility(View.GONE) + try: + from elyx import settings as _s + # default must match the plugins catalog (True) + if _s.get("live_search", True): + self._show_live_spinner() + self._schedule_live_search(text) + except Exception: + pass + + def beforeTextChanged(self, s, start, count, after): + pass + def onTextChanged(self, s, start, before, count): + pass + + +def _icons_on_clear_click(self, act): + try: + from elyx import assets + from ...utils.media import playSound + playSound(assets.sounds.clear_search.path_str, "sfx_clear_search") + except Exception: + pass + try: + self.search.setText("") + self.last_search_query = "" + self.build_list("") + imm = act.getSystemService("input_method") + imm.hideSoftInputFromWindow(self.search.getWindowToken(), 0) + except Exception: + pass + + +def _icons_on_search_btn_click(self, act): + try: + from elyx import assets + from ...utils.media import playSound + playSound(assets.sounds.search_btn.path_str, "sfx_search") + except Exception: + pass + _icons_perform_search(self, act) + + +def _icons_show_repo_menu(self, act): + try: + imm = act.getSystemService("input_method") + imm.hideSoftInputFromWindow(self.search.getWindowToken(), 0) + except Exception: + pass + fragment = get_last_fragment() + if fragment: + fragment.finishFragment() + repos = [] + try: + 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 = str(r.get("url") or "").strip() + if name and url: + repos.append(r) + except Exception: + pass + show_icon_repo_sheet(self.install_ui, repos, on_select=self._handle_repo_select) + + +def _icons_open_sort_menu(self, act): + try: + imm = act.getSystemService("input_method") + imm.hideSoftInputFromWindow(self.search.getWindowToken(), 0) + except Exception: + pass + def on_sort_selected(sort_type): + try: + current_q = self.search.getText().toString() if self.search else (self.last_search_query or "") + except Exception: + current_q = self.last_search_query or "" + self.build_list_with_sort(sort_type, current_q) + show_icon_sort_menu(self.install_ui, act, self.current_sort_type, on_sort_selected) + + + +def _icons_editor_listener(outer, act): + EditActionListener = find_class("android.widget.TextView$OnEditorActionListener") + + class _L(dynamic_proxy(EditActionListener)): + def __init__(self): + super().__init__() + + def onEditorAction(self, v, actionId, event): + if actionId in (EditorInfo.IME_ACTION_SEARCH, EditorInfo.IME_ACTION_DONE, 6, 3): + _icons_perform_search(outer, act) + return True + return False + + return _L() + + +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 + live_search = bool(settings.get("live_search", True)) + try: + accent = Theme.getColor(Theme.key_featuredStickers_addButton) + accent_pressed = Theme.getColor(Theme.key_featuredStickers_addButtonPressed) + button_text = Theme.getColor(Theme.key_featuredStickers_buttonText) + except Exception: + accent = Theme.getColor(Theme.key_dialogTextBlue) + accent_pressed = accent + button_text = -1 + subtitle_text = str(strings["total_plugins_unknown"]) if not self.icons else str(strings["icons_count"]).format(len(self.icons)) + main_layout = catalogIconsChromeCreate( + act, + self.main_bg_color, self.card_bg_color, self.card_pressed_color, self.text_color, + accent, accent_pressed, button_text, + self.install_ui._resolve_icon("input_clear"), + self.install_ui._resolve_icon("ic_ab_search"), + self.install_ui._resolve_icon("msg_smile_status"), + self.install_ui._resolve_icon("msg_list"), + subtitle_text, (not live_search), + ) + if main_layout is None: + return None + search_slot = main_layout.findViewWithTag("search_slot") + clear_btn = main_layout.findViewWithTag("clear_btn") + search_btn = main_layout.findViewWithTag("search_btn") + repo_btn = main_layout.findViewWithTag("repo_btn") + subtitle = main_layout.findViewWithTag("subtitle") + sort_btn = main_layout.findViewWithTag("sort_btn") + scroll = main_layout.findViewWithTag("scroll") + if None in (search_slot, clear_btn, search_btn, repo_btn, subtitle, sort_btn, scroll): + logx("icons: kotlin chrome missing tagged views, falling back", False) + return None + + self.search = EditTextBoldCursor(act) + self.search.setHint(strings["icons_search_hint"]) + self.search.setTextSize(TypedValue.COMPLEX_UNIT_DIP, 15) + self.search.setSingleLine(True) + self.search.setInputType(InputType.TYPE_CLASS_TEXT) + self.search.setBackgroundColor(0) + self.search.setTextColor(self.text_color) + try: + self.search.setHintTextColor(self.hint_text_color) + except Exception: + pass + try: + self.search.setCursorColor(self.cursor_color) + except Exception: + pass + try: + self.search.setPadding(AndroidUtilities.dp(8), AndroidUtilities.dp(8), AndroidUtilities.dp(10), AndroidUtilities.dp(8)) + except Exception: + pass + try: + self.search.setOnEditorActionListener(_icons_editor_listener(self, act)) + except Exception as ex: + logx(f"icons: setOnEditorActionListener failed: {ex}", True) + search_slot.addView(self.search, FrameLayout.LayoutParams(-1, -1)) + + clear_btn.setOnClickListener(OnClickListener(lambda v: _icons_on_clear_click(self, act))) + self.install_ui._apply_press_scale(clear_btn) + search_btn.setOnClickListener(OnClickListener(lambda v: _icons_on_search_btn_click(self, act))) + self.install_ui._apply_press_scale(search_btn) + repo_btn.setOnClickListener(OnClickListener(lambda v: _icons_show_repo_menu(self, act))) + self.install_ui._apply_press_scale(repo_btn) + sort_btn.setOnClickListener(OnClickListener(lambda v: _icons_open_sort_menu(self, act))) + self.install_ui._apply_press_scale(sort_btn) + + self.subtitle = subtitle + logx("icons: chrome built via kotlin dex", True) + return (main_layout, scroll, clear_btn) + + class InstallIconsUI: def __init__(self, plugin): self.plugin = plugin @@ -382,7 +715,7 @@ def load_task(): finally: self._loads_in_flight.discard(load_key) try: - run_on_queue(load_task) + run_io(load_task) except Exception: self._loads_in_flight.discard(load_key) raise @@ -458,7 +791,7 @@ def load_task(): finally: self._loads_in_flight.discard(load_key) try: - run_on_queue(load_task) + run_io(load_task) except Exception: self._loads_in_flight.discard(load_key) raise @@ -493,7 +826,10 @@ def _update_current_fragment_icons(self, icons, prebuilt_index=None): logx(f"IconList._update_current_fragment_icons: calling build_list_with_sort('{delegate.current_sort_type}')", True) delegate.build_list_with_sort(delegate.current_sort_type) else: - logx("IconList._update_current_fragment_icons: no results_container and loading not started, icons not shown", True) + # deferred chrome not built yet — flag it, the shell + # wrapper applies the data right after _build_chrome + delegate._data_ready_pending = True + logx("IconList._update_current_fragment_icons: chrome not ready, data flagged as pending", True) else: logx("IconList._update_current_fragment_icons: fragment has no usable delegate", True) except Exception as e: @@ -564,6 +900,8 @@ def __init__(self, install_ui, title, icons, show_loading_initial=False, repo_id self.results_container = None self._finish_loading = None self._loading_started = False + # set when icons arrive before the deferred chrome is built + self._data_ready_pending = False # registry of (iv, loaded_list) for the global swap ticker self._card_registry = [] self._ticker_started = False @@ -619,25 +957,42 @@ def _handle_repo_select(self, selected): self.install_ui._open_repo_icons(selected) def beforeCreateView(self): + # light shell now, heavy chrome a few frames later: _build_chrome + # makes hundreds of python->java calls and used to block the UI + # thread, freezing the fragment open animation for ~half a second act = get_last_fragment().getContext() - colors = self.install_ui._get_theme_colors() - self.main_bg_color = colors["main_bg_color"] - self.card_bg_color = colors["card_bg_color"] - self.card_pressed_color = colors["card_pressed_color"] - self.text_color = colors["text_color"] - self.secondary_text_color = colors["secondary_text_color"] - self.hint_text_color = colors["hint_text_color"] - self.cursor_color = colors["cursor_color"] - self.search_border_color = colors["search_border_color"] - self.search_stroke_width = colors["search_stroke_width"] + try: + shell_bg = self.install_ui._get_theme_colors()["main_bg_color"] + except Exception: + shell_bg = Theme.getColor(Theme.key_windowBackgroundGray) + shell = FrameLayout(act) + shell.setBackgroundColor(shell_bg) - self.content_view = FrameLayout(act) - self.content_view.setBackgroundColor(self.main_bg_color) + def _deferred(): + try: + view = self._build_chrome() + if view is not None: + shell.addView(view, FrameLayout.LayoutParams(-1, -1)) + # data that landed while the chrome was still building + if getattr(self, "_data_ready_pending", False): + self._data_ready_pending = False + if self._loading_started and callable(self._finish_loading): + logx("IconList: applying pending data after chrome build", True) + self._loading_started = False + self._finish_loading() + elif self.results_container is not None: + self.build_list_with_sort(self.current_sort_type) + except Exception as e: + logx(f"IconList: deferred _build_chrome error: {e}", False) + # let the open animation start smoothly before the heavy build + run_on_ui_thread(_deferred, 30) + return shell + def _build_chrome_python(self, act): + # original python chrome builder, kept as the fallback path main_layout = LinearLayout(act) main_layout.setOrientation(LinearLayout.VERTICAL) main_layout.setPadding(AndroidUtilities.dp(16), 0, AndroidUtilities.dp(16), AndroidUtilities.dp(14)) - self.content_view.addView(main_layout, FrameLayout.LayoutParams(-1, -1)) # search bar search_container = FrameLayout(act) @@ -679,189 +1034,12 @@ def beforeCreateView(self): except Exception: pass - def perform_search(): - try: - query = self.search.getText().toString() - if query != self.last_search_query: - self.last_search_query = query - self.build_list(query) - imm = act.getSystemService("input_method") - imm.hideSoftInputFromWindow(self.search.getWindowToken(), 0) - except Exception: - pass try: - EditActionListener = find_class("android.widget.TextView$OnEditorActionListener") - class SearchEditorActionListener(dynamic_proxy(EditActionListener)): - def __init__(self, outer): - super().__init__() - self.outer = outer - def onEditorAction(self, v, actionId, event): - if actionId in (EditorInfo.IME_ACTION_SEARCH, EditorInfo.IME_ACTION_DONE, 6, 3): - perform_search() - return True - return False - self.search.setOnEditorActionListener(SearchEditorActionListener(self)) + self.search.setOnEditorActionListener(_icons_editor_listener(self, act)) except Exception as ex: logx(f"icons: setOnEditorActionListener failed: {ex}", True) - class SearchTextWatcher(dynamic_proxy(TextWatcher)): - def __init__(self, outer, clear_btn_ref): - super().__init__() - self.outer = outer - self.clear_btn = clear_btn_ref - self._live_timer = None - - def _show_live_spinner(self): - try: - if getattr(self.outer, '_live_search_spinner', None) is None: - from org.telegram.ui.Components import CircularProgressDrawable - _size = 122 - _color = Theme.getColor(Theme.key_featuredStickers_addButton) - try: - _d = CircularProgressDrawable(float(_size), float(AndroidUtilities.dp(8)), _color) - except Exception: - _d = CircularProgressDrawable(_color) - try: - _d.size = float(_size) - _d.thickness = float(AndroidUtilities.dp(8)) - except Exception: - pass - _d.setBounds(0, 0, _size, _size) - _spinner_iv = ImageView(act) - _spinner_iv.setImageDrawable(_d) - _spinner_iv.setScaleType(ImageView.ScaleType.FIT_CENTER) - spinner_container = FrameLayout(act) - spinner_container.setLayoutParams(FrameLayout.LayoutParams(-1, -1)) - _lp = FrameLayout.LayoutParams(_size, _size, Gravity.CENTER) - spinner_container.addView(_spinner_iv, _lp) - self.outer._live_search_spinner = spinner_container - self.outer.content_view.addView(spinner_container, FrameLayout.LayoutParams(-1, -1)) - else: - self.outer._live_search_spinner.setAlpha(1.0) - self.outer._live_search_spinner.setVisibility(View.VISIBLE) - try: - self.outer.results_container.setVisibility(View.INVISIBLE) - except Exception: - pass - except Exception: - pass - - def _hide_live_spinner(self): - try: - spinner = getattr(self.outer, '_live_search_spinner', None) - if spinner is None: - return - spinner.animate().alpha(0.0).setDuration(150).withEndAction( - lambda: spinner.setVisibility(View.GONE) - ).start() - except Exception: - pass - try: - self.outer.results_container.setVisibility(View.VISIBLE) - except Exception: - pass - - def _schedule_live_search(self, query): - prev = self._live_timer - if prev is not None: - try: - prev.cancel() - except Exception: - pass - outer = self.outer - - def _do_search(): - # scoring runs on background thread after debounce - try: - if query != outer.last_search_query: - q = query.strip() - icons = outer.icons or [] - search_index = outer.search_index - sort_type = outer.current_sort_type - - if not q: - filtered = list(icons) - else: - isRussian = False - try: - from java.util import Locale - isRussian = Locale.getDefault().getLanguage() == "ru" - except Exception: - pass - fuzzy = settings.get("fuzzy_search", False) - scored = [] - for icon in icons: - s = search_mod.score(icon, q, search_index, isRussian, fuzzy) - if s[0] < 6: - scored.append((s, icon)) - scored.sort(key=lambda x: x[0]) - filtered = [icon for _, icon in scored] - - if not q: - if sort_type == "alpha_az": - filtered.sort(key=lambda i: str(i.get("name") or i.get("id") or "").lower()) - elif sort_type == "alpha_za": - filtered.sort(key=lambda i: str(i.get("name") or i.get("id") or "").lower(), reverse=True) - elif sort_type == "authors": - filtered.sort(key=lambda i: str(i.get("author") or "").lower()) - - def _ui(q=q, filtered=filtered): - try: - outer.last_search_query = q - outer.filtered_icons = filtered - outer.is_loading = True - outer.results_container.removeAllViews() - outer.visible_icons = [] - outer._card_registry = [] - outer._ticker_started = False - outer._preview_epoch += 1 - if hasattr(outer, "subtitle"): - outer.subtitle.setText(strings["icons_count"].format(len(filtered))) - if not filtered: - outer._show_empty_state() - else: - outer._load_initial_batch() - self._hide_live_spinner() - except Exception: - pass - run_on_ui_thread(_ui) - else: - run_on_ui_thread(lambda: self._hide_live_spinner()) - except Exception: - run_on_ui_thread(lambda: self._hide_live_spinner()) - - t = threading.Timer(0.3, _do_search) - self._live_timer = t - t.start() - - def afterTextChanged(self, s): - text = s.toString() - if text and len(text) > 0: - self.clear_btn.setVisibility(View.VISIBLE) - try: - self.clear_btn.animate().alpha(1.0).setDuration(200).start() - except Exception: - pass - else: - try: - self.clear_btn.animate().alpha(0.0).setDuration(200).withEndAction( - lambda: self.clear_btn.setVisibility(View.GONE)).start() - except Exception: - self.clear_btn.setVisibility(View.GONE) - try: - from elyx import settings as _s - # default must match the plugins catalog (True) - if _s.get("live_search", True): - self._show_live_spinner() - self._schedule_live_search(text) - except Exception: - pass - - def beforeTextChanged(self, s, start, count, after): - pass - def onTextChanged(self, s, start, before, count): - pass search_row = LinearLayout(act) search_row.setOrientation(LinearLayout.HORIZONTAL) @@ -884,23 +1062,8 @@ def onTextChanged(self, s, start, before, count): clear_btn_icon.setScaleType(ImageView.ScaleType.CENTER) clear_btn.addView(clear_btn_icon, FrameLayout.LayoutParams(AndroidUtilities.dp(20), AndroidUtilities.dp(20), Gravity.CENTER)) - def on_clear_click(): - try: - from elyx import assets - from ...utils.media import playSound - playSound(assets.sounds.clear_search.path_str, "sfx_clear_search") - except Exception: - pass - try: - self.search.setText("") - self.last_search_query = "" - self.build_list("") - imm = act.getSystemService("input_method") - imm.hideSoftInputFromWindow(self.search.getWindowToken(), 0) - except Exception: - pass - clear_btn.setOnClickListener(OnClickListener(lambda v: on_clear_click())) + clear_btn.setOnClickListener(OnClickListener(lambda v: _icons_on_clear_click(self, act))) self.install_ui._apply_press_scale(clear_btn) clear_btn.setVisibility(View.GONE) clear_btn.setAlpha(0.0) @@ -928,16 +1091,8 @@ def on_clear_click(): search_btn_icon.setScaleType(ImageView.ScaleType.CENTER) search_btn.addView(search_btn_icon, FrameLayout.LayoutParams(AndroidUtilities.dp(20), AndroidUtilities.dp(20), Gravity.CENTER)) - def onSearchBtnClick(v): - try: - from elyx import assets - from ...utils.media import playSound - playSound(assets.sounds.search_btn.path_str, "sfx_search") - except Exception: - pass - perform_search() - search_btn.setOnClickListener(OnClickListener(onSearchBtnClick)) + search_btn.setOnClickListener(OnClickListener(lambda v: _icons_on_search_btn_click(self, act))) self.install_ui._apply_press_scale(search_btn) try: from elyx import settings as _s @@ -976,29 +1131,8 @@ def onSearchBtnClick(v): pass repo_btn.addView(repo_icon, FrameLayout.LayoutParams(AndroidUtilities.dp(20), AndroidUtilities.dp(20), Gravity.CENTER)) - def show_repo_menu_handler(): - try: - imm = act.getSystemService("input_method") - imm.hideSoftInputFromWindow(self.search.getWindowToken(), 0) - except Exception: - pass - fragment = get_last_fragment() - if fragment: - fragment.finishFragment() - repos = [] - try: - 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 = str(r.get("url") or "").strip() - if name and url: - repos.append(r) - except Exception: - pass - show_icon_repo_sheet(self.install_ui, repos, on_select=self._handle_repo_select) - repo_btn.setOnClickListener(OnClickListener(lambda v: show_repo_menu_handler())) + repo_btn.setOnClickListener(OnClickListener(lambda v: _icons_show_repo_menu(self, act))) self.install_ui._apply_press_scale(repo_btn) header_row.addView(repo_btn, FrameLayout.LayoutParams(-2, -2, Gravity.LEFT | Gravity.CENTER_VERTICAL)) @@ -1040,21 +1174,8 @@ def show_repo_menu_handler(): pass sort_btn.addView(sort_icon, FrameLayout.LayoutParams(AndroidUtilities.dp(20), AndroidUtilities.dp(20), Gravity.CENTER)) - def show_sort_menu_handler(): - try: - imm = act.getSystemService("input_method") - imm.hideSoftInputFromWindow(self.search.getWindowToken(), 0) - except Exception: - pass - def on_sort_selected(sort_type): - try: - current_q = self.search.getText().toString() if self.search else (self.last_search_query or "") - except Exception: - current_q = self.last_search_query or "" - self.build_list_with_sort(sort_type, current_q) - show_icon_sort_menu(self.install_ui, act, self.current_sort_type, on_sort_selected) - sort_btn.setOnClickListener(OnClickListener(lambda v: show_sort_menu_handler())) + sort_btn.setOnClickListener(OnClickListener(lambda v: _icons_open_sort_menu(self, act))) self.install_ui._apply_press_scale(sort_btn) header_row.addView(sort_btn, FrameLayout.LayoutParams(-2, -2, Gravity.RIGHT | Gravity.CENTER_VERTICAL)) @@ -1069,6 +1190,35 @@ def on_sort_selected(sort_type): except Exception: pass + return main_layout, scroll, clear_btn + + def _build_chrome(self): + act = get_last_fragment().getContext() + colors = self.install_ui._get_theme_colors() + self.main_bg_color = colors["main_bg_color"] + self.card_bg_color = colors["card_bg_color"] + self.card_pressed_color = colors["card_pressed_color"] + self.text_color = colors["text_color"] + self.secondary_text_color = colors["secondary_text_color"] + self.hint_text_color = colors["hint_text_color"] + self.cursor_color = colors["cursor_color"] + self.search_border_color = colors["search_border_color"] + self.search_stroke_width = colors["search_stroke_width"] + + self.content_view = FrameLayout(act) + self.content_view.setBackgroundColor(self.main_bg_color) + + chrome = None + try: + chrome = _icons_build_chrome_kotlin(self, act) + except Exception as e: + logx(f"icons: kotlin chrome error: {e}", False) + if chrome is None: + main_layout, scroll, clear_btn = self._build_chrome_python(act) + else: + main_layout, scroll, clear_btn = chrome + self.content_view.addView(main_layout, 0, FrameLayout.LayoutParams(-1, -1)) + self.results_container = LinearLayout(act) self.results_container.setOrientation(LinearLayout.VERTICAL) self.results_container.setPadding(0, 0, 0, AndroidUtilities.dp(10)) @@ -1189,8 +1339,9 @@ def onScrollChange(self, v, scrollX, scrollY, oldScrollX, oldScrollY): except Exception: pass - main_layout.addView(scroll, LinearLayout.LayoutParams(-1, 0, 1.0)) - self.search.addTextChangedListener(SearchTextWatcher(self, clear_btn)) + if scroll.getParent() is None: + main_layout.addView(scroll, LinearLayout.LayoutParams(-1, 0, 1.0)) + self.search.addTextChangedListener(_IconsSearchTextWatcher(self, clear_btn, act)) try: from ..viewUtils import applyFontToTree applyFontToTree(self.content_view) diff --git a/packit/src/ui/PluginListActivity/card.py b/packit/src/ui/PluginListActivity/card.py index 41903529..54978947 100644 --- a/packit/src/ui/PluginListActivity/card.py +++ b/packit/src/ui/PluginListActivity/card.py @@ -540,6 +540,27 @@ def create_icon_pill(icon_name, handler): copyLinkSoundPath = None act_for_share = fragment.getParentActivity() if hasattr(fragment, "getParentActivity") else None + def do_install(): + # install straight from the catalog card; stat increments happen + # inside the install pipeline, no manual bump here + if not is_available: + try: + from ui.bulletin import BulletinHelper + BulletinHelper.show_error(str(strings["plugin_version_below_min"])) + except Exception: + pass + return + try: + from ...core import install_plugin + install_plugin( + p, + install_ui=self.install_ui, + all_plugins=self.plugins, + rm_rid=self.repo_id or str(p.get("_repo_id") or ""), + ) + except Exception as e: + logx(f"card: install from card error: {e}", False) + def do_download_relocated(): download_plugin_file(p) try: @@ -587,6 +608,7 @@ def do_report_relocated(): buttons.addView(spacer, LayoutHelper.createLinear(0, 0, 1.0)) relocate_actions = [ + ("_s_relocate_install", "msg_add", do_install), ("_s_relocate_copy", "msg_copy", do_copy_relocated), ("_s_relocate_share", "msg_share", do_share_relocated), ("_s_relocate_code", "msg_view_file", do_code_relocated), @@ -647,6 +669,7 @@ def do_report(): pass show_plugin_context_menu(anchor_view.getRootView(), anchor_view, [ + {"icon": "msg_add", "text": str(strings["msg_one_plugin_install"]), "action": do_install, "show": not getattr(self, "_s_relocate_install", False)}, {"icon": "msg_copy", "text": str(strings["copy_link"]), "action": do_copy, "show": not getattr(self, "_s_relocate_copy", False)}, {"icon": "msg_share", "text": str(strings["share"]), "action": do_share, "show": not getattr(self, "_s_relocate_share", False)}, {"icon": "msg_view_file", "text": str(strings["code"]), "action": do_code, "show": not getattr(self, "_s_relocate_code", False)}, diff --git a/packit/src/ui/PluginListActivity/fragment.py b/packit/src/ui/PluginListActivity/fragment.py index c0490953..d07b7dad 100644 --- a/packit/src/ui/PluginListActivity/fragment.py +++ b/packit/src/ui/PluginListActivity/fragment.py @@ -2,6 +2,7 @@ # SPDX-License-Identifier: GPL-3.0-or-later from packutil import logx +from ...utils.netQueue import run_io import re import json import threading @@ -295,7 +296,7 @@ def load_task(): run_on_ui_thread(lambda: self._update_current_fragment_plugins([])) finally: self._reload_in_flight.discard(reload_key) - run_on_queue(load_task) + run_io(load_task) def _open_all_repos_plugins(self): fragment = get_last_fragment() @@ -408,7 +409,8 @@ def __init__(self, install_ui, title, plugins, show_loading_initial=False, repo_ self.repo_id = repo_id self.plugins = _filter_unavailable(plugins) self.show_loading_initial = show_loading_initial - self.search_index = search_mod.build_index(self.plugins) + # skip the pointless empty-index build (json+dlopen on the click path) + self.search_index = search_mod.build_index(self.plugins) if self.plugins else None self.last_search_query = None self.filtered_plugins = [] self.visible_plugins = [] @@ -522,8 +524,32 @@ def _handle_repo_select(self, selected): self.install_ui._open_repo_plugins(selected) def beforeCreateView(self): + # light shell now, heavy chrome a few frames later: build_list_view + # makes hundreds of python->java calls and used to block the UI + # 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 - return _lv.build_list_view(self) + from android_utils import run_on_ui_thread + act = get_last_fragment().getContext() + try: + bg = self.install_ui._get_theme_colors()["main_bg_color"] + except Exception: + from org.telegram.ui.ActionBar import Theme + bg = Theme.getColor(Theme.key_windowBackgroundGray) + shell = FrameLayout(act) + shell.setBackgroundColor(bg) + + def _deferred(): + try: + view = _lv.build_list_view(self) + if view is not None: + shell.addView(view, FrameLayout.LayoutParams(-1, -1)) + except Exception as e: + logx(f"InstallUI: deferred build_list_view error: {e}", False) + # let the open animation start smoothly before the heavy build + run_on_ui_thread(_deferred, 30) + return shell def getTitle(self): return self.title @@ -596,6 +622,7 @@ def _cache_settings(self): self._s_chip_deps_size = float(settings.get("chip_deps_size", 11)) self._s_chip_size_size = float(settings.get("chip_size_size", 11)) self._s_fuzzy_search = settings.get("fuzzy_search", False) + self._s_relocate_install = settings.get("relocate_install", False) self._s_relocate_copy = settings.get("relocate_copy_link", False) self._s_relocate_share = settings.get("relocate_share", False) self._s_relocate_code = settings.get("relocate_code", False) diff --git a/packit/src/ui/PluginListActivity/listView.py b/packit/src/ui/PluginListActivity/listView.py index 0cd31021..1c95d5bb 100644 --- a/packit/src/ui/PluginListActivity/listView.py +++ b/packit/src/ui/PluginListActivity/listView.py @@ -40,36 +40,329 @@ from .helpers.utils import _build_plugin_count_label -def build_list_view(self) -> View: - logx(f"InstallUI: build_list_view enter id={id(self)} show_loading_initial={self.show_loading_initial} loading_container={getattr(self, 'loading_container', None) is not None} data_ready_before_view={getattr(self, '_data_ready_before_view', False)}", True) - # save scroll position before rebuilding view - _saved_scroll_y = 0 + +def _perform_search(self, act): try: - if hasattr(self, '_scroll_view') and self._scroll_view: - _saved_scroll_y = self._scroll_view.getScrollY() + query = self.search.getText().toString() + if query != self.last_search_query: + self.last_search_query = query + self.build_list(query) + imm = act.getSystemService("input_method") + imm.hideSoftInputFromWindow(self.search.getWindowToken(), 0) except Exception: pass - act = get_last_fragment().getContext() - colors = self.install_ui._get_theme_colors() - self.main_bg_color = colors["main_bg_color"] - self.card_bg_color = colors["card_bg_color"] - self.card_pressed_color = colors["card_pressed_color"] - self.text_color = colors["text_color"] - self.secondary_text_color = colors["secondary_text_color"] - self.hint_text_color = colors["hint_text_color"] - self.cursor_color = colors["cursor_color"] - self.search_border_color = colors["search_border_color"] - self.search_stroke_width = colors["search_stroke_width"] - self.content_view = FrameLayout(act) - self.content_view.setBackgroundColor(self.main_bg_color) - from ...ui.AchievementsActivity.service.AchivementsEngine import register_bulletin_container - register_bulletin_container(self.content_view) +class _SearchTextWatcherWithClear(dynamic_proxy(TextWatcher)): + def __init__(self, outer, clear_btn_ref): + super().__init__() + self.outer = outer + self.clear_btn = clear_btn_ref + self._live_timer = None + + def _show_live_spinner(self): + try: + if getattr(self.outer, '_live_search_spinner', None) is None: + spinner_container, spinner_view = self.outer.install_ui._create_center_loading_animation(self.outer.content_view) + if spinner_container is None: + return + self.outer._live_search_spinner = spinner_container + self.outer._live_search_spinner_view = spinner_view + self.outer.content_view.addView(spinner_container, FrameLayout.LayoutParams(-1, -1)) + else: + self.outer._live_search_spinner.setAlpha(1.0) + self.outer._live_search_spinner.setVisibility(View.VISIBLE) + # hide cards while spinner is shown + try: + self.outer.results_container.setVisibility(View.INVISIBLE) + except Exception: + pass + except Exception: + pass + + def _hide_live_spinner(self): + try: + spinner = getattr(self.outer, '_live_search_spinner', None) + if spinner is None: + return + spinner.animate().alpha(0.0).setDuration(150).withEndAction( + lambda: spinner.setVisibility(View.GONE) + ).start() + except Exception: + pass + try: + self.outer.results_container.setVisibility(View.VISIBLE) + except Exception: + pass + + def _schedule_live_search(self, query): + # cancel previous pending timer + prev = self._live_timer + if prev is not None: + try: + prev.cancel() + except Exception: + pass + outer = self.outer + + def _do_search(): + def _ui(): + try: + if query != outer.last_search_query: + outer.last_search_query = query + outer.build_list(query) + self._hide_live_spinner() + except Exception: + pass + run_on_ui_thread(_ui) + + t = threading.Timer(0.3, _do_search) + self._live_timer = t + t.start() + + def afterTextChanged(self, s): + text = s.toString() + if text and len(text) > 0: + self.clear_btn.setVisibility(View.VISIBLE) + try: + self.clear_btn.animate().alpha(1.0).setDuration(200).start() + except Exception: + pass + else: + try: + self.clear_btn.animate().alpha(0.0).setDuration(200).withEndAction( + lambda: self.clear_btn.setVisibility(View.GONE)).start() + except Exception: + self.clear_btn.setVisibility(View.GONE) + try: + from elyx import settings as _s + if _s.get("live_search", True) and not getattr(self.outer, "_ai_result_active", False): + self._show_live_spinner() + self._schedule_live_search(text) + except Exception: + pass + + def beforeTextChanged(self, s, start, count, after): + pass + def onTextChanged(self, s, start, before, count): + pass + + +def _on_clear_click(self, act): + try: + from elyx import assets + playSound(assets.sounds.clear_search.path_str, "sfx_clear_search") + except Exception: + pass + try: + self._ai_result_active = False + self._ai_result_plugins = [] + self.search.setText("") + self.last_search_query = "" + self.build_list("") + imm = act.getSystemService("input_method") + imm.hideSoftInputFromWindow(self.search.getWindowToken(), 0) + except Exception: + pass + + +def _on_search_btn_click(self, act): + try: + from elyx import assets + playSound(assets.sounds.search_btn.path_str, "sfx_search") + except Exception: + pass + _perform_search(self, act) + + +def _on_ai_pill_click(self, act): + try: + def _on_ai_results(names, query): + # set search field text and filter list by AI-returned plugin names + try: + ai_marker = "%ai response%" + # filter visible plugins to only those returned by AI, preserving order + name_set = set(n.lower() for n in names) + ordered = [] + for name in names: + for p in self.plugins: + pname = str(p.get("name") or p.get("id") or "").strip() + if pname.lower() == name.lower(): + ordered.append(p) + break + # fallback: include any plugin whose name is in name_set but not yet matched + matched_names = set(str(p.get("name") or p.get("id") or "").strip().lower() for p in ordered) + for p in self.plugins: + pname = str(p.get("name") or p.get("id") or "").strip().lower() + if pname in name_set and pname not in matched_names: + ordered.append(p) + matched_names.add(pname) + self._ai_result_plugins = ordered + # set flag before setText so watcher skips live search + self._ai_result_active = True + self.last_search_query = ai_marker + self.search.setText(ai_marker) + self.filtered_plugins = ordered + self.visible_plugins = [] + self.lazy_load_queue = deque() + self.results_container.removeAllViews() + if hasattr(self, "subtitle"): + total = len(self.plugins) + self.subtitle.setText(f"{len(ordered)}/{_build_plugin_count_label(total)}") + self._load_initial_batch() + except Exception as e: + logx(f"listView: on_ai_results error: {e}", False) + + show_ai_search_sheet(self.install_ui, act, on_ai_results=_on_ai_results) + except Exception as e: + logx(f"listView: ai search sheet error: {e}", False) + + +def _show_tag_filter(self, act): + try: + imm = act.getSystemService("input_method") + imm.hideSoftInputFromWindow(self.search.getWindowToken(), 0) + except Exception: + pass + def on_apply(tags, authors, app_versions, saved): + try: + self.selected_tags = tags + self.selected_authors = authors + self.selected_app_versions = app_versions + self.selected_saved = saved + current_q = self.search.getText().toString() if self.search else (self.last_search_query or "") + self.build_list_with_sort(self.current_sort_type, current_q) + except Exception: + pass + self._active_drawer = show_tag_drawer(act, self.content_view, self.plugins, self.selected_tags, on_apply, + self.selected_authors, self.selected_app_versions, self.selected_saved) + + +def _open_sort_menu(self, act): + try: + imm = act.getSystemService("input_method") + imm.hideSoftInputFromWindow(self.search.getWindowToken(), 0) + except Exception: + pass + def on_sort_selected(sort_type): + try: + current_q = self.search.getText().toString() if self.search else (self.last_search_query or "") + except Exception: + current_q = self.last_search_query or "" + self.build_list_with_sort(sort_type, current_q) + show_sort_menu(self.install_ui, act, self.current_sort_type, on_sort_selected) + + + +def _make_editor_action_listener(outer, act): + EditActionListener = find_class("android.widget.TextView$OnEditorActionListener") + + class _L(dynamic_proxy(EditActionListener)): + def __init__(self): + super().__init__() + + def onEditorAction(self, v, actionId, event): + if actionId == EditorInfo.IME_ACTION_SEARCH or actionId == EditorInfo.IME_ACTION_DONE or actionId == 6 or actionId == 3: + _perform_search(outer, act) + return True + return False + + return _L() + + +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 elyx import settings as _s + live_search = bool(_s.get("live_search", True)) + try: + accent = Theme.getColor(Theme.key_featuredStickers_addButton) + accent_pressed = Theme.getColor(Theme.key_featuredStickers_addButtonPressed) + button_text = Theme.getColor(Theme.key_featuredStickers_buttonText) + except Exception: + accent = Theme.getColor(Theme.key_dialogTextBlue) + accent_pressed = accent + button_text = -1 + try: + bold = AndroidUtilities.bold() + except Exception: + bold = None + subtitle_text = _build_plugin_count_label(len(self.plugins)) if self.plugins else str(strings["total_plugins_unknown"]) + main_layout = catalogChromeCreate( + act, + self.main_bg_color, self.card_bg_color, self.card_pressed_color, self.text_color, + accent, accent_pressed, button_text, + self.install_ui._resolve_icon("input_clear"), + self.install_ui._resolve_icon("ic_ab_search"), + self.install_ui._resolve_icon("msg_search"), + self.install_ui._resolve_icon("msg_list"), + self.install_ui._resolve_icon("msg_topics"), + "AI", subtitle_text, (not live_search), bold, + ) + if main_layout is None: + return None + search_slot = main_layout.findViewWithTag("search_slot") + clear_btn = main_layout.findViewWithTag("clear_btn") + search_btn = main_layout.findViewWithTag("search_btn") + ai_pill = main_layout.findViewWithTag("ai_pill") + subtitle = main_layout.findViewWithTag("subtitle") + tag_filter_btn = main_layout.findViewWithTag("tag_filter_btn") + sort_btn = main_layout.findViewWithTag("sort_btn") + scroll = main_layout.findViewWithTag("scroll") + if None in (search_slot, clear_btn, search_btn, ai_pill, subtitle, tag_filter_btn, sort_btn, scroll): + logx("listView: kotlin chrome missing tagged views, falling back", False) + return None + + # the only telegram-specific view is created here on the python side + self.search = EditTextBoldCursor(act) + self.search.setHint(strings["search_hint"]) + self.search.setTextSize(TypedValue.COMPLEX_UNIT_DIP, 15) + self.search.setSingleLine(True) + self.search.setInputType(InputType.TYPE_CLASS_TEXT) + self.search.setBackgroundColor(0) + self.search.setTextColor(self.text_color) + try: + self.search.setHintTextColor(self.hint_text_color) + except Exception: + pass + try: + self.search.setCursorColor(self.cursor_color) + except Exception: + pass + try: + self.search.setPadding(AndroidUtilities.dp(8), AndroidUtilities.dp(8), AndroidUtilities.dp(10), AndroidUtilities.dp(8)) + except Exception: + pass + try: + self.search.setOnEditorActionListener(_make_editor_action_listener(self, act)) + except Exception: + pass + search_slot.addView(self.search, FrameLayout.LayoutParams(-1, -1)) + + clear_btn.setOnClickListener(OnClickListener(lambda v: _on_clear_click(self, act))) + self.install_ui._apply_press_scale(clear_btn) + search_btn.setOnClickListener(OnClickListener(lambda v: _on_search_btn_click(self, act))) + self.install_ui._apply_press_scale(search_btn) + ai_pill.setOnClickListener(OnClickListener(lambda v: _on_ai_pill_click(self, act))) + self.install_ui._apply_press_scale(ai_pill) + tag_filter_btn.setOnClickListener(OnClickListener(lambda v: _show_tag_filter(self, act))) + self.install_ui._apply_press_scale(tag_filter_btn) + sort_btn.setOnClickListener(OnClickListener(lambda v: _open_sort_menu(self, act))) + self.install_ui._apply_press_scale(sort_btn) + + self.subtitle = subtitle + self._scroll_view = scroll + logx("listView: chrome built via kotlin dex", True) + return (main_layout, scroll, clear_btn) + + +def _build_chrome_python(self, act): + # original python chrome builder, kept as the fallback path main_layout = LinearLayout(act) main_layout.setOrientation(LinearLayout.VERTICAL) main_layout.setPadding(AndroidUtilities.dp(16), 0, AndroidUtilities.dp(16), AndroidUtilities.dp(14)) - self.content_view.addView(main_layout, FrameLayout.LayoutParams(-1, -1)) search_container = FrameLayout(act) pill = GradientDrawable() @@ -109,125 +402,12 @@ def build_list_view(self) -> View: except Exception: pass - def perform_search(): - try: - query = self.search.getText().toString() - if query != self.last_search_query: - self.last_search_query = query - self.build_list(query) - imm = act.getSystemService("input_method") - imm.hideSoftInputFromWindow(self.search.getWindowToken(), 0) - except Exception: - pass try: - EditActionListener = find_class("android.widget.TextView$OnEditorActionListener") - class SearchEditorActionListener(dynamic_proxy(EditActionListener)): - def __init__(self, outer): - super().__init__() - self.outer = outer - def onEditorAction(self, v, actionId, event): - if actionId == EditorInfo.IME_ACTION_SEARCH or actionId == EditorInfo.IME_ACTION_DONE or actionId == 6 or actionId == 3: - perform_search() - return True - return False - self.search.setOnEditorActionListener(SearchEditorActionListener(self)) + self.search.setOnEditorActionListener(_make_editor_action_listener(self, act)) except Exception: pass - class SearchTextWatcherWithClear(dynamic_proxy(TextWatcher)): - def __init__(self, outer, clear_btn_ref): - super().__init__() - self.outer = outer - self.clear_btn = clear_btn_ref - self._live_timer = None - - def _show_live_spinner(self): - try: - if getattr(self.outer, '_live_search_spinner', None) is None: - spinner_container, spinner_view = self.outer.install_ui._create_center_loading_animation(self.outer.content_view) - if spinner_container is None: - return - self.outer._live_search_spinner = spinner_container - self.outer._live_search_spinner_view = spinner_view - self.outer.content_view.addView(spinner_container, FrameLayout.LayoutParams(-1, -1)) - else: - self.outer._live_search_spinner.setAlpha(1.0) - self.outer._live_search_spinner.setVisibility(View.VISIBLE) - # hide cards while spinner is shown - try: - self.outer.results_container.setVisibility(View.INVISIBLE) - except Exception: - pass - except Exception: - pass - - def _hide_live_spinner(self): - try: - spinner = getattr(self.outer, '_live_search_spinner', None) - if spinner is None: - return - spinner.animate().alpha(0.0).setDuration(150).withEndAction( - lambda: spinner.setVisibility(View.GONE) - ).start() - except Exception: - pass - try: - self.outer.results_container.setVisibility(View.VISIBLE) - except Exception: - pass - - def _schedule_live_search(self, query): - # cancel previous pending timer - prev = self._live_timer - if prev is not None: - try: - prev.cancel() - except Exception: - pass - outer = self.outer - - def _do_search(): - def _ui(): - try: - if query != outer.last_search_query: - outer.last_search_query = query - outer.build_list(query) - self._hide_live_spinner() - except Exception: - pass - run_on_ui_thread(_ui) - - t = threading.Timer(0.3, _do_search) - self._live_timer = t - t.start() - - def afterTextChanged(self, s): - text = s.toString() - if text and len(text) > 0: - self.clear_btn.setVisibility(View.VISIBLE) - try: - self.clear_btn.animate().alpha(1.0).setDuration(200).start() - except Exception: - pass - else: - try: - self.clear_btn.animate().alpha(0.0).setDuration(200).withEndAction( - lambda: self.clear_btn.setVisibility(View.GONE)).start() - except Exception: - self.clear_btn.setVisibility(View.GONE) - try: - from elyx import settings as _s - if _s.get("live_search", True) and not getattr(self.outer, "_ai_result_active", False): - self._show_live_spinner() - self._schedule_live_search(text) - except Exception: - pass - - def beforeTextChanged(self, s, start, count, after): - pass - def onTextChanged(self, s, start, before, count): - pass search_row = LinearLayout(act) search_row.setOrientation(LinearLayout.HORIZONTAL) @@ -251,24 +431,8 @@ def onTextChanged(self, s, start, before, count): clear_btn_icon.setScaleType(ImageView.ScaleType.CENTER) clear_btn.addView(clear_btn_icon, FrameLayout.LayoutParams(AndroidUtilities.dp(20), AndroidUtilities.dp(20), Gravity.CENTER)) - def on_clear_click(): - try: - from elyx import assets - playSound(assets.sounds.clear_search.path_str, "sfx_clear_search") - except Exception: - pass - try: - self._ai_result_active = False - self._ai_result_plugins = [] - self.search.setText("") - self.last_search_query = "" - self.build_list("") - imm = act.getSystemService("input_method") - imm.hideSoftInputFromWindow(self.search.getWindowToken(), 0) - except Exception: - pass - clear_btn.setOnClickListener(OnClickListener(lambda v: on_clear_click())) + clear_btn.setOnClickListener(OnClickListener(lambda v: _on_clear_click(self, act))) self.install_ui._apply_press_scale(clear_btn) clear_btn.setVisibility(View.GONE) clear_btn.setAlpha(0.0) @@ -297,15 +461,8 @@ def on_clear_click(): search_btn_icon.setScaleType(ImageView.ScaleType.CENTER) search_btn.addView(search_btn_icon, FrameLayout.LayoutParams(AndroidUtilities.dp(20), AndroidUtilities.dp(20), Gravity.CENTER)) - def onSearchBtnClick(v): - try: - from elyx import assets - playSound(assets.sounds.search_btn.path_str, "sfx_search") - except Exception: - pass - perform_search() - search_btn.setOnClickListener(OnClickListener(onSearchBtnClick)) + search_btn.setOnClickListener(OnClickListener(lambda v: _on_search_btn_click(self, act))) self.install_ui._apply_press_scale(search_btn) try: from elyx import settings as _s @@ -361,49 +518,8 @@ def onSearchBtnClick(v): ai_pill_lp = FrameLayout.LayoutParams(-2, -2, Gravity.LEFT | Gravity.CENTER_VERTICAL) header_row.addView(ai_pill, ai_pill_lp) - def on_ai_pill_click(v): - try: - def _on_ai_results(names, query): - # set search field text and filter list by AI-returned plugin names - try: - ai_marker = "%ai response%" - # filter visible plugins to only those returned by AI, preserving order - name_set = set(n.lower() for n in names) - ordered = [] - for name in names: - for p in self.plugins: - pname = str(p.get("name") or p.get("id") or "").strip() - if pname.lower() == name.lower(): - ordered.append(p) - break - # fallback: include any plugin whose name is in name_set but not yet matched - matched_names = set(str(p.get("name") or p.get("id") or "").strip().lower() for p in ordered) - for p in self.plugins: - pname = str(p.get("name") or p.get("id") or "").strip().lower() - if pname in name_set and pname not in matched_names: - ordered.append(p) - matched_names.add(pname) - self._ai_result_plugins = ordered - # set flag before setText so watcher skips live search - self._ai_result_active = True - self.last_search_query = ai_marker - self.search.setText(ai_marker) - self.filtered_plugins = ordered - self.visible_plugins = [] - self.lazy_load_queue = deque() - self.results_container.removeAllViews() - if hasattr(self, "subtitle"): - total = len(self.plugins) - self.subtitle.setText(f"{len(ordered)}/{_build_plugin_count_label(total)}") - self._load_initial_batch() - except Exception as e: - logx(f"listView: on_ai_results error: {e}", False) - - show_ai_search_sheet(self.install_ui, act, on_ai_results=_on_ai_results) - except Exception as e: - logx(f"listView: ai search sheet error: {e}", False) - - ai_pill.setOnClickListener(OnClickListener(on_ai_pill_click)) + + ai_pill.setOnClickListener(OnClickListener(lambda v: _on_ai_pill_click(self, act))) self.install_ui._apply_press_scale(ai_pill) subtitle = TextView(act) @@ -447,26 +563,8 @@ def _on_ai_results(names, query): pass tag_filter_btn.addView(tag_filter_icon, FrameLayout.LayoutParams(AndroidUtilities.dp(20), AndroidUtilities.dp(20), Gravity.CENTER)) - def show_tag_filter_handler(): - try: - imm = act.getSystemService("input_method") - imm.hideSoftInputFromWindow(self.search.getWindowToken(), 0) - except Exception: - pass - def on_apply(tags, authors, app_versions, saved): - try: - self.selected_tags = tags - self.selected_authors = authors - self.selected_app_versions = app_versions - self.selected_saved = saved - current_q = self.search.getText().toString() if self.search else (self.last_search_query or "") - self.build_list_with_sort(self.current_sort_type, current_q) - except Exception: - pass - self._active_drawer = show_tag_drawer(act, self.content_view, self.plugins, self.selected_tags, on_apply, - self.selected_authors, self.selected_app_versions, self.selected_saved) - tag_filter_btn.setOnClickListener(OnClickListener(lambda v: show_tag_filter_handler())) + tag_filter_btn.setOnClickListener(OnClickListener(lambda v: _show_tag_filter(self, act))) self.install_ui._apply_press_scale(tag_filter_btn) tag_filter_btn_lp = FrameLayout.LayoutParams(-2, -2, Gravity.RIGHT | Gravity.CENTER_VERTICAL) tag_filter_btn_lp.rightMargin = AndroidUtilities.dp(40) @@ -491,21 +589,8 @@ def on_apply(tags, authors, app_versions, saved): pass sort_btn.addView(sort_icon, FrameLayout.LayoutParams(AndroidUtilities.dp(20), AndroidUtilities.dp(20), Gravity.CENTER)) - def show_sort_menu_handler(): - try: - imm = act.getSystemService("input_method") - imm.hideSoftInputFromWindow(self.search.getWindowToken(), 0) - except Exception: - pass - def on_sort_selected(sort_type): - try: - current_q = self.search.getText().toString() if self.search else (self.last_search_query or "") - except Exception: - current_q = self.last_search_query or "" - self.build_list_with_sort(sort_type, current_q) - show_sort_menu(self.install_ui, act, self.current_sort_type, on_sort_selected) - sort_btn.setOnClickListener(OnClickListener(lambda v: show_sort_menu_handler())) + sort_btn.setOnClickListener(OnClickListener(lambda v: _open_sort_menu(self, act))) self.install_ui._apply_press_scale(sort_btn) sort_btn_lp = FrameLayout.LayoutParams(-2, -2, Gravity.RIGHT | Gravity.CENTER_VERTICAL) header_row.addView(sort_btn, sort_btn_lp) @@ -522,6 +607,46 @@ def on_sort_selected(sort_type): except Exception: pass + return main_layout, scroll, clear_btn + + +def build_list_view(self) -> View: + logx(f"InstallUI: build_list_view enter id={id(self)} show_loading_initial={self.show_loading_initial} loading_container={getattr(self, 'loading_container', None) is not None} data_ready_before_view={getattr(self, '_data_ready_before_view', False)}", True) + # save scroll position before rebuilding view + _saved_scroll_y = 0 + try: + if hasattr(self, '_scroll_view') and self._scroll_view: + _saved_scroll_y = self._scroll_view.getScrollY() + except Exception: + pass + + act = get_last_fragment().getContext() + colors = self.install_ui._get_theme_colors() + self.main_bg_color = colors["main_bg_color"] + self.card_bg_color = colors["card_bg_color"] + self.card_pressed_color = colors["card_pressed_color"] + self.text_color = colors["text_color"] + self.secondary_text_color = colors["secondary_text_color"] + self.hint_text_color = colors["hint_text_color"] + self.cursor_color = colors["cursor_color"] + self.search_border_color = colors["search_border_color"] + self.search_stroke_width = colors["search_stroke_width"] + + self.content_view = FrameLayout(act) + self.content_view.setBackgroundColor(self.main_bg_color) + from ...ui.AchievementsActivity.service.AchivementsEngine import register_bulletin_container + register_bulletin_container(self.content_view) + chrome = None + try: + chrome = _build_chrome_kotlin(self, act) + except Exception as e: + logx(f"listView: kotlin chrome error: {e}", False) + if chrome is None: + main_layout, scroll, clear_btn = _build_chrome_python(self, act) + else: + main_layout, scroll, clear_btn = chrome + self.content_view.addView(main_layout, FrameLayout.LayoutParams(-1, -1)) + if self.show_loading_initial: content_wrapper = FrameLayout(act) content_wrapper.setLayoutParams(ScrollView.LayoutParams(-1, -2)) @@ -802,13 +927,14 @@ def onScrollChange(self, v, scrollX, scrollY, oldScrollX, oldScrollY): self.results_container = LinearLayout(act) self.results_container.setOrientation(LinearLayout.VERTICAL) self.results_container.setPadding(0, 0, 0, AndroidUtilities.dp(10)) - main_layout.addView(scroll, LinearLayout.LayoutParams(-1, 0, 1.0)) + if scroll.getParent() is None: + main_layout.addView(scroll, LinearLayout.LayoutParams(-1, 0, 1.0)) if _saved_scroll_y > 0: _y = _saved_scroll_y run_on_ui_thread(lambda: scroll.scrollTo(0, _y)) - self.search.addTextChangedListener(SearchTextWatcherWithClear(self, clear_btn)) + self.search.addTextChangedListener(_SearchTextWatcherWithClear(self, clear_btn)) try: from ..viewUtils import applyFontToTree applyFontToTree(self.content_view) diff --git a/packit/src/ui/pluginsUpdates/fragment.py b/packit/src/ui/pluginsUpdates/fragment.py index 827eb229..060bbc30 100644 --- a/packit/src/ui/pluginsUpdates/fragment.py +++ b/packit/src/ui/pluginsUpdates/fragment.py @@ -2,6 +2,7 @@ # SPDX-License-Identifier: GPL-3.0-or-later from packutil import logx +from ...utils.netQueue import run_io import json import os import threading @@ -1015,7 +1016,7 @@ def on_done(): logx(f"pluginsUpdates: task error: {e}", False) run_on_ui_thread(lambda: (setattr(self, '_is_loading', False), self._show_empty(str(strings["updates_failed_to_check"]), "error", title=str(strings["updates_error_title"]))) if alive[0] else None) - run_on_queue(task) + run_io(task) def _hide_spinner(self): try: @@ -1511,7 +1512,7 @@ def on_ui(): except Exception as e: logx(f"pluginsUpdates: _open_plugin_profile task error: {e}", False) - run_on_queue(task) + run_io(task) except Exception as e: logx(f"pluginsUpdates: _open_plugin_profile error: {e}", False) @@ -1674,7 +1675,7 @@ def on_installed(installed_pid): logx(f"pluginsUpdates: _install_update task error: {e}", False) run_on_ui_thread(lambda: set_btn_state("idle")) - run_on_queue(task) + run_io(task) def _on_plugin_done(self): self._done_count[0] += 1 @@ -2191,7 +2192,7 @@ def on_error(error): if on_done: on_done() - run_on_queue(task) + run_io(task) def _on_update_all_click(self): if self._is_loading: diff --git a/packit/src/utils/netQueue.py b/packit/src/utils/netQueue.py new file mode 100644 index 00000000..ed26e489 --- /dev/null +++ b/packit/src/utils/netQueue.py @@ -0,0 +1,58 @@ +# pyright: reportMissingImports=false +# SPDX-License-Identifier: GPL-3.0-or-later + +# PackIt's own executors for network I/O. Historically these tasks went +# through client_utils.run_on_queue — the HOST's plugins DispatchQueue, the +# same one PluginsController uses to open any plugin settings screen. One +# slow fetch (github raw timing out at 10-20s) stalled that queue and froze +# every PackIt entry point until the timeout fired. +# +# run_io: small pool for independent fetches (catalogs, widgets, update +# checks) — parallel, unordered. +# run_serial_io: single lane for RepositoryManager tasks, preserving their +# previous relative ordering (they read/write the repo cache files). + +import threading + +from packutil import logx + +_IO_WORKERS = 3 + +_io_queue = None +_serial_queue = None +_lock = threading.Lock() + + +def _start_workers(q, count, name): + def _worker(): + while True: + fn = q.get() + try: + fn() + except Exception as e: + logx(f"netQueue: {name} task error: {e}", False) + finally: + q.task_done() + + for _ in range(count): + threading.Thread(target=_worker, daemon=True).start() + + +def run_io(task): + global _io_queue + with _lock: + if _io_queue is None: + import queue + _io_queue = queue.Queue() + _start_workers(_io_queue, _IO_WORKERS, "io") + _io_queue.put(task) + + +def run_serial_io(task): + global _serial_queue + with _lock: + if _serial_queue is None: + import queue + _serial_queue = queue.Queue() + _start_workers(_serial_queue, 1, "serial") + _serial_queue.put(task) diff --git a/scripts/linux/kotlin-build.sh b/scripts/linux/kotlin-build.sh index 5e0f8a32..57cd99b2 100644 --- a/scripts/linux/kotlin-build.sh +++ b/scripts/linux/kotlin-build.sh @@ -25,6 +25,7 @@ MIN_API=26 PACKAGES=( "badges=kawaii.packetik.badges.BadgesNative" "openfile=kawaii.packetik.openfile.OpenFileNative" + "catalog=kawaii.packetik.catalog.CatalogChromeNative" ) die() { echo "error: $*" >&2; exit 1; }