Add higher-level bridge code for account_exists()

This commit is contained in:
marc-signal 2026-01-29 14:22:56 -05:00 committed by GitHub
parent f758cf9794
commit a6edef3ad0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 276 additions and 1 deletions

2
.gitignore vendored
View File

@ -1,4 +1,4 @@
/.idea
.idea
*.iml
/target
/swift/**/.build

View File

@ -1,2 +1,4 @@
v0.86.17
- Expose accountExists() API to client libraries

1
java/.gitignore vendored
View File

@ -5,3 +5,4 @@ out
*.ipr
*.iws
*.iml
.kotlin

View File

@ -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<RequestResult<Boolean, Nothing>> =
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))
}
}

View File

@ -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<RequestResult.Success<Boolean>>(result)
assertEquals(testCase.found, successResult.result)
}
}
}

View File

@ -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';

View File

@ -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<boolean>;
}
UnauthenticatedChatConnection.prototype.accountExists = async function (
{
account,
}: {
account: ServiceId;
},
options?: RequestOptions
): Promise<boolean> {
return await this._asyncContext.makeCancellable(
options?.abortSignal,
Native.UnauthenticatedChatConnection_account_exists(
this._asyncContext,
this._chatService,
account.getServiceIdFixedWidthBinary()
)
);
};

View File

@ -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<UnauthProfilesService>(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);
}
});
});
});

View File

@ -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<any UnauthProfilesService> {
public static var profiles: Self { .init() }
}

View File

@ -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<any UnauthProfilesService> {
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