using DodoSSH.Client.Session; using global::Android.Content; using global::Android.OS; using global::Android.Provider; namespace DodoSSH.Client.Android.Platform; /// /// The handful of facts about this phone that the platform-neutral layers need handed to them. /// /// /// /// Named Phone rather than Android because Android.Runtime.AndroidEnvironment already /// exists and this type is not it. /// /// /// Both members below are things docs/android-port.md called out as needing to come from the head rather /// than be branched for inside the core: the profile directory, and a device name that is not /// Environment.MachineName. /// /// internal static class PhoneEnvironment { private static Context? context; /// Captured once, from the launcher activity, before Avalonia starts. /// /// The application context rather than the activity's. An activity is destroyed and recreated /// on configuration changes this head does not declare as handled, and holding one in a static field is /// the textbook Android leak; the application context lives as long as the process, which is exactly the /// lifetime the composition root has. /// public static void Attach(Context activity) => context = activity.ApplicationContext ?? activity; /// Where this phone keeps its profile. /// /// filesDir — per-app, non-roaming, not user-visible, and removed when the app is uninstalled. /// asks for a local, non-roaming directory because two machines sharing one /// cache file corrupts the outbox; on Android that is not merely satisfied but enforced by the platform, /// since no other app can reach this path at all. /// public static ClientPaths Paths => new(Require().FilesDir?.AbsolutePath ?? throw new InvalidOperationException("Android returned no filesDir for this application.")); /// /// What this phone calls itself in connection and keychain log entries. /// /// /// /// Environment.MachineName returns localhost on Android, which would make every log entry /// written from a phone indistinguishable from every other — the finding recorded in /// docs/android-port.md §7. /// /// /// Settings.Global.DeviceName is what the user themselves typed in Settings, so it is the name /// they will recognise in a log written by a different device. It is null on phones that have never had /// one set, and the fallback is the marketing model rather than the board name: a person reading a log /// knows what a Pixel 8 is and does not know what shiba is. /// /// public static string DeviceName { get { var chosen = Settings.Global.GetString(Require().ContentResolver, "device_name"); if (!string.IsNullOrWhiteSpace(chosen)) { return chosen; } var model = Build.Model; return string.IsNullOrWhiteSpace(model) ? "Android phone" : model; } } /// Where this phone keeps copies that only have to survive the thing that made them. /// /// cacheDir — per-app like , and unlike it, reclaimable: Android deletes from /// here when the device runs short of storage. That is the right trade for the upload staging in /// , whose files are worthless the moment their transfer finishes, and it /// is why the profile is not kept here. The cost is stated rather than hidden: a file reclaimed under /// storage pressure while its upload is still running fails that upload. /// public static string CacheDirectory => Require().CacheDir?.AbsolutePath ?? throw new InvalidOperationException("Android returned no cacheDir for this application."); /// /// The activity currently on screen, or null while the app is backgrounded. /// /// /// Set and cleared by MainActivity. Separate from because the two have /// genuinely different lifetimes and different uses: the application context is what outlives /// everything and is right for starting services, and this is what a window flag or a system dialogue /// needs — neither substitutes for the other. /// public static global::Android.App.Activity? CurrentActivity { get; set; } /// Raised each time the launcher activity comes back to the foreground. /// /// /// ◆ Exists because the software keyboard can go away without Avalonia being told. /// IInputPane.StateChanged is the only signal PhoneShell has for the keyboard opening and /// closing, and it is not raised when Android dismisses the keyboard on the way out of the app — the /// activity is being paused, and the pane is simply gone by the time anything could have reported it. /// So the inset that holds the interface clear of the keyboard was still applied on the way back in, /// and the whole phone drew an entire keyboard's height above the bottom of a screen with no keyboard /// on it. Reproduced every time: open the keyboard anywhere, press Home, return — 838 of 2340 pixels /// dead, and staying that way until the keyboard was opened and closed again. /// /// /// An event here rather than a call into the view, because the view is Avalonia's and this is Android's: /// MainActivity already owns OnResume and already writes /// from it, and a static event is the same shape of hand-off one step further. Anything else that needs /// to re-read platform state on the way back in can subscribe without MainActivity learning what /// it is. /// /// /// Subscribers are responsible for unsubscribing — this is a static event and holds whatever it is /// given for the life of the process. PhoneShell attaches and detaches with its visual tree. /// /// public static event EventHandler? Resumed; /// Announces that the launcher activity is foreground again. internal static void RaiseResumed() => Resumed?.Invoke(null, EventArgs.Empty); /// The application context, once has run. public static Context Require() => context ?? throw new InvalidOperationException( "PhoneEnvironment was read before MainActivity attached it."); }