diff --git a/.gitignore b/.gitignore index 29f5cc480..750b522fc 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,4 @@ -/.idea +.idea *.iml /target /swift/**/.build diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 1eeccebca..37fad3843 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,2 +1,4 @@ v0.86.17 +- Expose accountExists() API to client libraries + diff --git a/java/.gitignore b/java/.gitignore index 5155a6616..73ecba779 100644 --- a/java/.gitignore +++ b/java/.gitignore @@ -5,3 +5,4 @@ out *.ipr *.iws *.iml +.kotlin diff --git a/java/client/src/main/java/org/signal/libsignal/net/UnauthProfilesService.kt b/java/client/src/main/java/org/signal/libsignal/net/UnauthProfilesService.kt new file mode 100644 index 000000000..6b1e0271b --- /dev/null +++ b/java/client/src/main/java/org/signal/libsignal/net/UnauthProfilesService.kt @@ -0,0 +1,38 @@ +// +// Copyright 2026 Signal Messenger, LLC. +// SPDX-License-Identifier: AGPL-3.0-only +// + +package org.signal.libsignal.net + +import org.signal.libsignal.internal.CompletableFuture +import org.signal.libsignal.internal.Native +import org.signal.libsignal.internal.mapWithCancellation +import org.signal.libsignal.protocol.ServiceId + +public class UnauthProfilesService( + private val connection: UnauthenticatedChatConnection, +) { + /** + * Does an account with the given ACI or PNI exist? + * + * All exceptions are mapped into [RequestResult]; unexpected ones will be treated as + * [RequestResult.ApplicationError]. + */ + public fun accountExists(account: ServiceId): CompletableFuture> = + try { + connection.runWithContextAndConnectionHandles { asyncCtx, conn -> + Native + .UnauthenticatedChatConnection_account_exists( + asyncCtx, + conn, + account.toServiceIdFixedWidthBinary(), + ).mapWithCancellation( + onSuccess = { RequestResult.Success(it) }, + onError = { err -> err.toRequestResult() }, + ) + } + } catch (e: Throwable) { + CompletableFuture.completedFuture(RequestResult.ApplicationError(e)) + } +} diff --git a/java/client/src/test/java/org/signal/libsignal/net/UnauthProfilesServiceTest.kt b/java/client/src/test/java/org/signal/libsignal/net/UnauthProfilesServiceTest.kt new file mode 100644 index 000000000..64ba41730 --- /dev/null +++ b/java/client/src/test/java/org/signal/libsignal/net/UnauthProfilesServiceTest.kt @@ -0,0 +1,60 @@ +// +// Copyright 2026 Signal Messenger, LLC. +// SPDX-License-Identifier: AGPL-3.0-only +// + +package org.signal.libsignal.net + +import org.junit.Assert.assertEquals +import org.junit.Test +import org.signal.libsignal.internal.TokioAsyncContext +import org.signal.libsignal.protocol.ServiceId +import java.util.UUID +import java.util.concurrent.TimeUnit +import kotlin.arrayOf +import kotlin.test.assertIs + +class UnauthProfilesServiceTest { + @Test + fun testAccountExists() { + data class TestCase( + val serviceId: ServiceId, + val found: Boolean, + ) + + val aci = ServiceId.Aci(UUID.fromString("9d0652a3-dcc3-4d11-975f-74d61598733f")) + val pni = ServiceId.Pni(UUID.fromString("796abedb-ca4e-4f18-8803-1fde5b921f9f")) + + val tokioAsyncContext = TokioAsyncContext() + val (chat, fakeRemote) = + UnauthenticatedChatConnection.fakeConnect( + tokioAsyncContext, + NoOpListener(), + Network.Environment.STAGING, + ) + + val profilesService = UnauthProfilesService(chat) + + for (testCase in listOf( + TestCase(aci, true), + TestCase(pni, true), + TestCase(aci, false), + TestCase(pni, false), + )) { + val responseFuture = profilesService.accountExists(testCase.serviceId) + val (request, requestId) = fakeRemote.getNextIncomingRequest().get(1, TimeUnit.SECONDS) + assertEquals("HEAD", request.method) + assertEquals("/v1/accounts/account/${testCase.serviceId.toServiceIdString()}", request.pathAndQuery) + fakeRemote.sendResponse( + requestId, + if (testCase.found) 200 else 404, + if (testCase.found) "OK" else "Not Found", + arrayOf(), + ByteArray(0), + ) + val result = responseFuture.get() + val successResult = assertIs>(result) + assertEquals(testCase.found, successResult.result) + } + } +} diff --git a/node/ts/net.ts b/node/ts/net.ts index 0c2bdd937..7371d6fdc 100644 --- a/node/ts/net.ts +++ b/node/ts/net.ts @@ -25,6 +25,7 @@ import { BridgedStringMap, newNativeHandle } from './internal.js'; export * from './net/CDSI.js'; export * from './net/Chat.js'; export * from './net/chat/UnauthMessagesService.js'; +export * from './net/chat/UnauthProfilesService.js'; export * from './net/chat/UnauthUsernamesService.js'; export * from './net/Registration.js'; export * from './net/SvrB.js'; diff --git a/node/ts/net/chat/UnauthProfilesService.ts b/node/ts/net/chat/UnauthProfilesService.ts new file mode 100644 index 000000000..1a8867144 --- /dev/null +++ b/node/ts/net/chat/UnauthProfilesService.ts @@ -0,0 +1,45 @@ +// +// Copyright 2026 Signal Messenger, LLC. +// SPDX-License-Identifier: AGPL-3.0-only +// + +import { ServiceId } from '../../Address.js'; +import { RequestOptions, UnauthenticatedChatConnection } from '../Chat.js'; +import * as Native from '../../Native.js'; + +declare module '../Chat' { + // eslint-disable-next-line @typescript-eslint/no-empty-object-type + interface UnauthenticatedChatConnection extends UnauthProfilesService {} +} + +export interface UnauthProfilesService { + /** + * Does an account with the given ACI or PNI exist? + * + * Throws / completes with failure only if the request can't be completed. + */ + accountExists: ( + request: { + account: ServiceId; + }, + options?: RequestOptions + ) => Promise; +} + +UnauthenticatedChatConnection.prototype.accountExists = async function ( + { + account, + }: { + account: ServiceId; + }, + options?: RequestOptions +): Promise { + return await this._asyncContext.makeCancellable( + options?.abortSignal, + Native.UnauthenticatedChatConnection_account_exists( + this._asyncContext, + this._chatService, + account.getServiceIdFixedWidthBinary() + ) + ); +}; diff --git a/node/ts/test/chat/UnauthProfilesServiceTest.ts b/node/ts/test/chat/UnauthProfilesServiceTest.ts new file mode 100644 index 000000000..20f6d3689 --- /dev/null +++ b/node/ts/test/chat/UnauthProfilesServiceTest.ts @@ -0,0 +1,51 @@ +// +// Copyright 2026 Signal Messenger, LLC. +// SPDX-License-Identifier: AGPL-3.0-only +// + +import { config, expect, use } from 'chai'; +import chaiAsPromised from 'chai-as-promised'; + +import * as Native from '../../Native.js'; +import * as util from '../util.js'; +import { TokioAsyncContext, UnauthProfilesService } from '../../net.js'; +import { connectUnauth } from './ServiceTestUtils.js'; +import { Aci, Pni } from '../../Address.js'; + +use(chaiAsPromised); + +util.initLogger(); +config.truncateThreshold = 0; + +describe('UnauthProfilesService', () => { + describe('accountExists', () => { + it('faithfully returns true or false', async () => { + const tokio = new TokioAsyncContext(Native.TokioAsyncContext_new()); + const [chat, fakeRemote] = connectUnauth(tokio); + + const aci = Aci.fromUuid('9d0652a3-dcc3-4d11-975f-74d61598733f'); + const pni = Pni.fromUuid('796abedb-ca4e-4f18-8803-1fde5b921f9f'); + + for (const testCase of [ + { serviceId: aci, found: true }, + { serviceId: pni, found: true }, + { serviceId: aci, found: false }, + { serviceId: pni, found: false }, + ]) { + const responseFuture = chat.accountExists({ + account: testCase.serviceId, + }); + const request = await fakeRemote.assertReceiveIncomingRequest(); + expect(request.verb).to.eq('HEAD'); + expect(request.path).to.eq( + `/v1/accounts/account/${testCase.serviceId.getServiceIdString()}` + ); + fakeRemote.sendReplyTo(request, { + status: testCase.found ? 200 : 404, + message: testCase.found ? 'OK' : 'Not Found', + }); + expect(await responseFuture).to.eq(testCase.found); + } + }); + }); +}); diff --git a/swift/Sources/LibSignalClient/chat/UnauthProfilesService.swift b/swift/Sources/LibSignalClient/chat/UnauthProfilesService.swift new file mode 100644 index 000000000..490837a74 --- /dev/null +++ b/swift/Sources/LibSignalClient/chat/UnauthProfilesService.swift @@ -0,0 +1,36 @@ +// +// Copyright 2026 Signal Messenger, LLC. +// SPDX-License-Identifier: AGPL-3.0-only +// + +import Foundation +import SignalFfi + +public protocol UnauthProfilesService: Sendable { + /// Does an account with the given ACI or PNI exist? + /// + /// Throws only if the request can't be completed. + func accountExists(_ account: ServiceId) async throws -> Bool +} + +extension UnauthenticatedChatConnection: UnauthProfilesService { + public func accountExists(_ account: ServiceId) async throws -> Bool { + return try await self.tokioAsyncContext + .invokeAsyncFunction { promise, tokioAsyncContext in + withNativeHandle { chatService in + account.withPointerToFixedWidthBinary { account in + signal_unauthenticated_chat_connection_account_exists( + promise, + tokioAsyncContext.const(), + chatService.const(), + account + ) + } + } + } + } +} + +extension UnauthServiceSelector where Self == UnauthServiceSelectorHelper { + public static var profiles: Self { .init() } +} diff --git a/swift/Tests/LibSignalClientTests/ChatServices/UnauthProfilesServiceTests.swift b/swift/Tests/LibSignalClientTests/ChatServices/UnauthProfilesServiceTests.swift new file mode 100644 index 000000000..f22fb6cfb --- /dev/null +++ b/swift/Tests/LibSignalClientTests/ChatServices/UnauthProfilesServiceTests.swift @@ -0,0 +1,41 @@ +// +// Copyright 2026 Signal Messenger, LLC. +// SPDX-License-Identifier: AGPL-3.0-only +// + +import XCTest + +@testable import LibSignalClient + +// These testing endpoints aren't generated in device builds, to save on code size. +#if !os(iOS) || targetEnvironment(simulator) + +class UnauthProfilesServiceTests: UnauthChatServiceTestBase { + override class var selector: SelectorCheck { .profiles } + + func testAccountExists() async throws { + let ACI = Aci(fromUUID: UUID(uuidString: "9d0652a3-dcc3-4d11-975f-74d61598733f")!) + let PNI = Pni(fromUUID: UUID(uuidString: "796abedb-ca4e-4f18-8803-1fde5b921f9f")!) + let api = self.api + struct TestCase { + var serviceId: ServiceId + var found: Bool + } + for testCase in [ + TestCase(serviceId: ACI, found: true), + TestCase(serviceId: PNI, found: true), + TestCase(serviceId: ACI, found: false), + TestCase(serviceId: PNI, found: false), + ] { + async let responseFuture = api.accountExists(testCase.serviceId) + let (request, id) = try await fakeRemote.getNextIncomingRequest() + XCTAssertEqual(request.method, "HEAD") + XCTAssertEqual(request.pathAndQuery, "/v1/accounts/account/\(testCase.serviceId.serviceIdString)") + try fakeRemote.sendResponse(requestId: id, ChatResponse(status: testCase.found ? 200 : 404)) + let resp = try await responseFuture + XCTAssertEqual(resp, testCase.found) + } + } +} + +#endif