fix(ios): pause/resume services with the app lifecycle and re-point media URLs on rebind (#21687)

* fix(media): re-point cached media URLs when the media server restarts (#47)

iOS suspends the app and kills status-go's local media server; on resume
it rebinds on a NEW ephemeral port and emits mediaserver.started, which
the Nim side never handled — every cached https://localhost:<oldport>/
image URL (chat images, avatars, stickers, link previews, community
icons) went permanently stale.

Key decisions:
- New MediaServerStartedSignal decoded in signals_manager (the enum
  member already existed, so the cheap-triage scan needs no change);
  null-event envelopes decode to port 0 and every rewrite helper treats
  port <= 0 as a no-op.
- One shared helper, withMediaServerPort (app_service/common/
  media_server_url.nim): rewrites the port ONLY for http(s)://
  localhost|127.0.0.1|0.0.0.0:<port> URLs, splicing around the authority
  so path/query stay byte-identical; everything else (remote URLs, data
  URIs, qrc/file paths, port-less URLs) passes through untouched, so
  callers apply it blindly. Desktop stays a no-op: the signal never
  fires there, and an unchanged port rewrites nothing.
- Subscribers rewrite in place + notify: message models (chat + pinned)
  re-emit image-carrying roles via dataChanged; link-preview thumbnails
  are QObjects and emit urlChanged directly; section model refreshes
  community image/banner/icon; contacts service rewrites its
  ContactDetails cache and re-emits SIGNAL_CONTACT_UPDATED per changed
  contact, plus the user-profile singleton's own avatar (its setters
  already no-op on equal values).
- Dropped the contacts service's imageServerUrl field: it was write-only
  dead code (fetched once, never read).

Files: src/app/core/signals/{signals_manager,types}.nim,
src/app/core/signals/remote_signals/mediaserver.nim (new),
src/app_service/common/media_server_url.nim (new),
src/app_service/service/contacts/service.nim,
src/app_service/service/message/dto/{link_preview,link_preview_thumbnail}.nim,
src/app/modules/shared_models/{message_item,message_model,link_preview_model,section_model}.nim,
chat_section chat_content + messages + main module/controller/io_interface
wiring, test/nim/{media_server_url_test (new),message_model_test,
signals_manager_test}.nim

Verified: media_server_url_test 9/9 OK, signals_manager_test 6/6 OK
(incl. 2 new mediaserver.started tests) via make nim-test-run USE_SYSTEM_NIM=1.
message_model_test does NOT link in this arm64 container — pre-existing
nimqml/LTO link failure, reproduced identically on clean HEAD with a
fresh nimcache; the 3 new model tests compile but need CI/another host
to run. Device repro (background 10+ min, resume, images reload) remains
for the human pass.

* fix(build): normalize two 'import Nimqml' casings to the module's real name

discord_message_item and message_transaction_parameters_item imported
Nimqml (capital N) while the vendored module file is nimqml.nim. On a
case-insensitive checkout (Docker-on-Mac bind mount) Nim treats the two
spellings as distinct modules whose nimcache artifacts collide on one
file, so any test pulling in these items (e.g. message_model_test) fails
to link with undefined nimqml symbols. Found while verifying #47.

* refactor(media): deduplicate the media-URL refresh template and drop dead code

Review follow-up to ca5e5c92c, no behavior change:
- Extract the thrice-duplicated compare-rewrite-flag template into
  refreshMediaServerUrl in media_server_url.nim; message_item,
  section_model and contacts/service now share one definition.
- Drop the why-comment copy-pasted verbatim into three controllers;
  the explanation lives on MediaServerStartedSignal and the helper module.
- Remove backend getImageServerURL, dead since its only caller
  (setImageServerUrl) was deleted on this branch.
- Whitespace: trailing newline in signals/types.nim, blank line before
  the appended suite in message_model_test.nim.

Verified: media_server_url_test, message_model_test and
signals_manager_test all pass (nim-test-run recipe); app wiring
compile-checked via app/modules/main/module + contacts service +
section_model with --compileOnly.

* fix(ios): drive PauseServices/ResumeServices from the app lifecycle (#51)

On iOS status-go runs in-process and nothing drove the pausable-services
lifecycle: services never paused on backgrounding (battery cost) and never
resumed on foregrounding, so the media server's listening socket iOS kills
during suspension stayed dead and every cached localhost media URL failed
until app restart (device-confirmed: post-resume image loads get Connection
refused while in-process RPC still works). Resuming now re-runs the full
recovery chain: ResumeServices -> ServiceRegistry -> mediaserver
ToForeground() rebind -> mediaserver.started -> #47/#49's URL refresh.

Key decisions:
- Lifecycle source is StatusQ's UrlSchemeEvent applicationStateChanged
  watcher (already the iOS foreground seam): new appBackgrounded signal
  emitted ONLY on Qt::ApplicationSuspended — Inactive dips from share
  sheets/system alerts never pause; appForegrounded fires on every return
  to Active, so ServicesPauseBridge latches (`paused`) and resumes only
  when it actually paused. Connections are synchronous (AutoConnection,
  same thread): iOS freezes the process right after the state change, a
  queued pause slot might never run.
- Bridge wired only under `when defined(ios)`: Android's service process
  already drives pause/resume from binder visibility (UI process must not
  double-drive), desktop is never suspended.
- Same contract as StatusGoService.java: fetch the service list from
  PausableServices() at each transition (late-registered services picked
  up; empty list — node not running, e.g. login screen — drives nothing),
  then PauseServices/ResumeServices with the JSON name array. The three
  libstatus C exports are bound in new backend/pausable_services.nim
  (vendor/nim-status-go doesn't cover them); errors logged, not fatal.
- Backend calls injected into the bridge as a seam
  (PausableServicesCalls), so the Nim test drives the real StatusQ
  signal emitters against recorders.

Files: src/app/core/services_pause_bridge.nim (new),
src/backend/pausable_services.nim (new), src/nim_status_client.nim,
src/statusq_bridge.nim, ui/StatusQ/{include/StatusQ/urlschemeevent.h,
src/{urlschemeevent,externc}.cpp}, Makefile (test target),
test/nim/services_pause_bridge_test.nim (new)

Verified: services_pause_bridge_test 15/15 OK; full tests-nim-linux 464
OK / 0 failed (chat_section_model_test fails to compile identically on
the clean base — pre-existing, unrelated); nim_status_client Linux path
compile-checked (--compileOnly, exit 0); StatusQ recompiles clean;
libstatus.so exports PausableServices/PauseServices/ResumeServices
(linked by the test). Device criteria (iPhone: photo -> background 10+
min -> resume -> images recover; Android S21: exactly one service-driven
pause/resume, no duplicates) remain for the human pass — iOS paths can't
run in this container.

* fix(tests): reset the fetch counter per case in services_pause_bridge

unittest setup vars are module-scope globals; the int's constant
initializer runs once, so fetches leaked across cases and the two
absolute-count assertions saw the running total.

* fix: Potential fix for pull request finding

* fix(statusq): handle the full application-state enum in watchApplicationState

Inactive is an expected transient dip and stays a no-op; anything else
unhandled now logs a qWarning.

* fix(tests): empty pausable set no longer latches paused, so foregrounding skips the fetch
This commit is contained in:
Alex Jbanca
2026-08-05 14:18:26 +03:00
committed by GitHub
parent f915b40539
commit 4483d7eb2d
35 changed files with 745 additions and 18 deletions
+1
View File
@@ -20,6 +20,7 @@ NIM_TESTS_LINK_STATUSQ := \
send_handler_adaptors_bench \
send_handler_lookup_bench \
send_modal_instantiation_bench \
services_pause_bridge_test \
signal_handler_test \
swap_key_harvest_bench \
swap_modal_instantiation_bench \
+83
View File
@@ -0,0 +1,83 @@
## App-lifecycle → status-go pausable-services bridge (iOS).
##
## On iOS status-go runs in-process and nothing drove PauseServices/
## ResumeServices: services never paused on backgrounding (battery cost) and —
## worse — never resumed on foregrounding, so the media server's listening
## socket iOS kills during suspension stayed dead and every cached
## https://localhost:<port>/ media URL failed until app restart. Resuming
## re-runs the whole recovery chain: ResumeServices →
## ServiceRegistry.ResumeMultiple → httpServer.ToForeground() → media server
## rebinds → mediaserver.started signal → the media-URL refresh.
##
## Wired only on iOS: the Android service process already drives pause/resume
## from binder UI-visibility changes (StatusGoService.java) — the UI process
## must not double-drive it — and desktop apps are never suspended.
##
## Flap handling: appBackgrounded is emitted only for Qt::ApplicationSuspended
## (a real backgrounding), never for the Inactive dips share sheets and system
## alerts cause; appForegrounded fires on EVERY return to Active, so resume
## runs only when a pause was actually driven (`paused` latch).
##
## Like the Android service, the service list is fetched from
## PausableServices() at each transition, so services registered in status-go
## later are picked up without client changes; an empty list (node not
## running, e.g. backgrounded on the login screen) drives nothing.
import nimqml, chronicles
import ./custom_urls/url_scheme_event
logScope:
topics = "services-pause-bridge"
type PausableServicesCalls* = object
## Seam over the backend's pausable-services mobile API
## (backend/pausable_services), injectable for tests.
pausableServiceNames*: proc(): seq[string]
pauseServices*: proc(names: seq[string])
resumeServices*: proc(names: seq[string])
QtObject:
type ServicesPauseBridge* = ref object of QObject
calls: PausableServicesCalls
paused: bool
proc delete*(self: ServicesPauseBridge) =
self.QObject.delete
proc onAppBackgrounded*(self: ServicesPauseBridge) {.slot.} =
if self.paused:
return
let names = self.calls.pausableServiceNames()
if names.len == 0:
return
self.paused = true
info "app backgrounded, pausing services", names
self.calls.pauseServices(names)
proc onAppForegrounded*(self: ServicesPauseBridge) {.slot.} =
if not self.paused:
return
self.paused = false
let names = self.calls.pausableServiceNames()
if names.len == 0:
return
info "app foregrounded, resuming services", names
self.calls.resumeServices(names)
proc setup(self: ServicesPauseBridge, urlSchemeEvent: UrlSchemeEvent) =
self.QObject.setup
# Both objects live on the main thread, so AutoConnection resolves to a
# direct (synchronous) call — required on backgrounding: iOS freezes the
# process soon after the state change is delivered, and a queued slot
# might never run before suspension, leaving services un-paused.
discard QObject.connect(urlSchemeEvent, SIGNAL("appBackgrounded()"),
self, SLOT("onAppBackgrounded()"), ConnectionType.AutoConnection)
discard QObject.connect(urlSchemeEvent, SIGNAL("appForegrounded()"),
self, SLOT("onAppForegrounded()"), ConnectionType.AutoConnection)
proc newServicesPauseBridge*(urlSchemeEvent: UrlSchemeEvent,
calls: PausableServicesCalls): ServicesPauseBridge =
new(result)
result.calls = calls
result.paused = false
result.setup(urlSchemeEvent)
@@ -0,0 +1,16 @@
import json
import base
import signal_type
type MediaServerStartedSignal* = ref object of Signal
## Emitted by status-go when the local media server (re)binds a port.
## On mobile the process suspension kills the listener and the restart
## picks a new ephemeral port, so every cached media URL goes stale.
port*: int
proc fromEvent*(T: type MediaServerStartedSignal, jsonSignal: JsonNode): MediaServerStartedSignal =
result = MediaServerStartedSignal()
result.signalType = SignalType.MediaServerStarted
if jsonSignal["event"].kind != JNull:
result.port = jsonSignal["event"]{"port"}.getInt()
+1
View File
@@ -106,6 +106,7 @@ QtObject:
of SignalType.HistoryRequestStarted: HistoryRequestStartedSignal.fromEvent(jsonSignal)
of SignalType.MailserverAvailable: MailserverAvailableSignal.fromEvent(jsonSignal)
of SignalType.MailserverNotWorking: MailserverNotWorkingSignal.fromEvent(jsonSignal)
of SignalType.MediaServerStarted: MediaServerStartedSignal.fromEvent(jsonSignal)
of SignalType.HistoryArchivesProtocolEnabled: HistoryArchivesSignal.historyArchivesProtocolEnabledFromEvent(jsonSignal)
of SignalType.HistoryArchivesProtocolDisabled: HistoryArchivesSignal.historyArchivesProtocolDisabledFromEvent(jsonSignal)
of SignalType.CreatingHistoryArchives: HistoryArchivesSignal.creatingHistoryArchivesFromEvent(jsonSignal)
+3 -3
View File
@@ -1,9 +1,9 @@
{.used.}
import ./remote_signals/[base, community, connection_status_change, connector, discovery_summary, envelope, expired, mailserver, messages,
import ./remote_signals/[base, community, connection_status_change, connector, discovery_summary, envelope, expired, mailserver, mediaserver, messages,
signal_type, wallet, whisper_filter, update_available, status_updates, backed_up_profile,
backed_up_settings, pairing, node, networks, back_up_completed]
export base, community, connection_status_change, connector, discovery_summary, envelope, expired, mailserver, messages,
export base, community, connection_status_change, connector, discovery_summary, envelope, expired, mailserver, mediaserver, messages,
signal_type, wallet, whisper_filter, update_available, status_updates, backed_up_profile,
backed_up_settings, back_up_completed, pairing, node, networks
backed_up_settings, back_up_completed, pairing, node, networks
@@ -56,6 +56,10 @@ proc delete*(self: Controller) =
self.events.disconnect()
proc init*(self: Controller) =
self.events.on(SignalType.MediaServerStarted.event) do(e: Args):
let args = MediaServerStartedSignal(e)
self.delegate.onMediaServerStarted(args.port)
self.events.on(SIGNAL_PINNED_MESSAGES_LOADED) do(e:Args):
let args = PinnedMessagesLoadedArgs(e)
if(self.chatId != args.chatId or args.pinnedMessages.len == 0):
@@ -30,6 +30,9 @@ method onNotificationsUpdated*(self: AccessInterface, hasUnreadMessages: bool, n
method newPinnedMessagesLoaded*(self: AccessInterface, pinnedMessages: seq[PinnedMessageDto], reactions: seq[ReactionDto]) {.base.} =
raise newException(ValueError, "No implementation available")
method onMediaServerStarted*(self: AccessInterface, port: int) {.base.} =
raise newException(ValueError, "No implementation available")
method onUnpinMessage*(self: AccessInterface, messageId: string) {.base.} =
raise newException(ValueError, "No implementation available")
@@ -10,6 +10,7 @@ import ../../../../../../app_service/service/mailservers/service as mailservers_
import ../../../../../../app_service/service/wallet_account/service as wallet_account_service
import ../../../../../../app_service/service/shared_urls/service as shared_urls_service
import ../../../../../core/eventemitter
import ../../../../../core/signals/types as signal_types
import ../../../../../core/unique_event_emitter
logScope:
@@ -53,6 +54,10 @@ proc delete*(self: Controller) =
self.events.disconnect()
proc init*(self: Controller) =
self.events.on(SignalType.MediaServerStarted.event) do(e: Args):
let args = MediaServerStartedSignal(e)
self.delegate.onMediaServerStarted(args.port)
self.events.on(SIGNAL_MESSAGES_LOADED) do(e:Args):
let args = MessagesLoadedArgs(e)
if self.chatId != args.chatId:
@@ -75,6 +75,9 @@ method onMessageDelivered*(self: AccessInterface, messageId: string) {.base.} =
method updateContactDetails*(self: AccessInterface, contactId: string) {.base.} =
raise newException(ValueError, "No implementation available")
method onMediaServerStarted*(self: AccessInterface, port: int) {.base.} =
raise newException(ValueError, "No implementation available")
method onMessageEdited*(self: AccessInterface, message: MessageDto) {.base.} =
raise newException(ValueError, "No implementation available")
@@ -453,6 +453,9 @@ method updateContactDetails*(self: Module, contactId: string) =
item.linkPreviewModel.setContactInfo(updatedContact)
method onMediaServerStarted*(self: Module, port: int) =
self.view.model().updateMediaServerPort(port)
method deleteMessage*(self: Module, messageId: string) =
self.controller.deleteMessage(messageId)
@@ -213,6 +213,9 @@ method newPinnedMessagesLoaded*(self: Module, pinnedMessages: seq[PinnedMessageD
return
self.view.pinnedModel().insertItemsBasedOnClock(viewItems)
method onMediaServerStarted*(self: Module, port: int) =
self.view.pinnedModel().updateMediaServerPort(port)
method unpinMessage*(self: Module, messageId: string) =
self.controller.unpinMessage(messageId)
+4
View File
@@ -104,6 +104,10 @@ proc delete*(self: Controller) =
discard
proc init*(self: Controller) =
self.events.on(SignalType.MediaServerStarted.event) do(e: Args):
let args = MediaServerStartedSignal(e)
self.delegate.onMediaServerStarted(args.port)
self.events.on(SIGNAL_ACTIVE_CHATS_LOADED) do(e:Args):
self.delegate.onChatsLoaded(
self.events,
+3
View File
@@ -99,6 +99,9 @@ method onChatsLoaded*(
) {.base.} =
raise newException(ValueError, "No implementation available")
method onMediaServerStarted*(self: AccessInterface, port: int) {.base.} =
raise newException(ValueError, "No implementation available")
method onCommunityDataLoaded*(
self: AccessInterface,
events: EventEmitter,
+3
View File
@@ -1018,6 +1018,9 @@ method onChatsLoaded*[T](
self.pendingProfileMigrationCheck = false
self.checkAndPerformProfileMigrationIfNeeded()
method onMediaServerStarted*[T](self: Module[T], port: int) =
self.view.model().updateMediaServerPort(port)
method onCommunityDataLoaded*[T](
self: Module[T],
events: EventEmitter,
@@ -1,4 +1,4 @@
import Nimqml, json, std/strformat
import nimqml, json, std/strformat
import ../../../app_service/service/message/dto/message
@@ -334,6 +334,13 @@ QtObject:
updateRoleWithValue(isLocalData, true)
updateRoleWithValue(loadingLocalData, false)
proc updateMediaServerPort*(self: Model, port: int) =
# Thumbnails are QObjects with notify signals, so QML picks up the URL
# change without a dataChanged emission.
for item in self.items:
if item.linkPreview != nil:
item.linkPreview.updateMediaServerPort(port)
proc setContactInfo*(self: Model, contactDetails: ContactDetails) =
for row, item in self.items:
if item.linkPreview.setContactInfo(contactDetails):
@@ -1,5 +1,6 @@
import json, std/strformat, strutils
import app/global/global_singleton
import ../../../app_service/common/media_server_url
import ../../../app_service/common/types
import ../../../app_service/service/contacts/dto/contact_details
import ../../../app_service/service/message/dto/message
@@ -698,3 +699,26 @@ proc quotedMessageAlbumImagesCount*(self: Item): int {.inline.} =
proc `quotedMessageAlbumImagesCount=`*(self: Item, value: int) {.inline.} =
self.quotedMessageAlbumImagesCount = value
proc updateMediaServerPort*(self: Item, port: int): bool =
## Re-points every cached media-server URL of this message at the freshly
## bound port (the media server restarts on a new ephemeral port when the
## mobile OS suspends and resumes the app). Non-media URLs are untouched.
## Returns true when at least one field changed; link-preview thumbnails
## notify QML directly and do not affect the return value.
refreshMediaServerUrl(self.senderIcon, port, result)
refreshMediaServerUrl(self.messageImage, port, result)
refreshMediaServerUrl(self.sticker, port, result)
refreshMediaServerUrl(self.quotedMessageAuthorAvatar, port, result)
refreshMediaServerUrl(self.quotedMessageAuthorDetails.dto.image.thumbnail, port, result)
refreshMediaServerUrl(self.quotedMessageAuthorDetails.dto.image.large, port, result)
refreshMediaServerUrl(self.deletedByContactDetails.dto.image.thumbnail, port, result)
refreshMediaServerUrl(self.deletedByContactDetails.dto.image.large, port, result)
for i in 0 ..< self.albumMessageImages.len:
refreshMediaServerUrl(self.albumMessageImages[i], port, result)
for i in 0 ..< self.quotedMessageAlbumMessageImages.len:
refreshMediaServerUrl(self.quotedMessageAlbumMessageImages[i], port, result)
for i in 0 ..< self.messageAttachments.len:
refreshMediaServerUrl(self.messageAttachments[i], port, result)
if self.linkPreviewModel != nil:
self.linkPreviewModel.updateMediaServerPort(port)
@@ -642,6 +642,26 @@ QtObject:
for i in 0 ..< self.items.len:
yield self.items[i]
proc updateMediaServerPort*(self: Model, port: int) =
## Re-points every cached media-server URL at the freshly bound port
## after a media-server restart (mobile suspend/resume). Rows whose
## URLs changed re-emit their image-carrying roles so delegates reload.
for i in 0 ..< self.items.len:
if not self.items[i].updateMediaServerPort(port):
continue
let index = self.createIndex(i, 0, nil)
defer: index.delete
self.dataChanged(index, index, @[
ModelRole.SenderIcon.int,
ModelRole.MessageImage.int,
ModelRole.Sticker.int,
ModelRole.DeletedByContactIcon.int,
ModelRole.QuotedMessageAuthorThumbnailImage.int,
ModelRole.QuotedMessageAlbumMessageImages.int,
ModelRole.AlbumMessageImages.int,
ModelRole.MessageAttachments.int,
])
iterator modelContactUpdateIterator*(self: Model, contactId: string): Item =
for i in 0 ..< self.items.len:
let senderMatches = self.items[i].senderId == contactId
@@ -1,4 +1,4 @@
import Nimqml, json, std/strformat
import nimqml, json, std/strformat
QtObject:
type
@@ -5,6 +5,7 @@ import json
import section_item, member_model, member_item
import ../main/communities/tokens/models/[token_item, token_model]
import model_utils
import ../../../app_service/common/media_server_url
import ../../../app_service/common/types
import app/global/global_singleton
@@ -277,6 +278,23 @@ QtObject:
updateItemRolesAndNotify self.getItemIndex(id):
updateRole(muted)
proc updateMediaServerPort*(self: SectionModel, port: int) =
## Re-points cached media-server URLs (community images/banners/icons)
## at the freshly bound port after a media-server restart.
for i in 0 ..< self.items.len:
var changed = false
refreshMediaServerUrl(self.items[i].image, port, changed)
refreshMediaServerUrl(self.items[i].bannerImageData, port, changed)
refreshMediaServerUrl(self.items[i].icon, port, changed)
if changed:
let dataIndex = self.createIndex(i, 0, nil)
defer: dataIndex.delete
self.dataChanged(dataIndex, dataIndex, @[
ModelRole.Image.int,
ModelRole.BannerImageData.int,
ModelRole.Icon.int,
])
proc editItem*(self: SectionModel, item: SectionItem) =
updateItemRolesAndNotify self.getItemIndex(item.id):
updateRolesFromItem(item,
@@ -0,0 +1,41 @@
import std/[strutils, uri]
# status-go serves chat/profile/community media over a local HTTP(S) server
# and embeds its ephemeral port in every URL it marshals. When that server
# restarts on a new port (mobile suspend/resume) it emits the
# `mediaserver.started` signal; subscribers use this helper to re-point
# cached URLs at the new port.
const MEDIA_SERVER_HOSTS = ["localhost", "127.0.0.1", "0.0.0.0"]
proc withMediaServerPort*(url: string, port: int): string =
## Rewrites the port of a loopback media-server URL. Anything else —
## empty strings, data URIs, remote URLs, port-less or already-current
## URLs — is returned unchanged, so callers can apply it blindly to any
## cached URL field.
if url.len == 0 or port <= 0:
return url
let u = parseUri(url)
if u.scheme != "http" and u.scheme != "https":
return url
if u.hostname notin MEDIA_SERVER_HOSTS:
return url
if u.port.len == 0 or u.port == $port:
return url
# Splice around the authority instead of re-serializing the parsed URI,
# so the rest of the URL stays byte-identical (round-tripping through
# parseUri can re-encode path/query characters).
let hostPort = u.hostname & ":" & u.port
let idx = url.find(hostPort)
if idx < 0:
return url
url[0 ..< idx] & u.hostname & ":" & $port & url[idx + hostPort.len .. ^1]
template refreshMediaServerUrl*(field, port, changed: untyped) =
## Re-points `field` in place via `withMediaServerPort` and sets `changed`
## to true when the URL actually changed. `changed` is left untouched
## otherwise, so one flag can accumulate over several fields.
let updated = withMediaServerPort(field, port)
if updated != field:
field = updated
changed = true
+27 -9
View File
@@ -8,6 +8,7 @@ import app/core/tasks/[qt, threadpool]
import ../../common/types as common_types
import ../../common/conversion as service_conversion
import ../../common/activity_center
import ../../common/media_server_url
import ../settings/service as settings_service
import ../network/service as network_service
@@ -111,7 +112,6 @@ QtObject:
contactsStatus: Table[string, StatusUpdateDto] # [contact_id, StatusUpdateDto]
events: EventEmitter
closingApp: bool
imageServerUrl: string
# Forward declaration
proc getContactById*(self: Service, id: string): ContactsDto
@@ -119,6 +119,7 @@ QtObject:
proc requestContactInfo*(self: Service, pubkey: string)
proc constructContactDetails(self: Service, contactDto: ContactsDto, isCurrentUser: bool = false): ContactDetails
proc parseContactsResponse*(self: Service, contacts: JsonNode, fromBackup: bool = false)
proc onMediaServerStarted(self: Service, port: int)
proc delete*(self: Service)
proc newService*(
@@ -268,14 +269,31 @@ QtObject:
if receivedData.statusUpdates.len > 0:
self.updateAndEmitStatuses(receivedData.statusUpdates)
proc setImageServerUrl(self: Service) =
try:
let response = status_contacts.getImageServerURL()
self.imageServerUrl = response.result.getStr()
except Exception as e:
let errDesription = e.msg
error "error: ", errDesription
return
self.events.on(SignalType.MediaServerStarted.event) do(e: Args):
let args = MediaServerStartedSignal(e)
self.onMediaServerStarted(args.port)
proc onMediaServerStarted(self: Service, port: int) =
## The media server rebinds to a new ephemeral port when the mobile OS
## suspends/resumes the app. Re-point every cached contact image URL and
## notify consumers so avatars keep loading (issue #47).
var changedContactIds: seq[string]
for contactId, details in self.contacts.mpairs:
var changed = false
refreshMediaServerUrl(details.icon, port, changed)
refreshMediaServerUrl(details.dto.image.thumbnail, port, changed)
refreshMediaServerUrl(details.dto.image.large, port, changed)
if changed:
changedContactIds.add(contactId)
for contactId in changedContactIds:
self.events.emit(SIGNAL_CONTACT_UPDATED, ContactArgs(contactId: contactId))
# The logged-in user's own avatar is cached in the user profile
# singleton; its setters emit imageChanged only when the value differs.
let userProfile = singletonInstance.userProfile
userProfile.setThumbnailImage(withMediaServerPort(userProfile.getThumbnailImage(), port))
userProfile.setLargeImage(withMediaServerPort(userProfile.getLargeImage(), port))
proc onLoggedInUserNameChange*(self: Service) {.slot.} =
let data = Args()
@@ -1,6 +1,7 @@
import json, std/strformat, tables
import ./status_link_preview, ./standard_link_preview
import ./status_contact_link_preview, ./status_community_link_preview, ./status_community_channel_link_preview
import ./link_preview_thumbnail
import ../../contacts/dto/contact_details
include ../../../common/json_utils
@@ -130,6 +131,25 @@ proc getCommunityId*(self: LinkPreview): string =
return self.statusCommunityChannelPreview.getCommunity().getCommunityId()
return ""
proc updateMediaServerPort*(self: LinkPreview, port: int) =
## Media-server-served thumbnails embed the server's ephemeral port;
## re-point them after a media-server restart. Safe on any preview:
## only loopback media URLs are rewritten.
template refresh(thumbnail: LinkPreviewThumbnail) =
if thumbnail != nil:
thumbnail.updateMediaServerPort(port)
if self.standardPreview != nil:
refresh(self.standardPreview.getThumbnail())
if self.statusContactPreview != nil:
refresh(self.statusContactPreview.getIcon())
if self.statusCommunityPreview != nil:
refresh(self.statusCommunityPreview.getIcon())
refresh(self.statusCommunityPreview.getBanner())
if self.statusCommunityChannelPreview != nil and self.statusCommunityChannelPreview.getCommunity() != nil:
refresh(self.statusCommunityChannelPreview.getCommunity().getIcon())
refresh(self.statusCommunityChannelPreview.getCommunity().getBanner())
proc setContactInfo*(self: LinkPreview, contactDetails: ContactDetails): bool =
if self.previewType == PreviewType.StatusContactPreview:
return self.statusContactPreview.setContactInfo(contactDetails)
@@ -1,4 +1,5 @@
import json, std/strformat, nimqml, chronicles
import ../../../common/media_server_url
include ../../../common/json_utils
QtObject:
@@ -82,6 +83,14 @@ QtObject:
"dataUri": self.dataUri
}
proc updateMediaServerPort*(self: LinkPreviewThumbnail, port: int) =
## Re-points a media-server-served thumbnail at the freshly bound port
## (no-op for remote/data-URI thumbnails).
let updated = withMediaServerPort(self.url, port)
if updated != self.url:
self.url = updated
self.urlChanged()
proc update*(self: LinkPreviewThumbnail, width: int, height: int, url: string, dataUri: string) =
if self.width != width:
self.width = width
-4
View File
@@ -65,10 +65,6 @@ proc sendContactUpdate*(publicKey, ensName, thumbnail: string): RpcResponse[Json
let payload = %* [publicKey, ensName, thumbnail]
result = callPrivateRPC("sendContactUpdate".prefix, payload)
proc getImageServerURL*(): RpcResponse[JsonNode] =
let payload = %* []
result = callPrivateRPC("imageServerURL".prefix, payload)
proc markAsTrusted*(pubkey: string): RpcResponse[JsonNode] =
let payload = %* [pubkey]
result = callPrivateRPC("markAsTrusted".prefix, payload)
+55
View File
@@ -0,0 +1,55 @@
## Pausable-services mobile API (PausableServices/PauseServices/ResumeServices).
## These are libstatus C exports generated by status-go's
## tools/generate-cbindings from the public functions in mobile/status.go;
## vendor/nim-status-go does not bind them yet, so they are declared here with
## the same importc pattern as status_go/impl.nim.
import json, chronicles
logScope:
topics = "rpc-pausable-services"
proc statusGoPausableServices(): cstring {.importc: "PausableServices".}
proc statusGoPauseServices(namesJson: cstring): cstring {.importc: "PauseServices".}
proc statusGoResumeServices(namesJson: cstring): cstring {.importc: "ResumeServices".}
proc parsePausableServiceNames*(response: string): seq[string] =
## Service names out of PausableServices()' response — a JSON array of
## {name, state} objects. Empty for anything else: "null" when the node is
## not running, error objects, malformed JSON (mirrors the Android service's
## fetchPausableServiceNames guard).
if response.len == 0:
return @[]
try:
let parsed = parseJson(response)
if parsed.kind != JArray:
return @[]
for item in parsed.getElems():
let name = item{"name"}.getStr()
if name.len > 0:
result.add(name)
except CatchableError:
result = @[]
proc apiResponseError*(response: string): string =
## The error field of a mobile-API {"error": ...} response; "" for success
## or an unparsable response.
try:
result = parseJson(response){"error"}.getStr()
except CatchableError:
result = ""
proc pausableServiceNames*(): seq[string] =
## Names of all pausable services currently registered in status-go;
## empty when the node is not running.
parsePausableServiceNames($statusGoPausableServices())
proc pauseServices*(names: seq[string]) =
let err = apiResponseError($statusGoPauseServices(cstring($(%names))))
if err.len > 0:
warn "PauseServices returned an error", err
proc resumeServices*(names: seq[string]) =
let err = apiResponseError($statusGoResumeServices(cstring($(%names))))
if err.len > 0:
warn "ResumeServices returned an error", err
+19
View File
@@ -13,6 +13,10 @@ import app/core/signal_handler
import app/core/custom_urls/url_scheme_event
import app/global/single_instance
when defined(ios):
import app/core/services_pause_bridge
import backend/pausable_services as backend_pausable_services
import seaqt/qguiapplication
import seaqt/qsslconfiguration
import seaqt/qsslcertificate
@@ -292,6 +296,19 @@ proc mainProc() =
# init url manager before app controller
statusFoundation.initUrlSchemeManager(urlSchemeEvent, singleInstance, openUri)
when defined(ios):
# iOS runs status-go in-process, so the app lifecycle must drive the
# pausable services itself: backgrounding pauses them, foregrounding
# resumes them — which rebinds the media server iOS kills during
# suspension and re-emits mediaserver.started (the media-URL refresh).
# Android's service process drives this from binder visibility instead;
# desktop apps are never suspended.
let servicesPauseBridge = newServicesPauseBridge(urlSchemeEvent,
PausableServicesCalls(
pausableServiceNames: backend_pausable_services.pausableServiceNames,
pauseServices: backend_pausable_services.pauseServices,
resumeServices: backend_pausable_services.resumeServices))
let appController = newAppController(statusFoundation)
let isProductionQVariant = newQVariant(if defined(production): true else: false)
@@ -346,6 +363,8 @@ proc mainProc() =
signalsManagerQVariant.delete()
appController.delete()
statusFoundation.delete()
when defined(ios):
servicesPauseBridge.delete()
singleInstance.delete()
app.delete()
+2
View File
@@ -19,6 +19,8 @@ proc statusq_urlscheme_create*(): pointer {.cdecl, importc.}
proc statusq_urlscheme_set_instance*(obj: pointer) {.cdecl, importc.}
proc statusq_urlscheme_install_event_filter*(obj: pointer) {.cdecl, importc.}
proc statusq_urlscheme_emit_deeplink*(obj: pointer, url: cstring) {.cdecl, importc.}
proc statusq_urlscheme_emit_appforegrounded*(obj: pointer) {.cdecl, importc.}
proc statusq_urlscheme_emit_appbackgrounded*(obj: pointer) {.cdecl, importc.}
proc statusq_urlscheme_delete*(obj: pointer) {.cdecl, importc.}
when defined(monitoring):
+53
View File
@@ -0,0 +1,53 @@
import unittest
import app_service/common/media_server_url
# status-go mints absolute media URLs (https://localhost:<port>/...) at
# marshal time. When the media server restarts on a new port (iOS
# background/resume), cached URLs must be re-pointed — and only those:
# the helper must be a safe no-op for every other string so callers can
# apply it blindly to any cached URL field (issue #47).
suite "withMediaServerPort":
test "rewrites the port of a localhost media URL, preserving path and query":
check withMediaServerPort("https://localhost:34567/messages/images?messageId=0xabc", 40000) ==
"https://localhost:40000/messages/images?messageId=0xabc"
test "rewrites http and 127.0.0.1 forms too":
check withMediaServerPort("http://127.0.0.1:1234/accountImages?keyUid=0x1&imageName=thumbnail", 999) ==
"http://127.0.0.1:999/accountImages?keyUid=0x1&imageName=thumbnail"
test "keeps an already-current port untouched":
let url = "https://localhost:40000/messages/images?messageId=0xabc"
check withMediaServerPort(url, 40000) == url
test "no-op for empty strings and non-positive ports":
check withMediaServerPort("", 40000) == ""
check withMediaServerPort("https://localhost:34567/x", 0) == "https://localhost:34567/x"
check withMediaServerPort("https://localhost:34567/x", -1) == "https://localhost:34567/x"
test "no-op for data URIs":
let dataUri = "data:image/png;base64,iVBORw0KGgo="
check withMediaServerPort(dataUri, 40000) == dataUri
test "no-op for remote URLs, with or without an explicit port":
check withMediaServerPort("https://example.com/img.png", 40000) ==
"https://example.com/img.png"
check withMediaServerPort("https://example.com:8080/img.png", 40000) ==
"https://example.com:8080/img.png"
test "no-op for local URLs without an explicit port":
# The media server always embeds its ephemeral port; a port-less
# localhost URL is not one of its URLs.
check withMediaServerPort("https://localhost/img.png", 40000) ==
"https://localhost/img.png"
test "no-op for non-http schemes and plain paths":
check withMediaServerPort("qrc:/imports/assets/x.svg", 40000) == "qrc:/imports/assets/x.svg"
check withMediaServerPort("file:///tmp/x.png", 40000) == "file:///tmp/x.png"
check withMediaServerPort("/tmp/x.png", 40000) == "/tmp/x.png"
test "a port embedded in the query string is not touched":
check withMediaServerPort("https://localhost:34567/proxy?u=http://localhost:34567/a", 40000) ==
"https://localhost:40000/proxy?u=http://localhost:34567/a"
+62
View File
@@ -390,3 +390,65 @@ suite "mark message as unread":
# and marker is insert last at position : position('0xb') - 1 equals to position 2 here
check(model.items[2].seen == true)
check(model.items[3].seen == false)
# The status-go media server restarts on a new ephemeral port when iOS
# suspends/resumes the app; cached absolute media URLs must be re-pointed
# at the new port while every other URL stays untouched (issue #47).
suite "media server port refresh":
const oldBase = "https://localhost:34567"
const newBase = "https://localhost:40000"
const remoteAvatar = "https://cdn.example.com/avatar.png"
proc createImageMessageItem(id: string, clock: int64): Item =
return message_model.createMessageItemFromDtos(
message = MessageDto(
id: id,
clock: clock,
contentType: ContentType.Image,
image: oldBase & "/messages/images?messageId=" & id,
albumId: "album-1",
sticker: Sticker(url: oldBase & "/ipfs?hash=0xdeadbeef"),
),
communityId = "",
sender = ContactDetails(
icon: oldBase & "/contactImages?publicKey=0xsender&imageName=thumbnail",
),
isCurrentUser = false,
renderedMessageText = "",
clearText = "",
albumImages = @[oldBase & "/messages/images?messageId=" & id],
albumMessageIds = @[id],
quotedMessageAuthorDetails = ContactDetails(
icon: remoteAvatar,
dto: ContactsDto(image: Images(thumbnail: remoteAvatar)),
),
)
test "updateMediaServerPort re-points cached local media URLs":
let model = newModel()
model.insertItemsBasedOnClock(@[createImageMessageItem("0x1", 1)])
model.updateMediaServerPort(40000)
let item = model.items[0]
check(item.messageImage == newBase & "/messages/images?messageId=0x1")
check(item.senderIcon == newBase & "/contactImages?publicKey=0xsender&imageName=thumbnail")
check(item.albumMessageImages == @[newBase & "/messages/images?messageId=0x1"])
check(item.sticker == newBase & "/ipfs?hash=0xdeadbeef")
test "updateMediaServerPort leaves remote URLs untouched":
let model = newModel()
model.insertItemsBasedOnClock(@[createImageMessageItem("0x1", 1)])
model.updateMediaServerPort(40000)
check(model.items[0].quotedMessageAuthorAvatar == remoteAvatar)
test "updateMediaServerPort is idempotent":
let model = newModel()
model.insertItemsBasedOnClock(@[createImageMessageItem("0x1", 1)])
model.updateMediaServerPort(40000)
model.updateMediaServerPort(40000)
check(model.items[0].messageImage == newBase & "/messages/images?messageId=0x1")
+165
View File
@@ -0,0 +1,165 @@
## Tests for the iOS app-lifecycle → pausable-services bridge (issue #51):
## driven over the real StatusQ appBackgrounded/appForegrounded signals,
## with the backend seam replaced by recorders.
## - a real backgrounding (Qt::ApplicationSuspended → appBackgrounded) pauses
## the full pausable set fetched from the backend; the matching
## foregrounding resumes it — the chain that makes the media server rebind
## and emit mediaserver.started (the #47 URL refresh trigger);
## - foreground flaps with no preceding pause (share sheets and system alerts
## dip the app to Inactive and back — appForegrounded fires on every return
## to Active) drive nothing;
## - repeated backgrounded events pause once; each pause resumes at most once;
## - an empty pausable set (node not running, e.g. backgrounded on the login
## screen) drives no pause and no resume;
## - the second suite covers the backend response parsing (PausableServices
## returns "null" when the node is down; Pause/ResumeServices return
## {"error": ...} envelopes).
import unittest
import nimqml
import app/core/custom_urls/url_scheme_event
import app/core/services_pause_bridge
import backend/pausable_services as backend_pausable_services
import statusq_bridge
# Selective import: pulling all of gen_qcoreapplication would re-export the seaqt
# gen_qobject_types.QObject and make nimqml's QObject ambiguous (see url_scheme_event_test).
from seaqt/qcoreapplication import QCoreApplication, create
discard QCoreApplication.create() # one app for the whole suite
suite "services_pause_bridge":
setup:
let urlSchemeEvent = newUrlSchemeEvent()
var names = @["mediaserver", "messenger", "localbackups"]
# unittest's setup vars live at module scope: a `= 0` constant initializer is
# emitted once and not re-run per case, so the counter must be reset here
# explicitly (unlike the seqs, whose `@[]` runtime init already resets).
var fetches: int
fetches = 0
var pauseCalls: seq[seq[string]] = @[]
var resumeCalls: seq[seq[string]] = @[]
let bridge = newServicesPauseBridge(urlSchemeEvent, PausableServicesCalls(
pausableServiceNames: proc(): seq[string] =
inc fetches
names,
pauseServices: proc(serviceNames: seq[string]) =
pauseCalls.add(serviceNames),
resumeServices: proc(serviceNames: seq[string]) =
resumeCalls.add(serviceNames)))
teardown:
bridge.delete()
test "backgrounding pauses the full pausable set":
statusq_urlscheme_emit_appbackgrounded(urlSchemeEvent.vptr)
check pauseCalls == @[@["mediaserver", "messenger", "localbackups"]]
check resumeCalls.len == 0
test "foregrounding after a pause resumes the services":
statusq_urlscheme_emit_appbackgrounded(urlSchemeEvent.vptr)
statusq_urlscheme_emit_appforegrounded(urlSchemeEvent.vptr)
check pauseCalls == @[@["mediaserver", "messenger", "localbackups"]]
check resumeCalls == @[@["mediaserver", "messenger", "localbackups"]]
test "foregrounding without a preceding pause drives nothing":
# Share sheets, system alerts and app start all re-enter Active without a
# suspension; resuming never-paused services would be noise.
statusq_urlscheme_emit_appforegrounded(urlSchemeEvent.vptr)
statusq_urlscheme_emit_appforegrounded(urlSchemeEvent.vptr)
check fetches == 0
check pauseCalls.len == 0
check resumeCalls.len == 0
test "repeated backgrounded events pause once":
statusq_urlscheme_emit_appbackgrounded(urlSchemeEvent.vptr)
statusq_urlscheme_emit_appbackgrounded(urlSchemeEvent.vptr)
check pauseCalls.len == 1
test "each pause resumes at most once":
statusq_urlscheme_emit_appbackgrounded(urlSchemeEvent.vptr)
statusq_urlscheme_emit_appforegrounded(urlSchemeEvent.vptr)
statusq_urlscheme_emit_appforegrounded(urlSchemeEvent.vptr)
check pauseCalls.len == 1
check resumeCalls.len == 1
test "full background/foreground cycles pause and resume each time":
statusq_urlscheme_emit_appbackgrounded(urlSchemeEvent.vptr)
statusq_urlscheme_emit_appforegrounded(urlSchemeEvent.vptr)
statusq_urlscheme_emit_appbackgrounded(urlSchemeEvent.vptr)
statusq_urlscheme_emit_appforegrounded(urlSchemeEvent.vptr)
check pauseCalls.len == 2
check resumeCalls.len == 2
test "the pausable set is re-fetched at each transition":
# Mirrors the Android service: services registered in status-go later are
# picked up without client changes.
statusq_urlscheme_emit_appbackgrounded(urlSchemeEvent.vptr)
names = @["mediaserver", "messenger", "localbackups", "downloader"]
statusq_urlscheme_emit_appforegrounded(urlSchemeEvent.vptr)
check pauseCalls == @[@["mediaserver", "messenger", "localbackups"]]
check resumeCalls ==
@[@["mediaserver", "messenger", "localbackups", "downloader"]]
test "an empty pausable set drives no pause and no resume":
# Node not running: backgrounded on the login screen. Nothing was paused,
# so the paused latch stays clear and the foreground event returns before
# fetching — only the backgrounding fetches.
names = @[]
statusq_urlscheme_emit_appbackgrounded(urlSchemeEvent.vptr)
statusq_urlscheme_emit_appforegrounded(urlSchemeEvent.vptr)
check fetches == 1
check pauseCalls.len == 0
check resumeCalls.len == 0
suite "pausable_services backend parsing":
test "the backend procs satisfy the bridge seam (nim_status_client wiring)":
# Mirrors the mainProc construction exactly: the plain backend procs must
# convert to the closure-typed seam fields, and forcing their codegen
# proves the PausableServices/PauseServices/ResumeServices importc symbols
# link against libstatus' generated C exports. Not invoked: no node runs
# here.
let calls = PausableServicesCalls(
pausableServiceNames: backend_pausable_services.pausableServiceNames,
pauseServices: backend_pausable_services.pauseServices,
resumeServices: backend_pausable_services.resumeServices)
check not calls.pausableServiceNames.isNil
check not calls.pauseServices.isNil
check not calls.resumeServices.isNil
test "a service list parses to its names":
check backend_pausable_services.parsePausableServiceNames(
"""[{"name":"mediaserver","state":"running"},
{"name":"messenger","state":"paused"}]""") ==
@["mediaserver", "messenger"]
test "node-not-running null response parses to no names":
# mobile/status.go marshals the nil PausableServices() slice to "null".
check backend_pausable_services.parsePausableServiceNames("null").len == 0
test "empty, error-object and malformed responses parse to no names":
check backend_pausable_services.parsePausableServiceNames("").len == 0
check backend_pausable_services.parsePausableServiceNames("[]").len == 0
check backend_pausable_services.parsePausableServiceNames(
"""{"error":"node stopped"}""").len == 0
check backend_pausable_services.parsePausableServiceNames(
"not json").len == 0
test "entries without a usable name are skipped":
check backend_pausable_services.parsePausableServiceNames(
"""[{"name":"mediaserver"},{"state":"running"},{"name":""}]""") ==
@["mediaserver"]
test "api response error envelope parses":
check backend_pausable_services.apiResponseError("""{"error":""}""") == ""
check backend_pausable_services.apiResponseError(
"""{"error":"service not found in registry: \"x\""}""") ==
"service not found in registry: \"x\""
check backend_pausable_services.apiResponseError("garbage") == ""
+28
View File
@@ -4,6 +4,7 @@ import app/core/eventemitter
import app/core/signals/signals_manager
import app/core/signals/signal_type_scan
import app/core/signals/remote_signals/signal_type
import app/core/signals/remote_signals/mediaserver
# Exercises the real ManageSignals dispatch path (`processSignal`) to prove that
# the cheap-triage guard skips unhandled types before parsing
@@ -56,6 +57,33 @@ suite "SignalsManager - unhandled signal-type skipping":
check dispatchedCount == 1
check unhandledSignalCount() == 1
test "mediaserver.started decodes the port and dispatches a typed signal":
# iOS restarts the media server on resume; the new port must survive the
# decode so subscribers can rewrite cached media URLs (issue #47).
let emitter = createEventEmitter()
var receivedPort = 0
emitter.on(SignalType.MediaServerStarted.event) do(a: Args):
receivedPort = MediaServerStartedSignal(a).port
let manager = newSignalsManager(emitter)
manager.processSignal("""{"type":"mediaserver.started","event":{"port":43210}}""")
check receivedPort == 43210
test "mediaserver.started with a null event dispatches with port 0":
let emitter = createEventEmitter()
var dispatched = false
var receivedPort = -1
emitter.on(SignalType.MediaServerStarted.event) do(a: Args):
dispatched = true
receivedPort = MediaServerStartedSignal(a).port
let manager = newSignalsManager(emitter)
manager.processSignal("""{"type":"mediaserver.started","event":null}""")
check dispatched
check receivedPort == 0
test "a handled type in a whitespaced envelope still dispatches (scan-miss fallback)":
# The fast substring scan assumes status-go's compact `"type":"` byte token.
# If the marshaling format ever changes (e.g. a space after the colon), the
@@ -12,6 +12,9 @@ namespace Status
public:
void emitDeepLinkToQt(const QString& url);
void emitAppForegroundedToQt();
void emitAppBackgroundedToQt();
void watchApplicationState();
static void setInstance(UrlSchemeEvent* instance);
void registerUrlHandler();
@@ -24,6 +27,12 @@ namespace Status
signals:
void urlActivated(const QString& url);
void appForegrounded();
// Emitted only on Qt::ApplicationSuspended — a real backgrounding
// (iOS applicationDidEnterBackground). Qt::ApplicationInactive is
// NOT backgrounded: share sheets and system alerts briefly
// deactivate the app without suspending it.
void appBackgrounded();
};
}
+9
View File
@@ -115,6 +115,7 @@ Q_DECL_EXPORT void statusq_invoke_method_queued(void* obj, const char* method, c
Q_DECL_EXPORT void* statusq_urlscheme_create() {
auto* ev = new Status::UrlSchemeEvent();
ev->registerUrlHandler();
ev->watchApplicationState();
return ev;
}
@@ -130,6 +131,14 @@ Q_DECL_EXPORT void statusq_urlscheme_emit_deeplink(void* obj, const char* url) {
static_cast<Status::UrlSchemeEvent*>(obj)->emitDeepLinkToQt(QString::fromUtf8(url));
}
Q_DECL_EXPORT void statusq_urlscheme_emit_appforegrounded(void* obj) {
static_cast<Status::UrlSchemeEvent*>(obj)->emitAppForegroundedToQt();
}
Q_DECL_EXPORT void statusq_urlscheme_emit_appbackgrounded(void* obj) {
static_cast<Status::UrlSchemeEvent*>(obj)->emitAppBackgroundedToQt();
}
Q_DECL_EXPORT void statusq_urlscheme_delete(void* obj) {
static_cast<QObject*>(obj)->deleteLater();
}
+40
View File
@@ -9,7 +9,9 @@ using namespace Status;
#include <QJniObject>
#endif // Q_OS_ANDROID
#include <QDebug>
#include <QDesktopServices>
#include <QGuiApplication>
void UrlSchemeEvent::registerUrlHandler()
{
@@ -43,6 +45,44 @@ bool UrlSchemeEvent::eventFilter(QObject* obj, QEvent* event)
return QObject::eventFilter(obj, event);
}
void UrlSchemeEvent::watchApplicationState()
{
// appBackgrounded/appForegrounded drive the iOS pausable-services
// bridge (src/app/core/services_pause_bridge.nim): pause on suspension,
// resume — and media-server rebind — on return to the foreground.
// Under QCoreApplication (unit tests) there is no application state; skip.
if (auto* app = qobject_cast<QGuiApplication*>(QCoreApplication::instance())) {
connect(app, &QGuiApplication::applicationStateChanged, this,
[this](Qt::ApplicationState state) {
switch (state) {
case Qt::ApplicationActive:
emit appForegrounded();
break;
case Qt::ApplicationSuspended:
emit appBackgrounded();
break;
case Qt::ApplicationInactive:
// Transient dip (share sheets, system alerts, app
// switcher) — deliberately not a backgrounding.
break;
default:
qWarning() << "Unhandled application state:" << state;
break;
}
});
}
}
void UrlSchemeEvent::emitAppForegroundedToQt()
{
emit appForegrounded();
}
void UrlSchemeEvent::emitAppBackgroundedToQt()
{
emit appBackgrounded();
}
void UrlSchemeEvent::emitDeepLinkToQt(const QString& url)
{
if (url.isEmpty()) return;