From: AsamK Date: Thu, 10 Feb 2022 17:27:24 +0000 (+0100) Subject: Store account list in accounts.json file X-Git-Tag: v0.10.4~22 X-Git-Url: https://git.nmode.ca/signal-cli/commitdiff_plain/0476895c3d9e843e505ef768340d7969b084cada?hp=ff6b733cd0448c05f4be5aad32895cc8c748ee79 Store account list in accounts.json file --- diff --git a/graalvm-config-dir/reflect-config.json b/graalvm-config-dir/reflect-config.json index ae98cd74..158f9429 100644 --- a/graalvm-config-dir/reflect-config.json +++ b/graalvm-config-dir/reflect-config.json @@ -859,6 +859,28 @@ "allDeclaredFields":true, "queryAllDeclaredMethods":true }, +{ + "name":"org.asamk.signal.manager.storage.accounts.AccountsStorage", + "allDeclaredFields":true, + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "methods":[ + {"name":"","parameterTypes":["java.util.List"] }, + {"name":"accounts","parameterTypes":[] } + ] +}, +{ + "name":"org.asamk.signal.manager.storage.accounts.AccountsStorage$Account", + "allDeclaredFields":true, + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "methods":[ + {"name":"","parameterTypes":["java.lang.String","java.lang.String","java.lang.String"] }, + {"name":"number","parameterTypes":[] }, + {"name":"path","parameterTypes":[] }, + {"name":"uuid","parameterTypes":[] } + ] +}, { "name":"org.asamk.signal.manager.storage.configuration.ConfigurationStore$Storage", "allDeclaredFields":true, diff --git a/lib/src/main/java/org/asamk/signal/manager/SignalAccountFiles.java b/lib/src/main/java/org/asamk/signal/manager/SignalAccountFiles.java index f1d37e81..379aa1cb 100644 --- a/lib/src/main/java/org/asamk/signal/manager/SignalAccountFiles.java +++ b/lib/src/main/java/org/asamk/signal/manager/SignalAccountFiles.java @@ -34,7 +34,7 @@ public class SignalAccountFiles { final ServiceEnvironment serviceEnvironment, final String userAgent, final TrustNewIdentity trustNewIdentity - ) { + ) throws IOException { this.pathConfig = PathConfig.createDefault(settingsPath); this.serviceEnvironmentConfig = ServiceConfig.getServiceEnvironmentConfig(serviceEnvironment, userAgent); this.userAgent = userAgent; diff --git a/lib/src/main/java/org/asamk/signal/manager/storage/accounts/AccountsStorage.java b/lib/src/main/java/org/asamk/signal/manager/storage/accounts/AccountsStorage.java new file mode 100644 index 00000000..341d9572 --- /dev/null +++ b/lib/src/main/java/org/asamk/signal/manager/storage/accounts/AccountsStorage.java @@ -0,0 +1,8 @@ +package org.asamk.signal.manager.storage.accounts; + +import java.util.List; + +record AccountsStorage(List accounts) { + + record Account(String path, String number, String uuid) {} +} diff --git a/lib/src/main/java/org/asamk/signal/manager/storage/accounts/AccountsStore.java b/lib/src/main/java/org/asamk/signal/manager/storage/accounts/AccountsStore.java index ed6dec97..07dfac3c 100644 --- a/lib/src/main/java/org/asamk/signal/manager/storage/accounts/AccountsStore.java +++ b/lib/src/main/java/org/asamk/signal/manager/storage/accounts/AccountsStore.java @@ -1,22 +1,136 @@ package org.asamk.signal.manager.storage.accounts; +import com.fasterxml.jackson.databind.ObjectMapper; + +import org.asamk.signal.manager.api.Pair; +import org.asamk.signal.manager.storage.Utils; +import org.asamk.signal.manager.util.IOUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.whispersystems.signalservice.api.push.ACI; import org.whispersystems.signalservice.api.util.PhoneNumberFormatter; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; import java.io.File; +import java.io.IOException; +import java.io.RandomAccessFile; +import java.nio.channels.Channels; +import java.nio.channels.FileChannel; +import java.nio.channels.FileLock; import java.util.Arrays; +import java.util.List; +import java.util.Objects; +import java.util.Random; import java.util.Set; +import java.util.function.Function; import java.util.stream.Collectors; +import java.util.stream.Stream; public class AccountsStore { + private final static Logger logger = LoggerFactory.getLogger(AccountsStore.class); + private final ObjectMapper objectMapper = Utils.createStorageObjectMapper(); + private final File dataPath; - public AccountsStore(final File dataPath) { + public AccountsStore(final File dataPath) throws IOException { this.dataPath = dataPath; + if (!getAccountsFile().exists()) { + createInitialAccounts(); + } } public Set getAllNumbers() { + return readAccounts().stream() + .map(AccountsStorage.Account::number) + .filter(Objects::nonNull) + .collect(Collectors.toSet()); + } + + public String getPathByNumber(String number) { + return readAccounts().stream() + .filter(a -> number.equals(a.number())) + .map(AccountsStorage.Account::path) + .findFirst() + .orElse(null); + } + + public String getPathByAci(ACI aci) { + return readAccounts().stream() + .filter(a -> aci.toString().equals(a.uuid())) + .map(AccountsStorage.Account::path) + .findFirst() + .orElse(null); + } + + public void updateAccount(String path, String number, ACI aci) { + updateAccounts(accounts -> accounts.stream().map(a -> { + if (path.equals(a.path())) { + return new AccountsStorage.Account(a.path(), number, aci == null ? null : aci.toString()); + } + + if (number != null && number.equals(a.number())) { + return new AccountsStorage.Account(a.path(), null, a.uuid()); + } + if (aci != null && aci.toString().equals(a.toString())) { + return new AccountsStorage.Account(a.path(), a.number(), null); + } + + return a; + }).toList()); + } + + public String addAccount(String number, ACI aci) { + final var accountPath = generateNewAccountPath(); + final var account = new AccountsStorage.Account(accountPath, number, aci == null ? null : aci.toString()); + updateAccounts(accounts -> { + final var existingAccounts = accounts.stream().map(a -> { + if (number != null && number.equals(a.number())) { + return new AccountsStorage.Account(a.path(), null, a.uuid()); + } + if (aci != null && aci.toString().equals(a.toString())) { + return new AccountsStorage.Account(a.path(), a.number(), null); + } + + return a; + }); + return Stream.concat(existingAccounts, Stream.of(account)).toList(); + }); + return accountPath; + } + + private String generateNewAccountPath() { + return new Random().ints(100000, 1000000) + .mapToObj(String::valueOf) + .filter(n -> !new File(dataPath, n).exists() && !new File(dataPath, n + ".d").exists()) + .findFirst() + .get(); + } + + private File getAccountsFile() { + return new File(dataPath, "accounts.json"); + } + + private void createInitialAccounts() throws IOException { + final var legacyAccountPaths = getLegacyAccountPaths(); + final var accountsStorage = new AccountsStorage(legacyAccountPaths.stream() + .map(number -> new AccountsStorage.Account(number, number, null)) + .toList()); + + IOUtils.createPrivateDirectories(dataPath); + var fileName = getAccountsFile(); + if (!fileName.exists()) { + IOUtils.createPrivateFile(fileName); + } + + final var pair = openFileChannel(getAccountsFile()); + try (final var fileChannel = pair.first(); final var lock = pair.second()) { + saveAccountsLocked(fileChannel, accountsStorage); + } + } + + private Set getLegacyAccountPaths() { final var files = dataPath.listFiles(); if (files == null) { @@ -30,23 +144,61 @@ public class AccountsStore { .collect(Collectors.toSet()); } - public String getPathByNumber(String number) { - return number; + private List readAccounts() { + try { + final var pair = openFileChannel(getAccountsFile()); + try (final var fileChannel = pair.first(); final var lock = pair.second()) { + return readAccountsLocked(fileChannel).accounts(); + } + } catch (IOException e) { + logger.error("Failed to read accounts list", e); + return List.of(); + } } - public String getPathByAci(ACI aci) { - return null; + private void updateAccounts(Function, List> updater) { + try { + final var pair = openFileChannel(getAccountsFile()); + try (final var fileChannel = pair.first(); final var lock = pair.second()) { + final var accountsStorage = readAccountsLocked(fileChannel); + final var newAccountsStorage = updater.apply(accountsStorage.accounts()); + saveAccountsLocked(fileChannel, new AccountsStorage(newAccountsStorage)); + } + } catch (IOException e) { + logger.error("Failed to update accounts list", e); + } } - public void updateAccount(String path, String number, ACI aci) { - // TODO remove number and uuid from all other accounts - if (!path.equals(number)) { - throw new UnsupportedOperationException("Updating number not supported yet"); + private AccountsStorage readAccountsLocked(FileChannel fileChannel) throws IOException { + fileChannel.position(0); + final var inputStream = Channels.newInputStream(fileChannel); + return objectMapper.readValue(inputStream, AccountsStorage.class); + } + + private void saveAccountsLocked(FileChannel fileChannel, AccountsStorage accountsStorage) throws IOException { + try { + try (var output = new ByteArrayOutputStream()) { + // Write to memory first to prevent corrupting the file in case of serialization errors + objectMapper.writeValue(output, accountsStorage); + var input = new ByteArrayInputStream(output.toByteArray()); + fileChannel.position(0); + input.transferTo(Channels.newOutputStream(fileChannel)); + fileChannel.truncate(fileChannel.position()); + fileChannel.force(false); + } + } catch (Exception e) { + logger.error("Error saving accounts file: {}", e.getMessage(), e); } } - public String addAccount(String number, ACI aci) { - // TODO remove number and uuid from all other accounts - return number; + private static Pair openFileChannel(File fileName) throws IOException { + var fileChannel = new RandomAccessFile(fileName, "rw").getChannel(); + var lock = fileChannel.tryLock(); + if (lock == null) { + logger.info("Config file is in use by another instance, waiting…"); + lock = fileChannel.lock(); + logger.info("Config file lock acquired."); + } + return new Pair<>(fileChannel, lock); } } diff --git a/src/main/java/org/asamk/signal/App.java b/src/main/java/org/asamk/signal/App.java index 5853e4b8..7a9297a8 100644 --- a/src/main/java/org/asamk/signal/App.java +++ b/src/main/java/org/asamk/signal/App.java @@ -163,10 +163,15 @@ public class App { ? TrustNewIdentity.ON_FIRST_USE : trustNewIdentityCli == TrustNewIdentityCli.ALWAYS ? TrustNewIdentity.ALWAYS : TrustNewIdentity.NEVER; - final SignalAccountFiles signalAccountFiles = new SignalAccountFiles(configPath, - serviceEnvironment, - BaseConfig.USER_AGENT, - trustNewIdentity); + final SignalAccountFiles signalAccountFiles; + try { + signalAccountFiles = new SignalAccountFiles(configPath, + serviceEnvironment, + BaseConfig.USER_AGENT, + trustNewIdentity); + } catch (IOException e) { + throw new IOErrorException("Failed to read local accounts list", e); + } if (command instanceof ProvisioningCommand provisioningCommand) { if (account != null) {