From b4e422db0bf5ada40471b35d050c879957b47121 Mon Sep 17 00:00:00 2001 From: Berserker Date: Tue, 31 Mar 2026 20:03:44 +0200 Subject: [PATCH] Android: working clients --- CommonClient.py | 29 ++++-- Launcher.py | 49 +++++++--- ModuleUpdate.py | 2 +- Utils.py | 2 +- __init__.py | 1 + buildozer.spec | 4 +- kvui.py | 163 ++++++++++++++++++++++++++------ worlds/LauncherComponents.py | 5 +- worlds/apquest/client/launch.py | 9 +- 9 files changed, 209 insertions(+), 55 deletions(-) create mode 100644 __init__.py diff --git a/CommonClient.py b/CommonClient.py index 3f98a4eff1..356b7577cb 100755 --- a/CommonClient.py +++ b/CommonClient.py @@ -806,8 +806,17 @@ class CommonContext: def run_gui(self): """Import kivy UI system from make_gui() and start running it as self.ui_task.""" ui_class = self.make_gui() - self.ui = ui_class(self) - self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI") + if Utils.is_kivy_running(): + from kivy.app import App + app = App.get_running_app() + self.ui = ui_class(self) + app.add_screen(self.ui) + else: + from kvui import GameApp + self.ui_app = GameApp(None) + self.ui = ui_class(self) + self.ui_app.update_ui(self.ui) + self.ui_task = asyncio.create_task(self.ui_app.async_run(), name="UI") def run_cli(self): if sys.stdin: @@ -1215,15 +1224,23 @@ def run_as_textclient(*args): parser = get_base_parser(description="Gameless Archipelago Client, for text interfacing.") parser.add_argument('--name', default=None, help="Slot Name to connect as.") parser.add_argument("url", nargs="?", help="Archipelago connection url") - args = parser.parse_args(args) + parsed_args = parser.parse_args(args) - args = handle_url_arg(args, parser=parser) + parsed_args = handle_url_arg(parsed_args, parser=parser) # use colorama to display colored text highlighting on windows colorama.just_fix_windows_console() - asyncio.run(main(args)) - colorama.deinit() + try: + loop = asyncio.get_running_loop() + except RuntimeError: + loop = None + + if loop: + return loop.create_task(main(parsed_args), name="TextClient Main") + else: + asyncio.run(main(parsed_args)) + colorama.deinit() if __name__ == '__main__': diff --git a/Launcher.py b/Launcher.py index 09187b525c..1d0ca8b086 100644 --- a/Launcher.py +++ b/Launcher.py @@ -9,6 +9,7 @@ Additional components can be added to worlds.LauncherComponents.components. """ import argparse +import asyncio import logging import multiprocessing import os @@ -240,7 +241,7 @@ refresh_components: Callable[[], None] | None = None def run_gui(launch_components: list[Component], args: Any) -> None: - from kvui import (ThemedApp, MDFloatLayout, MDGridLayout, ScrollBox) + from kvui import (ThemedApp, MDScreenManager, MDScreen, MDFloatLayout, MDGridLayout, ScrollBox) from kivy.properties import ObjectProperty from kivy.core.window import Window from kivy.metrics import dp @@ -255,7 +256,7 @@ def run_gui(launch_components: list[Component], args: Any) -> None: class LauncherCard(MDCard): component: Component | None image: str - context_button: MDIconButton = ObjectProperty(None) + context_button: MDIconButton = ObjectProperty(None, allownone=True) def __init__(self, *args, component: Component | None = None, image_path: str = "", **kwargs): self.component = component @@ -264,17 +265,23 @@ def run_gui(launch_components: list[Component], args: Any) -> None: class Launcher(ThemedApp): base_title: str = "Archipelago Launcher" - top_screen: MDFloatLayout = ObjectProperty(None) - navigation: MDGridLayout = ObjectProperty(None) - grid: MDGridLayout = ObjectProperty(None) - button_layout: ScrollBox = ObjectProperty(None) - search_box: MDTextField = ObjectProperty(None) + sm: MDScreenManager + + @property + def active_manager(self): + return getattr(getattr(self, "sm", None), "current_screen", None) + + launcher_screen: MDScreen + top_screen: MDFloatLayout = ObjectProperty(None, allownone=True) + navigation: MDGridLayout = ObjectProperty(None, allownone=True) + grid: MDGridLayout = ObjectProperty(None, allownone=True) + button_layout: ScrollBox = ObjectProperty(None, allownone=True) + search_box: MDTextField = ObjectProperty(None, allownone=True) cards: list[LauncherCard] current_filter: Sequence[str | Type] | None - def __init__(self, ctx=None, components=None, args=None): + def __init__(self, components=None, args=None): self.title = self.base_title + " " + Utils.__version__ - self.ctx = ctx self.icon = r"data/icon.png" self.favorites = [] self.launch_components = components @@ -372,6 +379,9 @@ def run_gui(launch_components: list[Component], args: Any) -> None: self.button_layout.layout.add_widget(card) def build(self): + self.sm = MDScreenManager() + self.launcher_screen = MDScreen(name='launcher') + self.top_screen = Builder.load_file(Utils.local_path("data/launcher.kv")) self.grid = self.top_screen.ids.grid self.navigation = self.top_screen.ids.navigation @@ -380,6 +390,9 @@ def run_gui(launch_components: list[Component], args: Any) -> None: self.set_colors() self.top_screen.md_bg_color = self.theme_cls.backgroundColor + self.launcher_screen.add_widget(self.top_screen) + self.sm.add_widget(self.launcher_screen) + global refresh_components refresh_components = self._refresh_components @@ -396,7 +409,19 @@ def run_gui(launch_components: list[Component], args: Any) -> None: # from kivy.modules.console import create_console # create_console(Window, self.top_screen) - return self.top_screen + return self.sm + + def add_screen(self, screen: MDScreen): + if self.sm.has_screen(screen.name): + self.sm.remove_widget(self.sm.get_screen(screen.name)) + screen.build() + self.sm.add_widget(screen) + self.sm.current = screen.name + screen.on_start() + + def remove_screen(self, screen: MDScreen): + self.sm.current = 'launcher' + self.sm.remove_widget(screen) def on_start(self): if self.launch_components: @@ -425,7 +450,7 @@ def run_gui(launch_components: list[Component], args: Any) -> None: # Activate search as soon as we start typing, no matter if we are focused on the search box or not. # Focus first, then capture the first character we type, otherwise it gets swallowed and lost. # Limit text input to ASCII non-control characters (space bar to tilde). - if not self.search_box.focus: + if self.sm.current == 'launcher' and not self.search_box.focus: self.search_box.focus = True if key in range(32, 126): self.search_box.text += codepoint @@ -442,7 +467,7 @@ def run_gui(launch_components: list[Component], args: Any) -> None: for filter in self.current_filter)) super().on_stop() - Launcher(components=launch_components, args=args).run() + asyncio.run(Launcher(components=launch_components, args=args).async_run()) # avoiding Launcher reference leak # and don't try to do something with widgets after window closed diff --git a/ModuleUpdate.py b/ModuleUpdate.py index 016f778b89..5a66ec48f4 100644 --- a/ModuleUpdate.py +++ b/ModuleUpdate.py @@ -21,7 +21,7 @@ _skip_update = bool( getattr(sys, "frozen", False) or multiprocessing.parent_process() or os.environ.get("SKIP_REQUIREMENTS_UPDATE", "").lower() in ("1", "true", "yes") or - sys.platform in ("ios", "android") + sys.platform in ("ios", "android") or "P4A_BOOTSTRAP" in os.environ or "ANDROID_ARGUMENT" in os.environ ) update_ran = _skip_update diff --git a/Utils.py b/Utils.py index d8dd8f01ab..4cc582f2c6 100644 --- a/Utils.py +++ b/Utils.py @@ -58,7 +58,7 @@ version_tuple = tuplize_version(__version__) is_linux = sys.platform.startswith("linux") is_macos = sys.platform == "darwin" is_windows = sys.platform in ("win32", "cygwin", "msys") -is_android = sys.platform == "android" +is_android = sys.platform == "android" or "P4A_BOOTSTRAP" in os.environ or "ANDROID_ARGUMENT" in os.environ is_ios = sys.platform == "ios" is_mobile = is_android or is_ios diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000000..2d4ac6aec6 --- /dev/null +++ b/__init__.py @@ -0,0 +1 @@ +# purely for python4android diff --git a/buildozer.spec b/buildozer.spec index 508904a759..a94df88a4f 100644 --- a/buildozer.spec +++ b/buildozer.spec @@ -3,7 +3,7 @@ title = Archipelago package.name = archipelago package.domain = gg.archipelago source.dir = . -source.include_exts = py,png,jpg,kv,atlas,json,yml,txt,lua +source.include_exts = py,png,jpg,kv,atlas,json,yml,txt,lua,ogg,csv,dsv,dat source.include_patterns = data/*, *.kv, *.py source.exclude_dirs = factorio,test,docs,.github,.git, deploy, bin, build, __pycache__ source.exclude_patterns = test,*.pyc,*.pyo,__pycache__,*.egg-info,*.dist-info,docs,examples,build,dist,.git,.github @@ -18,7 +18,7 @@ requirements = python3==3.11.14, hostpython3==3.11.14,pip==24.3.1, kivy==2.3.1,k # Android settings orientation = portrait, landscape, portrait-reverse, landscape-reverse -fullscreen = 0 +fullscreen = 1 android.gradle_properties = org.gradle.jvmargs=-Xmx8192m -XX:MaxMetaspaceSize=512m android.permissions = INTERNET,ACCESS_NETWORK_STATE,WRITE_EXTERNAL_STORAGE,READ_EXTERNAL_STORAGE android.api = 34 diff --git a/kvui.py b/kvui.py index 90f205a938..0e4e1fe136 100644 --- a/kvui.py +++ b/kvui.py @@ -98,6 +98,17 @@ from kivymd.uix.progressindicator import MDLinearProgressIndicator from kivymd.uix.scrollview import MDScrollView from kivymd.uix.tooltip import MDTooltip, MDTooltipPlain +from kivy.effects.scroll import ScrollEffect +from kivy.effects.dampedscroll import DampedScrollEffect + +# Despite the same version on desktop and android, these don't exist on android. I don't know why. +# But making them exist as empty stubs makes the app launch successfully. +for effect_cls in (ScrollEffect, DampedScrollEffect): + if not hasattr(effect_cls, "reset_scale"): + effect_cls.reset_scale = lambda *args: None + if not hasattr(effect_cls, "convert_overscroll"): + effect_cls.convert_overscroll = lambda *args: None + fade_in_animation = Animation(opacity=0, duration=0) + Animation(opacity=1, duration=0.25) from NetUtils import JSONtoTextParser, JSONMessagePart, SlotType, HintStatus @@ -114,6 +125,46 @@ remove_between_brackets = re.compile(r"\[.*?]") class ThemedApp(MDApp): + # Empty properties that only become useful in a Client. + @property + def active_manager(self) -> typing.Optional["GameManager"]: + return None + + @property + def ctx(self) -> typing.Optional[context_type]: + manager = self.active_manager + return manager.ctx if manager else None + + @property + def textinput(self): + manager = self.active_manager + return manager.textinput if manager else None + + @property + def commandprocessor(self): + manager = self.active_manager + return manager.commandprocessor if manager else None + + @property + def last_autofillable_command(self): + manager = self.active_manager + return manager.last_autofillable_command if manager else None + + @property + def screens(self): + manager = self.active_manager + return manager.screens if manager else None + + @property + def tabs(self): + manager = self.active_manager + return manager.tabs if manager else None + + def update_hints(self): + manager = self.active_manager + if manager: + manager.update_hints() + def set_colors(self): text_colors = KivyJSONtoTextParser.TextColors() self.theme_cls.theme_style = text_colors.theme_style @@ -154,7 +205,7 @@ class ImageButton(MDIconButton): class ScrollBox(MDScrollView): - layout: MDBoxLayout = ObjectProperty(None) + layout: MDBoxLayout = ObjectProperty(None, allownone=True) box_height: int = NumericProperty(dp(100)) def __init__(self, *args, **kwargs): @@ -225,7 +276,7 @@ MDButton.on_release = on_release class HoverBehavior(object): """originally from https://stackoverflow.com/a/605348110""" hovered = BooleanProperty(False) - border_point = ObjectProperty(None) + border_point = ObjectProperty(None, allownone=True) def __init__(self, **kwargs): self.register_event_type("on_enter") @@ -252,8 +303,8 @@ class HoverBehavior(object): def on_cursor_leave(self, *args): # if the mouse left the window, it is obviously no longer inside the hover label. - self.hovered = BooleanProperty(False) - self.border_point = ObjectProperty(None) + self.hovered = False + self.border_point = None self.dispatch("on_leave") @@ -327,8 +378,9 @@ class ServerLabel(HoverBehavior, MDTooltip, MDBoxLayout): tooltip_display_delay = 0.1 text: str = StringProperty("Server:") - def __init__(self, *args, **kwargs): + def __init__(self, ctx, *args, **kwargs): super().__init__(*args, **kwargs) + self.ctx = ctx self.add_widget(MDIcon(icon="information", font_size=sp(15))) self.add_widget(TooltipLabel(text=self.text, pos_hint={"center_x": 0.5, "center_y": 0.5}, font_size=sp(15))) @@ -341,10 +393,6 @@ class ServerLabel(HoverBehavior, MDTooltip, MDBoxLayout): def on_leave(self): self.animation_tooltip_dismiss() - @property - def ctx(self) -> context_type: - return MDApp.get_running_app().ctx - def get_text(self): if self.ctx.server: ctx = self.ctx @@ -524,20 +572,22 @@ class MarkupDropdown(MDDropdownMenu): class AutocompleteHintInput(ResizableTextField): min_chars = NumericProperty(3) - def __init__(self, **kwargs): + def __init__(self, ctx, commandprocessor, **kwargs): super().__init__(**kwargs) + self.ctx = ctx + self.commandprocessor = commandprocessor self.dropdown = MarkupDropdown(caller=self, position="bottom", border_margin=dp(2), width=self.width) self.bind(on_text_validate=self.on_message) self.bind(width=lambda instance, x: setattr(self.dropdown, "width", x)) def on_message(self, instance): - MDApp.get_running_app().commandprocessor("!hint "+instance.text) + self.commandprocessor("!hint "+instance.text) def on_text(self, instance, value): if len(value) >= self.min_chars: self.dropdown.items.clear() - ctx: context_type = MDApp.get_running_app().ctx + ctx: context_type = self.ctx if not ctx.game: return item_names = ctx.item_names._game_store[ctx.game].values() @@ -592,7 +642,6 @@ class HintLabel(RecycleDataViewBehavior, MDBoxLayout): self.status_text = "" self.hint = {} - ctx = MDApp.get_running_app().ctx menu_items = [] for status in (HintStatus.HINT_NO_PRIORITY, HintStatus.HINT_PRIORITY, HintStatus.HINT_AVOID): @@ -608,7 +657,7 @@ class HintLabel(RecycleDataViewBehavior, MDBoxLayout): self.dropdown = MDDropdownMenu(caller=self.ids["status"], items=menu_items) def select(instance, data): - ctx.update_hint(self.hint["location"], + self.ctx.update_hint(self.hint["location"], self.hint["finding_player"], data) @@ -618,6 +667,7 @@ class HintLabel(RecycleDataViewBehavior, MDBoxLayout): self.height = max([child.texture_size[1] for child in self.children]) def refresh_view_attrs(self, rv, index, data): + self.ctx = rv.ctx self.index = index self.striped = data.get("striped", False) self.receiving_text = data["receiving"]["text"] @@ -639,8 +689,7 @@ class HintLabel(RecycleDataViewBehavior, MDBoxLayout): if status_label.collide_point(*touch.pos): if self.hint["status"] == HintStatus.HINT_FOUND: return - ctx = MDApp.get_running_app().ctx - if ctx.slot_concerns_self(self.hint["receiving_player"]): # If this player owns this hint + if self.ctx.slot_concerns_self(self.hint["receiving_player"]): # If this player owns this hint # open a dropdown self.dropdown.open() elif self.selected: @@ -754,7 +803,7 @@ class MessageBox(Popup): class MDNavigationItemBase(MDNavigationItem): - text = StringProperty(None) + text = StringProperty(None, allownone=True) class ButtonsPrompt(MDDialog): @@ -842,7 +891,37 @@ class CommandButton(MDButton, MDTooltip): self.animation_tooltip_dismiss() -class GameManager(ThemedApp): +class GameApp(ThemedApp): + @property + def active_manager(self) -> typing.Optional["GameManager"]: + return self.ui_screen + + def __init__(self, ui_screen: typing.Optional["GameManager"] = None): + if ui_screen: + self.title = ui_screen.title + self.icon = ui_screen.icon + self.ui_screen = ui_screen + super().__init__() + + def update_ui(self, ui_screen: "GameManager"): + self.ui_screen = ui_screen + self.title = ui_screen.title + self.icon = ui_screen.icon + + def build(self) -> Layout: + self.ui_screen.build() + return self.ui_screen + + def on_start(self): + super().on_start() + self.ui_screen.on_start() + + def on_stop(self): + self.ui_screen.on_stop() + super().on_stop() + + +class GameManager(MDScreen): logging_pairs = [ ("Client", "Archipelago"), ] @@ -855,7 +934,7 @@ class GameManager(ThemedApp): tabs: MDNavigationBar screens: MDScreenManagerBase - def __init__(self, ctx: context_type): + def __init__(self, ctx: context_type, **kwargs): self.title = self.base_title self.ctx = ctx self.commandprocessor = ctx.command_processor(ctx) @@ -879,7 +958,14 @@ class GameManager(ThemedApp): ctx.on_user_say = intercept_say - super(GameManager, self).__init__() + super().__init__(**kwargs) + + @property + def theme_cls(self): + app = MDApp.get_running_app() + if app: + return app.theme_cls + return None @property def tab_count(self): @@ -889,10 +975,15 @@ class GameManager(ThemedApp): def on_start(self): def on_start(*args): - self.root.md_bg_color = self.theme_cls.backgroundColor - super().on_start() + if hasattr(self, "container"): + self.container.md_bg_color = self.theme_cls.backgroundColor Clock.schedule_once(on_start) + def set_colors(self): + app = MDApp.get_running_app() + if hasattr(app, "set_colors"): + app.set_colors() + def build(self) -> Layout: self.set_colors() self.container = ContainerLayout() @@ -902,7 +993,7 @@ class GameManager(ThemedApp): self.connect_layout = MDBoxLayout(orientation="horizontal", size_hint_y=None, height=dp(40), spacing=5, padding=(5, 10)) # top part - server_label = ServerLabel(width=dp(75)) + server_label = ServerLabel(self.ctx, width=dp(75)) self.connect_layout.add_widget(server_label) self.server_connect_bar = ConnectBarTextInput(text=self.ctx.suggested_address or "archipelago.gg:", pos_hint={"center_x": 0.5, "center_y": 0.5}) @@ -941,8 +1032,8 @@ class GameManager(ThemedApp): if len(self.logging_pairs) > 1: self.add_client_tab(display_name, self.log_panels[display_name]) - self.hint_log = HintLog(self.json_to_kivy_parser) - hint_panel = self.add_client_tab("Hints", HintLayout(self.hint_log)) + self.hint_log = HintLog(self.ctx, self.json_to_kivy_parser) + hint_panel = self.add_client_tab("Hints", HintLayout(self.ctx, self.commandprocessor, self.hint_log)) self.log_panels["Hints"] = hint_panel.content self.main_area_container = MDGridLayout(size_hint_y=1, rows=1) @@ -969,6 +1060,7 @@ class GameManager(ThemedApp): self.commandprocessor("/help") Clock.schedule_interval(self.update_texts, 1 / 30) self.container.add_widget(self.grid) + self.add_widget(self.container) # If the address contains a port, select it; otherwise, select the host. s = self.server_connect_bar.text @@ -1078,6 +1170,18 @@ class GameManager(ThemedApp): self.ctx.exit_event.set() + def stop(self): + """Stops the current "top" App. On Desktop that's a full quit, + on Android it quits to Launcher and calling again on Launcher then exits fully.""" + from kivy.app import App + app = App.get_running_app() + if app: + if hasattr(app, "remove_screen"): + app.remove_screen(self) + self.on_stop() + else: + app.stop() + def on_message(self, textinput: CommandPromptTextInput): try: input_text = textinput.text.strip() @@ -1178,12 +1282,12 @@ class UILog(MDRecycleView): class HintLayout(MDBoxLayout): orientation = "vertical" - def __init__(self, *args, **kwargs): + def __init__(self, ctx, commandprocessor, *args, **kwargs): super().__init__(*args, **kwargs) boxlayout = MDBoxLayout(orientation="horizontal", size_hint_y=None, height=dp(40)) boxlayout.add_widget(MDLabel(text="New Hint:", size_hint_x=None, size_hint_y=None, height=dp(40), width=dp(75), halign="center", valign="center")) - boxlayout.add_widget(AutocompleteHintInput()) + boxlayout.add_widget(AutocompleteHintInput(ctx, commandprocessor)) self.add_widget(boxlayout) def fix_heights(self): @@ -1230,8 +1334,9 @@ class HintLog(MDRecycleView): sort_key: str = "" reversed: bool = True - def __init__(self, parser): + def __init__(self, ctx, parser): super(HintLog, self).__init__() + self.ctx = ctx self.data = [self.header] self.parser = parser @@ -1239,7 +1344,7 @@ class HintLog(MDRecycleView): if not hints: # Fix the scrolling looking visually wrong in some edge cases self.scroll_y = 1.0 data = [] - ctx = MDApp.get_running_app().ctx + ctx = self.ctx for hint in hints: if not hint.get("status"): # Allows connecting to old servers hint["status"] = HintStatus.HINT_FOUND if hint["found"] else HintStatus.HINT_UNSPECIFIED diff --git a/worlds/LauncherComponents.py b/worlds/LauncherComponents.py index 4d401e0840..e8438e3350 100644 --- a/worlds/LauncherComponents.py +++ b/worlds/LauncherComponents.py @@ -98,8 +98,9 @@ def launch_subprocess(func: Callable, name: str | None = None, args: Tuple[str, def launch(func: Callable, name: str | None = None, args: Tuple[str, ...] = ()) -> None: - from Utils import is_kivy_running - if is_kivy_running(): + if is_mobile: + func(*args) + elif is_kivy_running(): launch_subprocess(func, name, args) else: func(*args) diff --git a/worlds/apquest/client/launch.py b/worlds/apquest/client/launch.py index 515cf395be..547c007c02 100644 --- a/worlds/apquest/client/launch.py +++ b/worlds/apquest/client/launch.py @@ -23,5 +23,10 @@ def launch_ap_quest_client(*args: Sequence[str]) -> None: colorama.just_fix_windows_console() - asyncio.run(main(launch_args)) - colorama.deinit() + try: + loop = asyncio.get_running_loop() + except RuntimeError: + asyncio.run(main(launch_args)) + colorama.deinit() + else: + loop.create_task(main(launch_args), name="APQuest Main")