Android: working clients

This commit is contained in:
Berserker
2026-03-31 20:03:44 +02:00
parent 07e6be7c93
commit b4e422db0b
9 changed files with 209 additions and 55 deletions
+23 -6
View File
@@ -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__':
+37 -12
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
# purely for python4android
+2 -2
View File
@@ -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
+134 -29
View File
@@ -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
+3 -2
View File
@@ -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)
+7 -2
View File
@@ -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")