diff --git a/makefiles/nim-tests.mk b/makefiles/nim-tests.mk index 34ab7f5e56..b8754b896a 100644 --- a/makefiles/nim-tests.mk +++ b/makefiles/nim-tests.mk @@ -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 \ diff --git a/src/app/core/services_pause_bridge.nim b/src/app/core/services_pause_bridge.nim new file mode 100644 index 0000000000..a777583d8e --- /dev/null +++ b/src/app/core/services_pause_bridge.nim @@ -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:/ 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) diff --git a/src/app/core/signals/remote_signals/mediaserver.nim b/src/app/core/signals/remote_signals/mediaserver.nim new file mode 100644 index 0000000000..e03f4f06db --- /dev/null +++ b/src/app/core/signals/remote_signals/mediaserver.nim @@ -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() diff --git a/src/app/core/signals/signals_manager.nim b/src/app/core/signals/signals_manager.nim index 9d3ce1c921..d8bdff3a18 100644 --- a/src/app/core/signals/signals_manager.nim +++ b/src/app/core/signals/signals_manager.nim @@ -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) diff --git a/src/app/core/signals/types.nim b/src/app/core/signals/types.nim index 731415575f..fbe08440a4 100644 --- a/src/app/core/signals/types.nim +++ b/src/app/core/signals/types.nim @@ -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 \ No newline at end of file + backed_up_settings, back_up_completed, pairing, node, networks diff --git a/src/app/modules/main/chat_section/chat_content/controller.nim b/src/app/modules/main/chat_section/chat_content/controller.nim index f01351d10f..afdc51a687 100644 --- a/src/app/modules/main/chat_section/chat_content/controller.nim +++ b/src/app/modules/main/chat_section/chat_content/controller.nim @@ -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): diff --git a/src/app/modules/main/chat_section/chat_content/io_interface.nim b/src/app/modules/main/chat_section/chat_content/io_interface.nim index b593cf3513..685233822d 100644 --- a/src/app/modules/main/chat_section/chat_content/io_interface.nim +++ b/src/app/modules/main/chat_section/chat_content/io_interface.nim @@ -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") diff --git a/src/app/modules/main/chat_section/chat_content/messages/controller.nim b/src/app/modules/main/chat_section/chat_content/messages/controller.nim index 8e74108ad2..fcf6d382d9 100644 --- a/src/app/modules/main/chat_section/chat_content/messages/controller.nim +++ b/src/app/modules/main/chat_section/chat_content/messages/controller.nim @@ -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: diff --git a/src/app/modules/main/chat_section/chat_content/messages/io_interface.nim b/src/app/modules/main/chat_section/chat_content/messages/io_interface.nim index ce6b180a49..d73ec389d7 100644 --- a/src/app/modules/main/chat_section/chat_content/messages/io_interface.nim +++ b/src/app/modules/main/chat_section/chat_content/messages/io_interface.nim @@ -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") diff --git a/src/app/modules/main/chat_section/chat_content/messages/module.nim b/src/app/modules/main/chat_section/chat_content/messages/module.nim index 74782810d4..b786d8cf70 100644 --- a/src/app/modules/main/chat_section/chat_content/messages/module.nim +++ b/src/app/modules/main/chat_section/chat_content/messages/module.nim @@ -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) diff --git a/src/app/modules/main/chat_section/chat_content/module.nim b/src/app/modules/main/chat_section/chat_content/module.nim index afa0ab3448..b4d867c14f 100644 --- a/src/app/modules/main/chat_section/chat_content/module.nim +++ b/src/app/modules/main/chat_section/chat_content/module.nim @@ -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) diff --git a/src/app/modules/main/controller.nim b/src/app/modules/main/controller.nim index dd11a1e718..15723aa728 100644 --- a/src/app/modules/main/controller.nim +++ b/src/app/modules/main/controller.nim @@ -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, diff --git a/src/app/modules/main/io_interface.nim b/src/app/modules/main/io_interface.nim index ea41e8a77b..65d4493e10 100644 --- a/src/app/modules/main/io_interface.nim +++ b/src/app/modules/main/io_interface.nim @@ -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, diff --git a/src/app/modules/main/module.nim b/src/app/modules/main/module.nim index 5de7292a9c..225b8a9b5e 100644 --- a/src/app/modules/main/module.nim +++ b/src/app/modules/main/module.nim @@ -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, diff --git a/src/app/modules/shared_models/discord_message_item.nim b/src/app/modules/shared_models/discord_message_item.nim index 90a9b7b63f..474751b077 100644 --- a/src/app/modules/shared_models/discord_message_item.nim +++ b/src/app/modules/shared_models/discord_message_item.nim @@ -1,4 +1,4 @@ -import Nimqml, json, std/strformat +import nimqml, json, std/strformat import ../../../app_service/service/message/dto/message diff --git a/src/app/modules/shared_models/link_preview_model.nim b/src/app/modules/shared_models/link_preview_model.nim index d066ece333..1f92bb97c7 100644 --- a/src/app/modules/shared_models/link_preview_model.nim +++ b/src/app/modules/shared_models/link_preview_model.nim @@ -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): diff --git a/src/app/modules/shared_models/message_item.nim b/src/app/modules/shared_models/message_item.nim index 218192ea5a..194303537a 100644 --- a/src/app/modules/shared_models/message_item.nim +++ b/src/app/modules/shared_models/message_item.nim @@ -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) diff --git a/src/app/modules/shared_models/message_model.nim b/src/app/modules/shared_models/message_model.nim index 05f958a426..f57791f7bd 100644 --- a/src/app/modules/shared_models/message_model.nim +++ b/src/app/modules/shared_models/message_model.nim @@ -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 diff --git a/src/app/modules/shared_models/message_transaction_parameters_item.nim b/src/app/modules/shared_models/message_transaction_parameters_item.nim index 6f3add8da5..3169d2026e 100644 --- a/src/app/modules/shared_models/message_transaction_parameters_item.nim +++ b/src/app/modules/shared_models/message_transaction_parameters_item.nim @@ -1,4 +1,4 @@ -import Nimqml, json, std/strformat +import nimqml, json, std/strformat QtObject: type diff --git a/src/app/modules/shared_models/section_model.nim b/src/app/modules/shared_models/section_model.nim index c197db1af1..9fc4358934 100644 --- a/src/app/modules/shared_models/section_model.nim +++ b/src/app/modules/shared_models/section_model.nim @@ -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, diff --git a/src/app_service/common/media_server_url.nim b/src/app_service/common/media_server_url.nim new file mode 100644 index 0000000000..8b00d63217 --- /dev/null +++ b/src/app_service/common/media_server_url.nim @@ -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 diff --git a/src/app_service/service/contacts/service.nim b/src/app_service/service/contacts/service.nim index edfd4a617e..dad98fc693 100644 --- a/src/app_service/service/contacts/service.nim +++ b/src/app_service/service/contacts/service.nim @@ -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() diff --git a/src/app_service/service/message/dto/link_preview.nim b/src/app_service/service/message/dto/link_preview.nim index cb565a3ef1..8240c61b48 100644 --- a/src/app_service/service/message/dto/link_preview.nim +++ b/src/app_service/service/message/dto/link_preview.nim @@ -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) diff --git a/src/app_service/service/message/dto/link_preview_thumbnail.nim b/src/app_service/service/message/dto/link_preview_thumbnail.nim index 435bacf3ca..230752b9d7 100644 --- a/src/app_service/service/message/dto/link_preview_thumbnail.nim +++ b/src/app_service/service/message/dto/link_preview_thumbnail.nim @@ -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 diff --git a/src/backend/contacts.nim b/src/backend/contacts.nim index abad3477b1..1ff3f141d3 100644 --- a/src/backend/contacts.nim +++ b/src/backend/contacts.nim @@ -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) diff --git a/src/backend/pausable_services.nim b/src/backend/pausable_services.nim new file mode 100644 index 0000000000..0582e81615 --- /dev/null +++ b/src/backend/pausable_services.nim @@ -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 diff --git a/src/nim_status_client.nim b/src/nim_status_client.nim index ef0c9cbacf..35ba5cffa6 100644 --- a/src/nim_status_client.nim +++ b/src/nim_status_client.nim @@ -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() diff --git a/src/statusq_bridge.nim b/src/statusq_bridge.nim index 50165da243..e99b20665e 100644 --- a/src/statusq_bridge.nim +++ b/src/statusq_bridge.nim @@ -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): diff --git a/test/nim/media_server_url_test.nim b/test/nim/media_server_url_test.nim new file mode 100644 index 0000000000..73a18bc9c5 --- /dev/null +++ b/test/nim/media_server_url_test.nim @@ -0,0 +1,53 @@ +import unittest + +import app_service/common/media_server_url + +# status-go mints absolute media URLs (https://localhost:/...) 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" diff --git a/test/nim/message_model_test.nim b/test/nim/message_model_test.nim index 1e61e47bce..a90d23b654 100644 --- a/test/nim/message_model_test.nim +++ b/test/nim/message_model_test.nim @@ -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") diff --git a/test/nim/services_pause_bridge_test.nim b/test/nim/services_pause_bridge_test.nim new file mode 100644 index 0000000000..c53ac00ccb --- /dev/null +++ b/test/nim/services_pause_bridge_test.nim @@ -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") == "" diff --git a/test/nim/signals_manager_test.nim b/test/nim/signals_manager_test.nim index 861acf5b3b..ceca6853fe 100644 --- a/test/nim/signals_manager_test.nim +++ b/test/nim/signals_manager_test.nim @@ -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 diff --git a/ui/StatusQ/include/StatusQ/urlschemeevent.h b/ui/StatusQ/include/StatusQ/urlschemeevent.h index 3154da6300..44ae3dad29 100644 --- a/ui/StatusQ/include/StatusQ/urlschemeevent.h +++ b/ui/StatusQ/include/StatusQ/urlschemeevent.h @@ -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(); }; } diff --git a/ui/StatusQ/src/externc.cpp b/ui/StatusQ/src/externc.cpp index 2f137cbe90..c5d2be531c 100644 --- a/ui/StatusQ/src/externc.cpp +++ b/ui/StatusQ/src/externc.cpp @@ -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(obj)->emitDeepLinkToQt(QString::fromUtf8(url)); } +Q_DECL_EXPORT void statusq_urlscheme_emit_appforegrounded(void* obj) { + static_cast(obj)->emitAppForegroundedToQt(); +} + +Q_DECL_EXPORT void statusq_urlscheme_emit_appbackgrounded(void* obj) { + static_cast(obj)->emitAppBackgroundedToQt(); +} + Q_DECL_EXPORT void statusq_urlscheme_delete(void* obj) { static_cast(obj)->deleteLater(); } diff --git a/ui/StatusQ/src/urlschemeevent.cpp b/ui/StatusQ/src/urlschemeevent.cpp index 9eb2410ee6..db339b9720 100644 --- a/ui/StatusQ/src/urlschemeevent.cpp +++ b/ui/StatusQ/src/urlschemeevent.cpp @@ -9,7 +9,9 @@ using namespace Status; #include #endif // Q_OS_ANDROID +#include #include +#include 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(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;