mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2024-12-17 17:20:21 +01:00
Compare commits
86 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8cd362eed8 | |||
| b7ac1b1b55 | |||
| 5952fd5290 | |||
| 353fe4539c | |||
| b003d659e4 | |||
| 6d113ae2e2 | |||
| f264470e14 | |||
| 65391756ef | |||
| 4e27a4ea4f | |||
| b23b109b00 | |||
| 8bf830ced9 | |||
| 80a77a1104 | |||
| 760282cdfd | |||
| 105a6afb4b | |||
| 061125ab63 | |||
| 2f10633d1d | |||
| e34a8ef719 | |||
| e30f7695ab | |||
| 2bb2042d7d | |||
| 0a6133fe5b | |||
| a4a6e2418a | |||
| 7344398826 | |||
| 7f662ec7cc | |||
| 298dd9744f | |||
| 6268f0a32b | |||
| f0d64a30e9 | |||
| d08df4cfbf | |||
| 8e84b9e85f | |||
| c935e8aff3 | |||
| a0a567f5f7 | |||
| ddd97baf5a | |||
| 98e68c8e74 | |||
| 03edde18eb | |||
| 8e7e5209d3 | |||
| 631dfff5e9 | |||
| f69c842ba6 | |||
| 18c802159b | |||
| 45e557fd80 | |||
| d50562cfee | |||
| 15d3d3b11a | |||
| 2b715a0d8c | |||
| 02d00944ff | |||
| 71d6410604 | |||
| 141611293f | |||
| c9400fe932 | |||
| 445a8e75fe | |||
| 9d30a3495e | |||
| d77980e50e | |||
| bb02f07370 | |||
| a3cd7ca89e | |||
| 976fc68cc3 | |||
| 7c7e931aa9 | |||
| e8e619effa | |||
| bd0139eaab | |||
| e9f77e1064 | |||
| 677b75f368 | |||
| 92d13591f3 | |||
| 6be8476f90 | |||
| ae9b83515c | |||
| 26a233ab1a | |||
| c7783a7039 | |||
| 0c34a545fa | |||
| 1d4afe591e | |||
| 10ec3dd8b6 | |||
| a715e847ad | |||
| 9ac0f30c5a | |||
| 7996194f92 | |||
| c2054b5ccf | |||
| 1cb500bc16 | |||
| f5825d20e4 | |||
| 77d249cc37 | |||
| 3f905f59df | |||
| 27f4661ac4 | |||
| 2389e870b3 | |||
| d61ff0f2a7 | |||
| f5d61e7838 | |||
| e762923410 | |||
| d87b86199c | |||
| a7a66c2b55 | |||
| f970ef264a | |||
| 3793cd138e | |||
| 8dd90733b8 | |||
| 0bdd96ae8a | |||
| dd62b1cccb | |||
| 38f40fec3d | |||
| ff7c22e114 |
@@ -52,15 +52,19 @@ jobs:
|
||||
- os: ubuntu-20.04
|
||||
cache_path: ~/.cabal/store
|
||||
asset_name: simplex-chat-ubuntu-20_04-x86-64
|
||||
desktop_asset_name: simplex-desktop-ubuntu-20_04-x86_64.deb
|
||||
- os: ubuntu-22.04
|
||||
cache_path: ~/.cabal/store
|
||||
asset_name: simplex-chat-ubuntu-22_04-x86-64
|
||||
desktop_asset_name: simplex-desktop-ubuntu-22_04-x86_64.deb
|
||||
- os: macos-latest
|
||||
cache_path: ~/.cabal/store
|
||||
asset_name: simplex-chat-macos-x86-64
|
||||
desktop_asset_name: simplex-desktop-macos-x86_64.dmg
|
||||
- os: windows-latest
|
||||
cache_path: C:/cabal
|
||||
asset_name: simplex-chat-windows-x86-64
|
||||
desktop_asset_name: simplex-desktop-windows-x86_64.msi
|
||||
steps:
|
||||
- name: Configure pagefile (Windows)
|
||||
if: matrix.os == 'windows-latest'
|
||||
@@ -99,6 +103,10 @@ jobs:
|
||||
echo " extra-lib-dirs: /usr/local/opt/openssl@1.1/lib" >> cabal.project.local
|
||||
echo " flags: +openssl" >> cabal.project.local
|
||||
|
||||
- name: Install AppImage dependencies
|
||||
if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'ubuntu-20.04'
|
||||
run: sudo apt install -y desktop-file-utils
|
||||
|
||||
- name: Install pkg-config for Mac
|
||||
if: matrix.os == 'macos-latest'
|
||||
run: brew install pkg-config
|
||||
@@ -111,23 +119,86 @@ jobs:
|
||||
echo "package direct-sqlcipher" >> cabal.project.local
|
||||
echo " flags: +openssl" >> cabal.project.local
|
||||
|
||||
- name: Unix build
|
||||
id: unix_build
|
||||
- name: Unix build CLI
|
||||
id: unix_cli_build
|
||||
if: matrix.os != 'windows-latest'
|
||||
shell: bash
|
||||
run: |
|
||||
cabal build --enable-tests
|
||||
echo "::set-output name=bin_path::$(cabal list-bin simplex-chat)"
|
||||
|
||||
- name: Unix upload binary to release
|
||||
- name: Unix upload CLI binary to release
|
||||
if: startsWith(github.ref, 'refs/tags/v') && matrix.os != 'windows-latest'
|
||||
uses: svenstaro/upload-release-action@v2
|
||||
with:
|
||||
repo_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
file: ${{ steps.unix_build.outputs.bin_path }}
|
||||
file: ${{ steps.unix_cli_build.outputs.bin_path }}
|
||||
asset_name: ${{ matrix.asset_name }}
|
||||
tag: ${{ github.ref }}
|
||||
|
||||
- name: Setup Java
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
uses: actions/setup-java@v3
|
||||
with:
|
||||
distribution: 'corretto'
|
||||
java-version: '17'
|
||||
cache: 'gradle'
|
||||
|
||||
- name: Linux build desktop
|
||||
id: linux_desktop_build
|
||||
if: startsWith(github.ref, 'refs/tags/v') && (matrix.os == 'ubuntu-20.04' || matrix.os == 'ubuntu-22.04')
|
||||
shell: bash
|
||||
run: |
|
||||
scripts/desktop/build-lib-linux.sh
|
||||
cd apps/multiplatform
|
||||
./gradlew packageDeb
|
||||
echo "::set-output name=package_path::$(echo $PWD/release/main/deb/simplex_*_amd64.deb)"
|
||||
|
||||
- name: Linux make AppImage
|
||||
id: linux_appimage_build
|
||||
if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'ubuntu-20.04'
|
||||
shell: bash
|
||||
run: |
|
||||
scripts/desktop/make-appimage-linux.sh
|
||||
echo "::set-output name=appimage_path::$(echo $PWD/apps/multiplatform/release/main/*imple*.AppImage)"
|
||||
|
||||
- name: Mac build desktop
|
||||
id: mac_desktop_build
|
||||
if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'macos-latest'
|
||||
shell: bash
|
||||
run: |
|
||||
scripts/desktop/build-lib-mac.sh
|
||||
cd apps/multiplatform
|
||||
./gradlew packageDmg
|
||||
echo "::set-output name=package_path::$(echo $PWD/release/main/dmg/SimpleX-*.dmg)"
|
||||
|
||||
- name: Linux upload desktop package to release
|
||||
if: startsWith(github.ref, 'refs/tags/v') && (matrix.os == 'ubuntu-20.04' || matrix.os == 'ubuntu-22.04')
|
||||
uses: svenstaro/upload-release-action@v2
|
||||
with:
|
||||
repo_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
file: ${{ steps.linux_desktop_build.outputs.package_path }}
|
||||
asset_name: ${{ matrix.desktop_asset_name }}
|
||||
tag: ${{ github.ref }}
|
||||
|
||||
- name: Linux upload AppImage to release
|
||||
if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'ubuntu-22.04'
|
||||
uses: svenstaro/upload-release-action@v2
|
||||
with:
|
||||
repo_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
file: ${{ steps.linux_appimage_build.outputs.appimage_path }}
|
||||
asset_name: simplex-desktop-x86_64.AppImage
|
||||
tag: ${{ github.ref }}
|
||||
|
||||
- name: Mac upload desktop package to release
|
||||
if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'macos-latest'
|
||||
uses: svenstaro/upload-release-action@v2
|
||||
with:
|
||||
repo_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
file: ${{ steps.mac_desktop_build.outputs.package_path }}
|
||||
asset_name: ${{ matrix.desktop_asset_name }}
|
||||
tag: ${{ github.ref }}
|
||||
|
||||
- name: Unix test
|
||||
if: matrix.os != 'windows-latest'
|
||||
timeout-minutes: 30
|
||||
|
||||
@@ -53,6 +53,7 @@ website/src/docs/
|
||||
website/translations.json
|
||||
website/src/img/images/
|
||||
website/src/images/
|
||||
website/src/js/lottie.min.js
|
||||
# Generated files
|
||||
website/package/generated*
|
||||
|
||||
|
||||
@@ -171,6 +171,12 @@ func apiSetUserContactReceipts(_ userId: Int64, userMsgReceiptSettings: UserMsgR
|
||||
throw r
|
||||
}
|
||||
|
||||
func apiSetUserGroupReceipts(_ userId: Int64, userMsgReceiptSettings: UserMsgReceiptSettings) async throws {
|
||||
let r = await chatSendCmd(.apiSetUserGroupReceipts(userId: userId, userMsgReceiptSettings: userMsgReceiptSettings))
|
||||
if case .cmdOk = r { return }
|
||||
throw r
|
||||
}
|
||||
|
||||
func apiHideUser(_ userId: Int64, viewPwd: String) async throws -> User {
|
||||
try await setUserPrivacy_(.apiHideUser(userId: userId, viewPwd: viewPwd))
|
||||
}
|
||||
@@ -796,29 +802,35 @@ func apiChatUnread(type: ChatType, id: Int64, unreadChat: Bool) async throws {
|
||||
try await sendCommandOkResp(.apiChatUnread(type: type, id: id, unreadChat: unreadChat))
|
||||
}
|
||||
|
||||
func receiveFile(user: User, fileId: Int64) async {
|
||||
if let chatItem = await apiReceiveFile(fileId: fileId) {
|
||||
func receiveFile(user: User, fileId: Int64, auto: Bool = false) async {
|
||||
if let chatItem = await apiReceiveFile(fileId: fileId, auto: auto) {
|
||||
DispatchQueue.main.async { chatItemSimpleUpdate(user, chatItem) }
|
||||
}
|
||||
}
|
||||
|
||||
func apiReceiveFile(fileId: Int64, inline: Bool? = nil) async -> AChatItem? {
|
||||
func apiReceiveFile(fileId: Int64, inline: Bool? = nil, auto: Bool = false) async -> AChatItem? {
|
||||
let r = await chatSendCmd(.receiveFile(fileId: fileId, inline: inline))
|
||||
let am = AlertManager.shared
|
||||
if case let .rcvFileAccepted(_, chatItem) = r { return chatItem }
|
||||
if case .rcvFileAcceptedSndCancelled = r {
|
||||
am.showAlertMsg(
|
||||
title: "Cannot receive file",
|
||||
message: "Sender cancelled file transfer."
|
||||
)
|
||||
logger.debug("apiReceiveFile error: sender cancelled file transfer")
|
||||
if !auto {
|
||||
am.showAlertMsg(
|
||||
title: "Cannot receive file",
|
||||
message: "Sender cancelled file transfer."
|
||||
)
|
||||
}
|
||||
} else if let networkErrorAlert = networkErrorAlert(r) {
|
||||
logger.error("apiReceiveFile network error: \(String(describing: r))")
|
||||
am.showAlert(networkErrorAlert)
|
||||
} else {
|
||||
logger.error("apiReceiveFile error: \(String(describing: r))")
|
||||
switch r {
|
||||
case .chatCmdError(_, .error(.fileAlreadyReceiving)):
|
||||
switch chatError(r) {
|
||||
case .fileCancelled:
|
||||
logger.debug("apiReceiveFile ignoring fileCancelled error")
|
||||
case .fileAlreadyReceiving:
|
||||
logger.debug("apiReceiveFile ignoring fileAlreadyReceiving error")
|
||||
default:
|
||||
logger.error("apiReceiveFile error: \(String(describing: r))")
|
||||
am.showAlertMsg(
|
||||
title: "Error receiving file",
|
||||
message: "Error: \(String(describing: r))"
|
||||
@@ -1317,7 +1329,7 @@ func processReceivedMsg(_ res: ChatResponse) async {
|
||||
}
|
||||
if let file = cItem.autoReceiveFile() {
|
||||
Task {
|
||||
await receiveFile(user: user, fileId: file.fileId)
|
||||
await receiveFile(user: user, fileId: file.fileId, auto: true)
|
||||
}
|
||||
}
|
||||
if cItem.showNotification {
|
||||
|
||||
@@ -197,7 +197,7 @@ struct CIFileView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
let sentFile: ChatItem = ChatItem(
|
||||
chatDir: .directSnd,
|
||||
meta: CIMeta.getSample(1, .now, "", .sndSent, itemEdited: true),
|
||||
meta: CIMeta.getSample(1, .now, "", .sndSent(sndProgress: .complete), itemEdited: true),
|
||||
content: .sndMsgContent(msgContent: .file("")),
|
||||
quotedItem: nil,
|
||||
file: CIFile.getSample(fileStatus: .sndComplete)
|
||||
|
||||
@@ -13,6 +13,7 @@ struct CIMetaView: View {
|
||||
@EnvironmentObject var chat: Chat
|
||||
var chatItem: ChatItem
|
||||
var metaColor = Color.secondary
|
||||
var paleMetaColor = Color(UIColor.tertiaryLabel)
|
||||
|
||||
var body: some View {
|
||||
if chatItem.isDeletedContent {
|
||||
@@ -21,12 +22,23 @@ struct CIMetaView: View {
|
||||
let meta = chatItem.meta
|
||||
let ttl = chat.chatInfo.timedMessagesTTL
|
||||
switch meta.itemStatus {
|
||||
case .sndSent:
|
||||
ciMetaText(meta, chatTTL: ttl, color: metaColor, sent: .sent)
|
||||
case .sndRcvd:
|
||||
ZStack {
|
||||
ciMetaText(meta, chatTTL: ttl, color: metaColor, sent: .rcvd1)
|
||||
ciMetaText(meta, chatTTL: ttl, color: metaColor, sent: .rcvd2)
|
||||
case let .sndSent(sndProgress):
|
||||
switch sndProgress {
|
||||
case .complete: ciMetaText(meta, chatTTL: ttl, color: metaColor, sent: .sent)
|
||||
case .partial: ciMetaText(meta, chatTTL: ttl, color: paleMetaColor, sent: .sent)
|
||||
}
|
||||
case let .sndRcvd(_, sndProgress):
|
||||
switch sndProgress {
|
||||
case .complete:
|
||||
ZStack {
|
||||
ciMetaText(meta, chatTTL: ttl, color: metaColor, sent: .rcvd1)
|
||||
ciMetaText(meta, chatTTL: ttl, color: metaColor, sent: .rcvd2)
|
||||
}
|
||||
case .partial:
|
||||
ZStack {
|
||||
ciMetaText(meta, chatTTL: ttl, color: paleMetaColor, sent: .rcvd1)
|
||||
ciMetaText(meta, chatTTL: ttl, color: paleMetaColor, sent: .rcvd2)
|
||||
}
|
||||
}
|
||||
default:
|
||||
ciMetaText(meta, chatTTL: ttl, color: metaColor)
|
||||
@@ -61,7 +73,7 @@ func ciMetaText(_ meta: CIMeta, chatTTL: Int?, color: Color = .clear, transparen
|
||||
switch sent {
|
||||
case nil: r = r + t1
|
||||
case .sent: r = r + t1 + gap
|
||||
case .rcvd1: r = r + t.foregroundColor(transparent ? .clear : color.opacity(0.67)) + gap
|
||||
case .rcvd1: r = r + t.foregroundColor(transparent ? .clear : statusColor.opacity(0.67)) + gap
|
||||
case .rcvd2: r = r + gap + t1
|
||||
}
|
||||
r = r + Text(" ")
|
||||
@@ -78,8 +90,12 @@ private func statusIconText(_ icon: String, _ color: Color) -> Text {
|
||||
struct CIMetaView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
Group {
|
||||
CIMetaView(chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent))
|
||||
CIMetaView(chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent, itemEdited: true))
|
||||
CIMetaView(chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent(sndProgress: .complete)))
|
||||
CIMetaView(chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent(sndProgress: .partial)))
|
||||
CIMetaView(chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndRcvd(msgRcptStatus: .ok, sndProgress: .complete)))
|
||||
CIMetaView(chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndRcvd(msgRcptStatus: .ok, sndProgress: .partial)))
|
||||
CIMetaView(chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndRcvd(msgRcptStatus: .badMsgHash, sndProgress: .complete)))
|
||||
CIMetaView(chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent(sndProgress: .complete), itemEdited: true))
|
||||
CIMetaView(chatItem: ChatItem.getDeletedContentSample())
|
||||
}
|
||||
.previewLayout(.fixed(width: 360, height: 100))
|
||||
|
||||
@@ -247,10 +247,10 @@ struct CIVideoView: View {
|
||||
.padding([.trailing, .top], 11)
|
||||
}
|
||||
|
||||
private func receiveFileIfValidSize(file: CIFile, receiveFile: @escaping (User, Int64) async -> Void) {
|
||||
private func receiveFileIfValidSize(file: CIFile, receiveFile: @escaping (User, Int64, Bool) async -> Void) {
|
||||
Task {
|
||||
if let user = ChatModel.shared.currentUser {
|
||||
await receiveFile(user, file.fileId)
|
||||
await receiveFile(user, file.fileId, false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -269,7 +269,7 @@ struct CIVoiceView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
let sentVoiceMessage: ChatItem = ChatItem(
|
||||
chatDir: .directSnd,
|
||||
meta: CIMeta.getSample(1, .now, "", .sndSent, itemEdited: true),
|
||||
meta: CIMeta.getSample(1, .now, "", .sndSent(sndProgress: .complete), itemEdited: true),
|
||||
content: .sndMsgContent(msgContent: .voice(text: "", duration: 30)),
|
||||
quotedItem: nil,
|
||||
file: CIFile.getSample(fileStatus: .sndComplete)
|
||||
|
||||
@@ -32,7 +32,7 @@ func emojiText(_ text: String) -> Text {
|
||||
struct EmojiItemView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
Group{
|
||||
EmojiItemView(chatItem: ChatItem.getSample(1, .directSnd, .now, "🙂", .sndSent))
|
||||
EmojiItemView(chatItem: ChatItem.getSample(1, .directSnd, .now, "🙂", .sndSent(sndProgress: .complete)))
|
||||
EmojiItemView(chatItem: ChatItem.getSample(2, .directRcv, .now, "👍"))
|
||||
}
|
||||
.previewLayout(.fixed(width: 360, height: 70))
|
||||
|
||||
@@ -75,14 +75,14 @@ struct FramedCIVoiceView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
let sentVoiceMessage: ChatItem = ChatItem(
|
||||
chatDir: .directSnd,
|
||||
meta: CIMeta.getSample(1, .now, "", .sndSent, itemEdited: true),
|
||||
meta: CIMeta.getSample(1, .now, "", .sndSent(sndProgress: .complete), itemEdited: true),
|
||||
content: .sndMsgContent(msgContent: .voice(text: "Hello there", duration: 30)),
|
||||
quotedItem: nil,
|
||||
file: CIFile.getSample(fileStatus: .sndComplete)
|
||||
)
|
||||
let voiceMessageWithQuote: ChatItem = ChatItem(
|
||||
chatDir: .directSnd,
|
||||
meta: CIMeta.getSample(1, .now, "", .sndSent, itemEdited: true),
|
||||
meta: CIMeta.getSample(1, .now, "", .sndSent(sndProgress: .complete), itemEdited: true),
|
||||
content: .sndMsgContent(msgContent: .voice(text: "", duration: 30)),
|
||||
quotedItem: CIQuote.getSample(1, .now, "Hi", chatDir: .directRcv),
|
||||
file: CIFile.getSample(fileStatus: .sndComplete)
|
||||
|
||||
@@ -349,8 +349,8 @@ struct FramedItemView_Previews: PreviewProvider {
|
||||
Group{
|
||||
FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello"), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
|
||||
FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello", quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directSnd)), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
|
||||
FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent, quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directRcv)), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
|
||||
FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directSnd, .now, "👍", .sndSent, quotedItem: CIQuote.getSample(1, .now, "Hello too", chatDir: .directRcv)), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
|
||||
FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent(sndProgress: .complete), quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directRcv)), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
|
||||
FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directSnd, .now, "👍", .sndSent(sndProgress: .complete), quotedItem: CIQuote.getSample(1, .now, "Hello too", chatDir: .directRcv)), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
|
||||
FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this covers -"), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
|
||||
FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this text has the time on the same line "), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
|
||||
FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directRcv, .now, "https://simplex.chat"), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
|
||||
@@ -363,10 +363,10 @@ struct FramedItemView_Previews: PreviewProvider {
|
||||
struct FramedItemView_Edited_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
Group {
|
||||
FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent, itemEdited: true), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
|
||||
FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemEdited: true), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
|
||||
FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello", quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directSnd), itemEdited: true), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
|
||||
FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent, quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directRcv), itemEdited: true), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
|
||||
FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directSnd, .now, "👍", .sndSent, quotedItem: CIQuote.getSample(1, .now, "Hello too", chatDir: .directRcv), itemEdited: true), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
|
||||
FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent(sndProgress: .complete), quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directRcv), itemEdited: true), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
|
||||
FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directSnd, .now, "👍", .sndSent(sndProgress: .complete), quotedItem: CIQuote.getSample(1, .now, "Hello too", chatDir: .directRcv), itemEdited: true), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
|
||||
FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this covers -", .rcvRead, itemEdited: true), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
|
||||
FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this text has the time on the same line ", .rcvRead, itemEdited: true), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
|
||||
FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directRcv, .now, "https://simplex.chat", .rcvRead, itemEdited: true), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
|
||||
@@ -381,10 +381,10 @@ struct FramedItemView_Edited_Previews: PreviewProvider {
|
||||
struct FramedItemView_Deleted_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
Group {
|
||||
FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent, itemDeleted: .deleted(deletedTs: .now)), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
|
||||
FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemDeleted: .deleted(deletedTs: .now)), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
|
||||
FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello", quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directSnd), itemDeleted: .deleted(deletedTs: .now)), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
|
||||
FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent, quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directRcv), itemDeleted: .deleted(deletedTs: .now)), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
|
||||
FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directSnd, .now, "👍", .sndSent, quotedItem: CIQuote.getSample(1, .now, "Hello too", chatDir: .directRcv), itemDeleted: .deleted(deletedTs: .now)), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
|
||||
FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent(sndProgress: .complete), quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directRcv), itemDeleted: .deleted(deletedTs: .now)), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
|
||||
FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directSnd, .now, "👍", .sndSent(sndProgress: .complete), quotedItem: CIQuote.getSample(1, .now, "Hello too", chatDir: .directRcv), itemDeleted: .deleted(deletedTs: .now)), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
|
||||
FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this covers -", .rcvRead, itemDeleted: .deleted(deletedTs: .now)), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
|
||||
FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this text has the time on the same line ", .rcvRead, itemDeleted: .deleted(deletedTs: .now)), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
|
||||
FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directRcv, .now, "https://simplex.chat", .rcvRead, itemDeleted: .deleted(deletedTs: .now)), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
|
||||
|
||||
@@ -46,7 +46,7 @@ struct MarkedDeletedItemView: View {
|
||||
struct MarkedDeletedItemView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
Group {
|
||||
MarkedDeletedItemView(chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent, itemDeleted: .deleted(deletedTs: .now)))
|
||||
MarkedDeletedItemView(chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemDeleted: .deleted(deletedTs: .now)))
|
||||
}
|
||||
.previewLayout(.fixed(width: 360, height: 200))
|
||||
}
|
||||
|
||||
@@ -87,7 +87,7 @@ struct MsgContentView: View {
|
||||
func messageText(_ text: String, _ formattedText: [FormattedText]?, _ sender: String?, icon: String? = nil, preview: Bool = false) -> Text {
|
||||
let s = text
|
||||
var res: Text
|
||||
if let ft = formattedText, ft.count > 0 {
|
||||
if let ft = formattedText, ft.count > 0 && ft.count <= 200 {
|
||||
res = formatText(ft[0], preview)
|
||||
var i = 1
|
||||
while i < ft.count {
|
||||
|
||||
@@ -14,11 +14,23 @@ struct ChatItemInfoView: View {
|
||||
var ci: ChatItem
|
||||
@Binding var chatItemInfo: ChatItemInfo?
|
||||
@State private var selection: CIInfoTab = .history
|
||||
@State private var alert: CIInfoViewAlert? = nil
|
||||
@AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false
|
||||
|
||||
enum CIInfoTab {
|
||||
case history
|
||||
case quote
|
||||
case delivery
|
||||
}
|
||||
|
||||
enum CIInfoViewAlert: Identifiable {
|
||||
case deliveryStatusAlert(status: CIStatus)
|
||||
|
||||
var id: String {
|
||||
switch self {
|
||||
case .deliveryStatusAlert: return "deliveryStatusAlert"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@@ -31,6 +43,11 @@ struct ChatItemInfoView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.alert(item: $alert) { alertItem in
|
||||
switch(alertItem) {
|
||||
case let .deliveryStatusAlert(status): return deliveryStatusAlert(status)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,19 +57,44 @@ struct ChatItemInfoView: View {
|
||||
: NSLocalizedString("Received message", comment: "message info title")
|
||||
}
|
||||
|
||||
private var numTabs: Int {
|
||||
var numTabs = 1
|
||||
if chatItemInfo?.memberDeliveryStatuses != nil {
|
||||
numTabs += 1
|
||||
}
|
||||
if ci.quotedItem != nil {
|
||||
numTabs += 1
|
||||
}
|
||||
return numTabs
|
||||
}
|
||||
|
||||
@ViewBuilder private func itemInfoView() -> some View {
|
||||
if let qi = ci.quotedItem {
|
||||
if numTabs > 1 {
|
||||
TabView(selection: $selection) {
|
||||
if let mdss = chatItemInfo?.memberDeliveryStatuses {
|
||||
deliveryTab(mdss)
|
||||
.tabItem {
|
||||
Label("Delivery", systemImage: "checkmark.message")
|
||||
}
|
||||
.tag(CIInfoTab.delivery)
|
||||
}
|
||||
historyTab()
|
||||
.tabItem {
|
||||
Label("History", systemImage: "clock")
|
||||
}
|
||||
.tag(CIInfoTab.history)
|
||||
quoteTab(qi)
|
||||
.tabItem {
|
||||
Label("In reply to", systemImage: "arrowshape.turn.up.left")
|
||||
}
|
||||
.tag(CIInfoTab.quote)
|
||||
if let qi = ci.quotedItem {
|
||||
quoteTab(qi)
|
||||
.tabItem {
|
||||
Label("In reply to", systemImage: "arrowshape.turn.up.left")
|
||||
}
|
||||
.tag(CIInfoTab.quote)
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
if chatItemInfo?.memberDeliveryStatuses != nil {
|
||||
selection = .delivery
|
||||
}
|
||||
}
|
||||
} else {
|
||||
historyTab()
|
||||
@@ -217,9 +259,89 @@ struct ChatItemInfoView: View {
|
||||
: Color(uiColor: .tertiarySystemGroupedBackground)
|
||||
}
|
||||
|
||||
@ViewBuilder private func deliveryTab(_ memberDeliveryStatuses: [MemberDeliveryStatus]) -> some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
details()
|
||||
Divider().padding(.vertical)
|
||||
Text("Delivery")
|
||||
.font(.title2)
|
||||
.padding(.bottom, 4)
|
||||
memberDeliveryStatusesView(memberDeliveryStatuses)
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
.frame(maxHeight: .infinity, alignment: .top)
|
||||
}
|
||||
|
||||
@ViewBuilder private func memberDeliveryStatusesView(_ memberDeliveryStatuses: [MemberDeliveryStatus]) -> some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
let mss = membersStatuses(memberDeliveryStatuses)
|
||||
if !mss.isEmpty {
|
||||
ForEach(mss, id: \.0.groupMemberId) { memberStatus in
|
||||
memberDeliveryStatusView(memberStatus.0, memberStatus.1)
|
||||
}
|
||||
} else {
|
||||
Text("No info on delivery")
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func membersStatuses(_ memberDeliveryStatuses: [MemberDeliveryStatus]) -> [(GroupMember, CIStatus)] {
|
||||
memberDeliveryStatuses.compactMap({ mds in
|
||||
if let mem = ChatModel.shared.groupMembers.first(where: { $0.groupMemberId == mds.groupMemberId }) {
|
||||
return (mem, mds.memberDeliveryStatus)
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private func memberDeliveryStatusView(_ member: GroupMember, _ status: CIStatus) -> some View {
|
||||
HStack{
|
||||
ProfileImage(imageStr: member.image)
|
||||
.frame(width: 30, height: 30)
|
||||
.padding(.trailing, 2)
|
||||
Text(member.chatViewName)
|
||||
.lineLimit(1)
|
||||
Spacer()
|
||||
Group {
|
||||
if let (icon, statusColor) = status.statusIcon(Color.secondary) {
|
||||
switch status {
|
||||
case .sndRcvd:
|
||||
ZStack(alignment: .trailing) {
|
||||
Image(systemName: icon)
|
||||
.foregroundColor(statusColor.opacity(0.67))
|
||||
.padding(.trailing, 6)
|
||||
Image(systemName: icon)
|
||||
.foregroundColor(statusColor.opacity(0.67))
|
||||
}
|
||||
default:
|
||||
Image(systemName: icon)
|
||||
.foregroundColor(statusColor)
|
||||
}
|
||||
} else {
|
||||
Image(systemName: "ellipsis")
|
||||
.foregroundColor(Color.secondary)
|
||||
}
|
||||
}
|
||||
.onTapGesture {
|
||||
alert = .deliveryStatusAlert(status: status)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func deliveryStatusAlert(_ status: CIStatus) -> Alert {
|
||||
Alert(
|
||||
title: Text(status.statusText),
|
||||
message: Text(status.statusDescription)
|
||||
)
|
||||
}
|
||||
|
||||
private func itemInfoShareText() -> String {
|
||||
let meta = ci.meta
|
||||
var shareText: [String] = [title, ""]
|
||||
var shareText: [String] = [String.localizedStringWithFormat(NSLocalizedString("# %@", comment: "copied message info title, # <title>"), title), ""]
|
||||
shareText += [String.localizedStringWithFormat(NSLocalizedString("Sent at: %@", comment: "copied message info"), localTimestamp(meta.itemTs))]
|
||||
if !ci.chatDir.sent {
|
||||
shareText += [String.localizedStringWithFormat(NSLocalizedString("Received at: %@", comment: "copied message info"), localTimestamp(meta.createdAt))]
|
||||
@@ -245,7 +367,7 @@ struct ChatItemInfoView: View {
|
||||
]
|
||||
}
|
||||
if let qi = ci.quotedItem {
|
||||
shareText += ["", NSLocalizedString("In reply to", comment: "copied message info")]
|
||||
shareText += ["", NSLocalizedString("## In reply to", comment: "copied message info")]
|
||||
let t = qi.text
|
||||
shareText += [""]
|
||||
if let sender = qi.getSender(nil) {
|
||||
@@ -262,9 +384,23 @@ struct ChatItemInfoView: View {
|
||||
}
|
||||
shareText += [t != "" ? t : NSLocalizedString("no text", comment: "copied message info in history")]
|
||||
}
|
||||
if let mdss = chatItemInfo?.memberDeliveryStatuses {
|
||||
let mss = membersStatuses(mdss)
|
||||
if !mss.isEmpty {
|
||||
shareText += ["", NSLocalizedString("## Delivery", comment: "copied message info")]
|
||||
shareText += [""]
|
||||
for (member, status) in mss {
|
||||
shareText += [String.localizedStringWithFormat(
|
||||
NSLocalizedString("%@: %@", comment: "copied message info, <recipient>: <message delivery status description>"),
|
||||
member.chatViewName,
|
||||
status.statusDescription
|
||||
)]
|
||||
}
|
||||
}
|
||||
}
|
||||
if let chatItemInfo = chatItemInfo,
|
||||
!chatItemInfo.itemVersions.isEmpty {
|
||||
shareText += ["", NSLocalizedString("History", comment: "copied message info")]
|
||||
shareText += ["", NSLocalizedString("## History", comment: "copied message info")]
|
||||
for (index, itemVersion) in chatItemInfo.itemVersions.enumerated() {
|
||||
let t = itemVersion.msgContent.text
|
||||
shareText += [
|
||||
|
||||
@@ -125,9 +125,9 @@ struct ChatItemView_Previews: PreviewProvider {
|
||||
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directRcv, .now, "🙂🙂🙂🙂🙂"), revealed: Binding.constant(false))
|
||||
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directRcv, .now, "🙂🙂🙂🙂🙂🙂"), revealed: Binding.constant(false))
|
||||
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getDeletedContentSample(), revealed: Binding.constant(false))
|
||||
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent, itemDeleted: .deleted(deletedTs: .now)), revealed: Binding.constant(false))
|
||||
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(1, .directSnd, .now, "🙂", .sndSent, itemLive: true), revealed: Binding.constant(true))
|
||||
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent, itemLive: true), revealed: Binding.constant(true))
|
||||
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemDeleted: .deleted(deletedTs: .now)), revealed: Binding.constant(false))
|
||||
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(1, .directSnd, .now, "🙂", .sndSent(sndProgress: .complete), itemLive: true), revealed: Binding.constant(true))
|
||||
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemLive: true), revealed: Binding.constant(true))
|
||||
}
|
||||
.previewLayout(.fixed(width: 360, height: 70))
|
||||
.environmentObject(Chat.sampleData)
|
||||
|
||||
@@ -770,6 +770,12 @@ struct ChatView: View {
|
||||
await MainActor.run {
|
||||
chatItemInfo = ciInfo
|
||||
}
|
||||
if case let .group(gInfo) = chat.chatInfo {
|
||||
let groupMembers = await apiListMembers(gInfo.groupId)
|
||||
await MainActor.run {
|
||||
ChatModel.shared.groupMembers = groupMembers
|
||||
}
|
||||
}
|
||||
} catch let error {
|
||||
logger.error("apiGetChatItemInfo error: \(responseError(error))")
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
import SwiftUI
|
||||
import SimpleXChat
|
||||
|
||||
let SMALL_GROUPS_RCPS_MEM_LIMIT: Int = 20
|
||||
|
||||
struct GroupChatInfoView: View {
|
||||
@EnvironmentObject var chatModel: ChatModel
|
||||
@Environment(\.dismiss) var dismiss: DismissAction
|
||||
@@ -21,6 +23,8 @@ struct GroupChatInfoView: View {
|
||||
@State private var showAddMembersSheet: Bool = false
|
||||
@State private var connectionStats: ConnectionStats?
|
||||
@State private var connectionCode: String?
|
||||
@State private var sendReceipts = SendReceipts.userDefault(true)
|
||||
@State private var sendReceiptsUserDefault = true
|
||||
@AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false
|
||||
@State private var searchText: String = ""
|
||||
@FocusState private var searchFocussed
|
||||
@@ -30,6 +34,7 @@ struct GroupChatInfoView: View {
|
||||
case clearChatAlert
|
||||
case leaveGroupAlert
|
||||
case cantInviteIncognitoAlert
|
||||
case largeGroupReceiptsDisabled
|
||||
|
||||
var id: GroupChatInfoViewAlert { get { self } }
|
||||
}
|
||||
@@ -52,6 +57,11 @@ struct GroupChatInfoView: View {
|
||||
addOrEditWelcomeMessage()
|
||||
}
|
||||
groupPreferencesButton($groupInfo)
|
||||
if members.filter { $0.memberCurrent }.count <= SMALL_GROUPS_RCPS_MEM_LIMIT {
|
||||
sendReceiptsOption()
|
||||
} else {
|
||||
sendReceiptsOptionDisabled()
|
||||
}
|
||||
} header: {
|
||||
Text("")
|
||||
} footer: {
|
||||
@@ -115,9 +125,14 @@ struct GroupChatInfoView: View {
|
||||
case .clearChatAlert: return clearChatAlert()
|
||||
case .leaveGroupAlert: return leaveGroupAlert()
|
||||
case .cantInviteIncognitoAlert: return cantInviteIncognitoAlert()
|
||||
case .largeGroupReceiptsDisabled: return largeGroupReceiptsDisabledAlert()
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
if let currentUser = chatModel.currentUser {
|
||||
sendReceiptsUserDefault = currentUser.sendRcptsSmallGroups
|
||||
}
|
||||
sendReceipts = SendReceipts.fromBool(groupInfo.chatSettings.sendRcpts, userDefault: sendReceiptsUserDefault)
|
||||
do {
|
||||
if let link = try apiGetGroupLink(groupInfo.groupId) {
|
||||
(groupLink, groupLinkMemberRole) = link
|
||||
@@ -328,6 +343,38 @@ struct GroupChatInfoView: View {
|
||||
secondaryButton: .cancel()
|
||||
)
|
||||
}
|
||||
|
||||
private func sendReceiptsOption() -> some View {
|
||||
Picker(selection: $sendReceipts) {
|
||||
ForEach([.yes, .no, .userDefault(sendReceiptsUserDefault)]) { (opt: SendReceipts) in
|
||||
Text(opt.text)
|
||||
}
|
||||
} label: {
|
||||
Label("Send receipts", systemImage: "checkmark.message")
|
||||
}
|
||||
.frame(height: 36)
|
||||
.onChange(of: sendReceipts) { _ in
|
||||
setSendReceipts()
|
||||
}
|
||||
}
|
||||
|
||||
private func setSendReceipts() {
|
||||
var chatSettings = chat.chatInfo.chatSettings ?? ChatSettings.defaults
|
||||
chatSettings.sendRcpts = sendReceipts.bool()
|
||||
updateChatSettings(chat, chatSettings: chatSettings)
|
||||
}
|
||||
|
||||
private func sendReceiptsOptionDisabled() -> some View {
|
||||
HStack {
|
||||
Label("Send receipts", systemImage: "checkmark.message")
|
||||
Spacer()
|
||||
Text("disabled")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.onTapGesture {
|
||||
alert = .largeGroupReceiptsDisabled
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func groupPreferencesButton(_ groupInfo: Binding<GroupInfo>, _ creatingGroup: Bool = false) -> some View {
|
||||
@@ -356,6 +403,13 @@ func cantInviteIncognitoAlert() -> Alert {
|
||||
)
|
||||
}
|
||||
|
||||
func largeGroupReceiptsDisabledAlert() -> Alert {
|
||||
Alert(
|
||||
title: Text("Receipts are disabled"),
|
||||
message: Text("This group has over \(SMALL_GROUPS_RCPS_MEM_LIMIT) members, delivery receipts are not sent.")
|
||||
)
|
||||
}
|
||||
|
||||
struct GroupChatInfoView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
GroupChatInfoView(chat: Chat(chatInfo: ChatInfo.sampleData.group, chatItems: []), groupInfo: GroupInfo.sampleData)
|
||||
|
||||
@@ -28,6 +28,7 @@ struct GroupMemberInfoView: View {
|
||||
case switchAddressAlert
|
||||
case abortSwitchAddressAlert
|
||||
case syncConnectionForceAlert
|
||||
case connectViaMemberAddressAlert(contactLink: String)
|
||||
case connRequestSentAlert(type: ConnReqType)
|
||||
case error(title: LocalizedStringKey, error: LocalizedStringKey)
|
||||
case other(alert: Alert)
|
||||
@@ -38,8 +39,9 @@ struct GroupMemberInfoView: View {
|
||||
case let .changeMemberRoleAlert(_, role): return "changeMemberRoleAlert \(role.rawValue)"
|
||||
case .switchAddressAlert: return "switchAddressAlert"
|
||||
case .abortSwitchAddressAlert: return "abortSwitchAddressAlert"
|
||||
case .connRequestSentAlert: return "connRequestSentAlert"
|
||||
case .syncConnectionForceAlert: return "syncConnectionForceAlert"
|
||||
case .connectViaMemberAddressAlert: return "connectViaMemberAddressAlert"
|
||||
case .connRequestSentAlert: return "connRequestSentAlert"
|
||||
case let .error(title, _): return "error \(title)"
|
||||
case let .other(alert): return "other \(alert)"
|
||||
}
|
||||
@@ -201,6 +203,7 @@ struct GroupMemberInfoView: View {
|
||||
case .switchAddressAlert: return switchAddressAlert(switchMemberAddress)
|
||||
case .abortSwitchAddressAlert: return abortSwitchAddressAlert(abortSwitchMemberAddress)
|
||||
case .syncConnectionForceAlert: return syncConnectionForceAlert({ syncMemberConnection(force: true) })
|
||||
case let .connectViaMemberAddressAlert(contactLink): return connectViaMemberAddressAlert(contactLink)
|
||||
case let .connRequestSentAlert(type): return connReqSentAlert(type)
|
||||
case let .error(title, error): return Alert(title: Text(title), message: Text(error))
|
||||
case let .other(alert): return alert
|
||||
@@ -210,12 +213,23 @@ struct GroupMemberInfoView: View {
|
||||
|
||||
func connectViaAddressButton(_ contactLink: String) -> some View {
|
||||
Button {
|
||||
connectViaAddress(contactLink)
|
||||
alert = .connectViaMemberAddressAlert(contactLink: contactLink)
|
||||
} label: {
|
||||
Label("Connect", systemImage: "link")
|
||||
}
|
||||
}
|
||||
|
||||
func connectViaMemberAddressAlert(_ contactLink: String) -> Alert {
|
||||
return Alert(
|
||||
title: Text("Connect directly?"),
|
||||
message: Text("Сonnection request will be sent to this group member."),
|
||||
primaryButton: .default(Text("Connect")) {
|
||||
connectViaAddress(contactLink)
|
||||
},
|
||||
secondaryButton: .cancel()
|
||||
)
|
||||
}
|
||||
|
||||
func connectViaAddress(_ contactLink: String) {
|
||||
Task {
|
||||
let (connReqType, connectAlert) = await apiConnect_(connReq: contactLink)
|
||||
|
||||
@@ -258,20 +258,20 @@ struct ChatPreviewView_Previews: PreviewProvider {
|
||||
))
|
||||
ChatPreviewView(chat: Chat(
|
||||
chatInfo: ChatInfo.sampleData.direct,
|
||||
chatItems: [ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent)]
|
||||
chatItems: [ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete))]
|
||||
))
|
||||
ChatPreviewView(chat: Chat(
|
||||
chatInfo: ChatInfo.sampleData.direct,
|
||||
chatItems: [ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent)],
|
||||
chatItems: [ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete))],
|
||||
chatStats: ChatStats(unreadCount: 11, minUnreadItemId: 0)
|
||||
))
|
||||
ChatPreviewView(chat: Chat(
|
||||
chatInfo: ChatInfo.sampleData.direct,
|
||||
chatItems: [ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent, itemDeleted: .deleted(deletedTs: .now))]
|
||||
chatItems: [ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemDeleted: .deleted(deletedTs: .now))]
|
||||
))
|
||||
ChatPreviewView(chat: Chat(
|
||||
chatInfo: ChatInfo.sampleData.direct,
|
||||
chatItems: [ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent)],
|
||||
chatItems: [ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete))],
|
||||
chatStats: ChatStats(unreadCount: 3, minUnreadItemId: 0)
|
||||
))
|
||||
ChatPreviewView(chat: Chat(
|
||||
|
||||
@@ -21,6 +21,10 @@ struct PrivacySettings: View {
|
||||
@State private var contactReceiptsReset = false
|
||||
@State private var contactReceiptsOverrides = 0
|
||||
@State private var contactReceiptsDialogue = false
|
||||
@State private var groupReceipts = false
|
||||
@State private var groupReceiptsReset = false
|
||||
@State private var groupReceiptsOverrides = 0
|
||||
@State private var groupReceiptsDialogue = false
|
||||
@State private var alert: PrivacySettingsViewAlert?
|
||||
|
||||
enum PrivacySettingsViewAlert: Identifiable {
|
||||
@@ -89,15 +93,15 @@ struct PrivacySettings: View {
|
||||
settingsRow("person") {
|
||||
Toggle("Contacts", isOn: $contactReceipts)
|
||||
}
|
||||
// settingsRow("person.2") {
|
||||
// Toggle("Small groups (max 20)", isOn: Binding.constant(false))
|
||||
// }
|
||||
settingsRow("person.2") {
|
||||
Toggle("Small groups (max 20)", isOn: $groupReceipts)
|
||||
}
|
||||
} header: {
|
||||
Text("Send delivery receipts to")
|
||||
} footer: {
|
||||
VStack(alignment: .leading) {
|
||||
Text("These settings are for your current profile **\(ChatModel.shared.currentUser?.displayName ?? "")**.")
|
||||
Text("They can be overridden in contact settings")
|
||||
Text("They can be overridden in contact and group settings.")
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
@@ -113,19 +117,44 @@ struct PrivacySettings: View {
|
||||
contactReceipts.toggle()
|
||||
}
|
||||
}
|
||||
.confirmationDialog(groupReceiptsDialogTitle, isPresented: $groupReceiptsDialogue, titleVisibility: .visible) {
|
||||
Button(groupReceipts ? "Enable (keep overrides)" : "Disable (keep overrides)") {
|
||||
setSendReceiptsGroups(groupReceipts, clearOverrides: false)
|
||||
}
|
||||
Button(contactReceipts ? "Enable for all" : "Disable for all", role: .destructive) {
|
||||
setSendReceiptsGroups(groupReceipts, clearOverrides: true)
|
||||
}
|
||||
Button("Cancel", role: .cancel) {
|
||||
groupReceiptsReset = true
|
||||
groupReceipts.toggle()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.onChange(of: contactReceipts) { _ in // sometimes there is race with onAppear
|
||||
.onChange(of: contactReceipts) { _ in
|
||||
if contactReceiptsReset {
|
||||
contactReceiptsReset = false
|
||||
} else {
|
||||
setOrAskSendReceiptsContacts(contactReceipts)
|
||||
}
|
||||
}
|
||||
.onChange(of: groupReceipts) { _ in
|
||||
if groupReceiptsReset {
|
||||
groupReceiptsReset = false
|
||||
} else {
|
||||
setOrAskSendReceiptsGroups(groupReceipts)
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
if let u = m.currentUser, contactReceipts != u.sendRcptsContacts {
|
||||
contactReceiptsReset = true
|
||||
contactReceipts = u.sendRcptsContacts
|
||||
if let u = m.currentUser {
|
||||
if contactReceipts != u.sendRcptsContacts {
|
||||
contactReceiptsReset = true
|
||||
contactReceipts = u.sendRcptsContacts
|
||||
}
|
||||
if groupReceipts != u.sendRcptsSmallGroups {
|
||||
groupReceiptsReset = true
|
||||
groupReceipts = u.sendRcptsSmallGroups
|
||||
}
|
||||
}
|
||||
}
|
||||
.alert(item: $alert) { alert in
|
||||
@@ -179,7 +208,55 @@ struct PrivacySettings: View {
|
||||
}
|
||||
}
|
||||
} catch let error {
|
||||
alert = .error(title: "Error setting delivery receipts!", error: "Error: \(responseError(error))")
|
||||
alert = .error(title: "Error setting contact delivery receipts!", error: "Error: \(responseError(error))")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func setOrAskSendReceiptsGroups(_ enable: Bool) {
|
||||
groupReceiptsOverrides = m.chats.reduce(0) { count, chat in
|
||||
let sendRcpts = chat.chatInfo.groupInfo?.chatSettings.sendRcpts
|
||||
return count + (sendRcpts == nil || sendRcpts == enable ? 0 : 1)
|
||||
}
|
||||
if groupReceiptsOverrides == 0 {
|
||||
setSendReceiptsGroups(enable, clearOverrides: false)
|
||||
} else {
|
||||
groupReceiptsDialogue = true
|
||||
}
|
||||
}
|
||||
|
||||
private var groupReceiptsDialogTitle: LocalizedStringKey {
|
||||
groupReceipts
|
||||
? "Sending receipts is disabled for \(groupReceiptsOverrides) groups"
|
||||
: "Sending receipts is enabled for \(groupReceiptsOverrides) groups"
|
||||
}
|
||||
|
||||
private func setSendReceiptsGroups(_ enable: Bool, clearOverrides: Bool) {
|
||||
Task {
|
||||
do {
|
||||
if let currentUser = m.currentUser {
|
||||
let userMsgReceiptSettings = UserMsgReceiptSettings(enable: enable, clearOverrides: clearOverrides)
|
||||
try await apiSetUserGroupReceipts(currentUser.userId, userMsgReceiptSettings: userMsgReceiptSettings)
|
||||
privacyDeliveryReceiptsSet.set(true)
|
||||
await MainActor.run {
|
||||
var updatedUser = currentUser
|
||||
updatedUser.sendRcptsSmallGroups = enable
|
||||
m.updateUser(updatedUser)
|
||||
if clearOverrides {
|
||||
m.chats.forEach { chat in
|
||||
if var groupInfo = chat.chatInfo.groupInfo {
|
||||
let sendRcpts = groupInfo.chatSettings.sendRcpts
|
||||
if sendRcpts != nil && sendRcpts != enable {
|
||||
groupInfo.chatSettings.sendRcpts = nil
|
||||
m.updateGroup(groupInfo)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch let error {
|
||||
alert = .error(title: "Error setting group delivery receipts!", error: "Error: \(responseError(error))")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,11 @@
|
||||
5C00168128C4FE760094D739 /* KeyChain.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C00168028C4FE760094D739 /* KeyChain.swift */; };
|
||||
5C029EA82837DBB3004A9677 /* CICallItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C029EA72837DBB3004A9677 /* CICallItemView.swift */; };
|
||||
5C029EAA283942EA004A9677 /* CallController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C029EA9283942EA004A9677 /* CallController.swift */; };
|
||||
5C0403922A7EAA41006ACFE8 /* libHSsimplex-chat-5.3.0.2-57EsBXX08D1H5qwhz1zMA5-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C04038D2A7EAA41006ACFE8 /* libHSsimplex-chat-5.3.0.2-57EsBXX08D1H5qwhz1zMA5-ghc8.10.7.a */; };
|
||||
5C0403932A7EAA41006ACFE8 /* libHSsimplex-chat-5.3.0.2-57EsBXX08D1H5qwhz1zMA5.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C04038E2A7EAA41006ACFE8 /* libHSsimplex-chat-5.3.0.2-57EsBXX08D1H5qwhz1zMA5.a */; };
|
||||
5C0403942A7EAA41006ACFE8 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C04038F2A7EAA41006ACFE8 /* libffi.a */; };
|
||||
5C0403952A7EAA41006ACFE8 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C0403902A7EAA41006ACFE8 /* libgmp.a */; };
|
||||
5C0403962A7EAA41006ACFE8 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C0403912A7EAA41006ACFE8 /* libgmpxx.a */; };
|
||||
5C05DF532840AA1D00C683F9 /* CallSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C05DF522840AA1D00C683F9 /* CallSettings.swift */; };
|
||||
5C063D2727A4564100AEC577 /* ChatPreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C063D2627A4564100AEC577 /* ChatPreviewView.swift */; };
|
||||
5C10D88828EED12E00E58BF0 /* ContactConnectionInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C10D88728EED12E00E58BF0 /* ContactConnectionInfo.swift */; };
|
||||
@@ -175,11 +180,6 @@
|
||||
64D0C2C229FA57AB00B38D5F /* UserAddressLearnMore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64D0C2C129FA57AB00B38D5F /* UserAddressLearnMore.swift */; };
|
||||
64D0C2C629FAC1EC00B38D5F /* AddContactLearnMore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64D0C2C529FAC1EC00B38D5F /* AddContactLearnMore.swift */; };
|
||||
64E972072881BB22008DBC02 /* CIGroupInvitationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64E972062881BB22008DBC02 /* CIGroupInvitationView.swift */; };
|
||||
64EC94052A77EC4F0025EAA3 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64EC94002A77EC4F0025EAA3 /* libffi.a */; };
|
||||
64EC94062A77EC4F0025EAA3 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64EC94012A77EC4F0025EAA3 /* libgmpxx.a */; };
|
||||
64EC94072A77EC4F0025EAA3 /* libHSsimplex-chat-5.2.1.1-GvH62P2b8AGLxqODv4h64K-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64EC94022A77EC4F0025EAA3 /* libHSsimplex-chat-5.2.1.1-GvH62P2b8AGLxqODv4h64K-ghc8.10.7.a */; };
|
||||
64EC94082A77EC4F0025EAA3 /* libHSsimplex-chat-5.2.1.1-GvH62P2b8AGLxqODv4h64K.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64EC94032A77EC4F0025EAA3 /* libHSsimplex-chat-5.2.1.1-GvH62P2b8AGLxqODv4h64K.a */; };
|
||||
64EC94092A77EC4F0025EAA3 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64EC94042A77EC4F0025EAA3 /* libgmp.a */; };
|
||||
64F1CC3B28B39D8600CD1FB1 /* IncognitoHelp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64F1CC3A28B39D8600CD1FB1 /* IncognitoHelp.swift */; };
|
||||
D7197A1829AE89660055C05A /* WebRTC in Frameworks */ = {isa = PBXBuildFile; productRef = D7197A1729AE89660055C05A /* WebRTC */; };
|
||||
D72A9088294BD7A70047C86D /* NativeTextEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = D72A9087294BD7A70047C86D /* NativeTextEditor.swift */; };
|
||||
@@ -263,6 +263,11 @@
|
||||
5C00168028C4FE760094D739 /* KeyChain.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyChain.swift; sourceTree = "<group>"; };
|
||||
5C029EA72837DBB3004A9677 /* CICallItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CICallItemView.swift; sourceTree = "<group>"; };
|
||||
5C029EA9283942EA004A9677 /* CallController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallController.swift; sourceTree = "<group>"; };
|
||||
5C04038D2A7EAA41006ACFE8 /* libHSsimplex-chat-5.3.0.2-57EsBXX08D1H5qwhz1zMA5-ghc8.10.7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.3.0.2-57EsBXX08D1H5qwhz1zMA5-ghc8.10.7.a"; sourceTree = "<group>"; };
|
||||
5C04038E2A7EAA41006ACFE8 /* libHSsimplex-chat-5.3.0.2-57EsBXX08D1H5qwhz1zMA5.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.3.0.2-57EsBXX08D1H5qwhz1zMA5.a"; sourceTree = "<group>"; };
|
||||
5C04038F2A7EAA41006ACFE8 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = "<group>"; };
|
||||
5C0403902A7EAA41006ACFE8 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = "<group>"; };
|
||||
5C0403912A7EAA41006ACFE8 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = "<group>"; };
|
||||
5C05DF522840AA1D00C683F9 /* CallSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallSettings.swift; sourceTree = "<group>"; };
|
||||
5C063D2627A4564100AEC577 /* ChatPreviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatPreviewView.swift; sourceTree = "<group>"; };
|
||||
5C10D88728EED12E00E58BF0 /* ContactConnectionInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactConnectionInfo.swift; sourceTree = "<group>"; };
|
||||
@@ -454,11 +459,6 @@
|
||||
64D0C2C529FAC1EC00B38D5F /* AddContactLearnMore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddContactLearnMore.swift; sourceTree = "<group>"; };
|
||||
64DAE1502809D9F5000DA960 /* FileUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileUtils.swift; sourceTree = "<group>"; };
|
||||
64E972062881BB22008DBC02 /* CIGroupInvitationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIGroupInvitationView.swift; sourceTree = "<group>"; };
|
||||
64EC94002A77EC4F0025EAA3 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = "<group>"; };
|
||||
64EC94012A77EC4F0025EAA3 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = "<group>"; };
|
||||
64EC94022A77EC4F0025EAA3 /* libHSsimplex-chat-5.2.1.1-GvH62P2b8AGLxqODv4h64K-ghc8.10.7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.2.1.1-GvH62P2b8AGLxqODv4h64K-ghc8.10.7.a"; sourceTree = "<group>"; };
|
||||
64EC94032A77EC4F0025EAA3 /* libHSsimplex-chat-5.2.1.1-GvH62P2b8AGLxqODv4h64K.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.2.1.1-GvH62P2b8AGLxqODv4h64K.a"; sourceTree = "<group>"; };
|
||||
64EC94042A77EC4F0025EAA3 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = "<group>"; };
|
||||
64F1CC3A28B39D8600CD1FB1 /* IncognitoHelp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IncognitoHelp.swift; sourceTree = "<group>"; };
|
||||
D72A9087294BD7A70047C86D /* NativeTextEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeTextEditor.swift; sourceTree = "<group>"; };
|
||||
D741547729AF89AF0022400A /* StoreKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = StoreKit.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS16.1.sdk/System/Library/Frameworks/StoreKit.framework; sourceTree = DEVELOPER_DIR; };
|
||||
@@ -501,13 +501,13 @@
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
64EC94072A77EC4F0025EAA3 /* libHSsimplex-chat-5.2.1.1-GvH62P2b8AGLxqODv4h64K-ghc8.10.7.a in Frameworks */,
|
||||
64EC94092A77EC4F0025EAA3 /* libgmp.a in Frameworks */,
|
||||
64EC94062A77EC4F0025EAA3 /* libgmpxx.a in Frameworks */,
|
||||
5C0403932A7EAA41006ACFE8 /* libHSsimplex-chat-5.3.0.2-57EsBXX08D1H5qwhz1zMA5.a in Frameworks */,
|
||||
5C0403922A7EAA41006ACFE8 /* libHSsimplex-chat-5.3.0.2-57EsBXX08D1H5qwhz1zMA5-ghc8.10.7.a in Frameworks */,
|
||||
5CE2BA93284534B000EC33A6 /* libiconv.tbd in Frameworks */,
|
||||
64EC94082A77EC4F0025EAA3 /* libHSsimplex-chat-5.2.1.1-GvH62P2b8AGLxqODv4h64K.a in Frameworks */,
|
||||
64EC94052A77EC4F0025EAA3 /* libffi.a in Frameworks */,
|
||||
5C0403942A7EAA41006ACFE8 /* libffi.a in Frameworks */,
|
||||
5C0403952A7EAA41006ACFE8 /* libgmp.a in Frameworks */,
|
||||
5CE2BA94284534BB00EC33A6 /* libz.tbd in Frameworks */,
|
||||
5C0403962A7EAA41006ACFE8 /* libgmpxx.a in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -568,11 +568,11 @@
|
||||
5C764E5C279C70B7000C6508 /* Libraries */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
64EC94002A77EC4F0025EAA3 /* libffi.a */,
|
||||
64EC94042A77EC4F0025EAA3 /* libgmp.a */,
|
||||
64EC94012A77EC4F0025EAA3 /* libgmpxx.a */,
|
||||
64EC94022A77EC4F0025EAA3 /* libHSsimplex-chat-5.2.1.1-GvH62P2b8AGLxqODv4h64K-ghc8.10.7.a */,
|
||||
64EC94032A77EC4F0025EAA3 /* libHSsimplex-chat-5.2.1.1-GvH62P2b8AGLxqODv4h64K.a */,
|
||||
5C04038F2A7EAA41006ACFE8 /* libffi.a */,
|
||||
5C0403902A7EAA41006ACFE8 /* libgmp.a */,
|
||||
5C0403912A7EAA41006ACFE8 /* libgmpxx.a */,
|
||||
5C04038D2A7EAA41006ACFE8 /* libHSsimplex-chat-5.3.0.2-57EsBXX08D1H5qwhz1zMA5-ghc8.10.7.a */,
|
||||
5C04038E2A7EAA41006ACFE8 /* libHSsimplex-chat-5.3.0.2-57EsBXX08D1H5qwhz1zMA5.a */,
|
||||
);
|
||||
path = Libraries;
|
||||
sourceTree = "<group>";
|
||||
@@ -1478,7 +1478,7 @@
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 162;
|
||||
CURRENT_PROJECT_VERSION = 164;
|
||||
DEVELOPMENT_TEAM = 5NN7GUYB6T;
|
||||
ENABLE_BITCODE = NO;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
@@ -1499,7 +1499,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 5.2.1;
|
||||
MARKETING_VERSION = 5.3;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app;
|
||||
PRODUCT_NAME = SimpleX;
|
||||
SDKROOT = iphoneos;
|
||||
@@ -1520,7 +1520,7 @@
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 162;
|
||||
CURRENT_PROJECT_VERSION = 164;
|
||||
DEVELOPMENT_TEAM = 5NN7GUYB6T;
|
||||
ENABLE_BITCODE = NO;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
@@ -1541,7 +1541,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 5.2.1;
|
||||
MARKETING_VERSION = 5.3;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app;
|
||||
PRODUCT_NAME = SimpleX;
|
||||
SDKROOT = iphoneos;
|
||||
@@ -1600,7 +1600,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements";
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 162;
|
||||
CURRENT_PROJECT_VERSION = 164;
|
||||
DEVELOPMENT_TEAM = 5NN7GUYB6T;
|
||||
ENABLE_BITCODE = NO;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
@@ -1613,7 +1613,7 @@
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 5.2.1;
|
||||
MARKETING_VERSION = 5.3;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
@@ -1632,7 +1632,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements";
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 162;
|
||||
CURRENT_PROJECT_VERSION = 164;
|
||||
DEVELOPMENT_TEAM = 5NN7GUYB6T;
|
||||
ENABLE_BITCODE = NO;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
@@ -1645,7 +1645,7 @@
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 5.2.1;
|
||||
MARKETING_VERSION = 5.3;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
@@ -1664,7 +1664,7 @@
|
||||
APPLICATION_EXTENSION_API_ONLY = YES;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 160;
|
||||
CURRENT_PROJECT_VERSION = 164;
|
||||
DEFINES_MODULE = YES;
|
||||
DEVELOPMENT_TEAM = 5NN7GUYB6T;
|
||||
DYLIB_COMPATIBILITY_VERSION = 1;
|
||||
@@ -1710,7 +1710,7 @@
|
||||
APPLICATION_EXTENSION_API_ONLY = YES;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 160;
|
||||
CURRENT_PROJECT_VERSION = 164;
|
||||
DEFINES_MODULE = YES;
|
||||
DEVELOPMENT_TEAM = 5NN7GUYB6T;
|
||||
DYLIB_COMPATIBILITY_VERSION = 1;
|
||||
|
||||
@@ -206,7 +206,7 @@ public func responseError(_ err: Error) -> String {
|
||||
switch r {
|
||||
case let .chatCmdError(_, chatError): return chatErrorString(chatError)
|
||||
case let .chatError(_, chatError): return chatErrorString(chatError)
|
||||
default: return String(describing: r)
|
||||
default: return "\(String(describing: r.responseType)), details: \(String(describing: r.details))"
|
||||
}
|
||||
} else {
|
||||
return String(describing: err)
|
||||
|
||||
@@ -19,6 +19,7 @@ public enum ChatCommand {
|
||||
case apiSetActiveUser(userId: Int64, viewPwd: String?)
|
||||
case setAllContactReceipts(enable: Bool)
|
||||
case apiSetUserContactReceipts(userId: Int64, userMsgReceiptSettings: UserMsgReceiptSettings)
|
||||
case apiSetUserGroupReceipts(userId: Int64, userMsgReceiptSettings: UserMsgReceiptSettings)
|
||||
case apiHideUser(userId: Int64, viewPwd: String)
|
||||
case apiUnhideUser(userId: Int64, viewPwd: String)
|
||||
case apiMuteUser(userId: Int64)
|
||||
@@ -127,7 +128,10 @@ public enum ChatCommand {
|
||||
case let .setAllContactReceipts(enable): return "/set receipts all \(onOff(enable))"
|
||||
case let .apiSetUserContactReceipts(userId, userMsgReceiptSettings):
|
||||
let umrs = userMsgReceiptSettings
|
||||
return "/_set receipts \(userId) \(onOff(umrs.enable)) clear_overrides=\(onOff(umrs.clearOverrides))"
|
||||
return "/_set receipts contacts \(userId) \(onOff(umrs.enable)) clear_overrides=\(onOff(umrs.clearOverrides))"
|
||||
case let .apiSetUserGroupReceipts(userId, userMsgReceiptSettings):
|
||||
let umrs = userMsgReceiptSettings
|
||||
return "/_set receipts groups \(userId) \(onOff(umrs.enable)) clear_overrides=\(onOff(umrs.clearOverrides))"
|
||||
case let .apiHideUser(userId, viewPwd): return "/_hide user \(userId) \(encodeJSON(viewPwd))"
|
||||
case let .apiUnhideUser(userId, viewPwd): return "/_unhide user \(userId) \(encodeJSON(viewPwd))"
|
||||
case let .apiMuteUser(userId): return "/_mute user \(userId)"
|
||||
@@ -257,6 +261,7 @@ public enum ChatCommand {
|
||||
case .apiSetActiveUser: return "apiSetActiveUser"
|
||||
case .setAllContactReceipts: return "setAllContactReceipts"
|
||||
case .apiSetUserContactReceipts: return "apiSetUserContactReceipts"
|
||||
case .apiSetUserGroupReceipts: return "apiSetUserGroupReceipts"
|
||||
case .apiHideUser: return "apiHideUser"
|
||||
case .apiUnhideUser: return "apiUnhideUser"
|
||||
case .apiMuteUser: return "apiMuteUser"
|
||||
@@ -447,6 +452,7 @@ public enum ChatResponse: Decodable, Error {
|
||||
case sentConfirmation(user: User)
|
||||
case sentInvitation(user: User)
|
||||
case contactAlreadyExists(user: User, contact: Contact)
|
||||
case contactRequestAlreadyAccepted(user: User, contact: Contact)
|
||||
case contactDeleted(user: User, contact: Contact)
|
||||
case chatCleared(user: User, chatInfo: ChatInfo)
|
||||
case userProfileNoChange(user: User)
|
||||
@@ -476,6 +482,7 @@ public enum ChatResponse: Decodable, Error {
|
||||
case newChatItem(user: User, chatItem: AChatItem)
|
||||
case chatItemStatusUpdated(user: User, chatItem: AChatItem)
|
||||
case chatItemUpdated(user: User, chatItem: AChatItem)
|
||||
case chatItemNotChanged(user: User, chatItem: AChatItem)
|
||||
case chatItemReaction(user: User, added: Bool, reaction: ACIReaction)
|
||||
case chatItemDeleted(user: User, deletedChatItem: AChatItem, toChatItem: AChatItem?, byUser: Bool)
|
||||
case contactsList(user: User, contacts: [Contact])
|
||||
@@ -578,6 +585,7 @@ public enum ChatResponse: Decodable, Error {
|
||||
case .sentConfirmation: return "sentConfirmation"
|
||||
case .sentInvitation: return "sentInvitation"
|
||||
case .contactAlreadyExists: return "contactAlreadyExists"
|
||||
case .contactRequestAlreadyAccepted: return "contactRequestAlreadyAccepted"
|
||||
case .contactDeleted: return "contactDeleted"
|
||||
case .chatCleared: return "chatCleared"
|
||||
case .userProfileNoChange: return "userProfileNoChange"
|
||||
@@ -607,6 +615,7 @@ public enum ChatResponse: Decodable, Error {
|
||||
case .newChatItem: return "newChatItem"
|
||||
case .chatItemStatusUpdated: return "chatItemStatusUpdated"
|
||||
case .chatItemUpdated: return "chatItemUpdated"
|
||||
case .chatItemNotChanged: return "chatItemNotChanged"
|
||||
case .chatItemReaction: return "chatItemReaction"
|
||||
case .chatItemDeleted: return "chatItemDeleted"
|
||||
case .contactsList: return "contactsList"
|
||||
@@ -708,6 +717,7 @@ public enum ChatResponse: Decodable, Error {
|
||||
case .sentConfirmation: return noDetails
|
||||
case .sentInvitation: return noDetails
|
||||
case let .contactAlreadyExists(u, contact): return withUser(u, String(describing: contact))
|
||||
case let .contactRequestAlreadyAccepted(u, contact): return withUser(u, String(describing: contact))
|
||||
case let .contactDeleted(u, contact): return withUser(u, String(describing: contact))
|
||||
case let .chatCleared(u, chatInfo): return withUser(u, String(describing: chatInfo))
|
||||
case .userProfileNoChange: return noDetails
|
||||
@@ -737,6 +747,7 @@ public enum ChatResponse: Decodable, Error {
|
||||
case let .newChatItem(u, chatItem): return withUser(u, String(describing: chatItem))
|
||||
case let .chatItemStatusUpdated(u, chatItem): return withUser(u, String(describing: chatItem))
|
||||
case let .chatItemUpdated(u, chatItem): return withUser(u, String(describing: chatItem))
|
||||
case let .chatItemNotChanged(u, chatItem): return withUser(u, String(describing: chatItem))
|
||||
case let .chatItemReaction(u, added, reaction): return withUser(u, "added: \(added)\n\(String(describing: reaction))")
|
||||
case let .chatItemDeleted(u, deletedChatItem, toChatItem, byUser): return withUser(u, "deletedChatItem:\n\(String(describing: deletedChatItem))\ntoChatItem:\n\(String(describing: toChatItem))\nbyUser: \(byUser)")
|
||||
case let .contactsList(u, contacts): return withUser(u, String(describing: contacts))
|
||||
@@ -810,6 +821,14 @@ public enum ChatResponse: Decodable, Error {
|
||||
}
|
||||
}
|
||||
|
||||
public func chatError(_ chatResponse: ChatResponse) -> ChatErrorType? {
|
||||
switch chatResponse {
|
||||
case let .chatCmdError(_, .error(error)): return error
|
||||
case let .chatError(_, .error(error)): return error
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
|
||||
struct NewUser: Encodable {
|
||||
var profile: Profile?
|
||||
var sameServers: Bool
|
||||
@@ -1362,14 +1381,32 @@ public enum ChatError: Decodable {
|
||||
|
||||
public enum ChatErrorType: Decodable {
|
||||
case noActiveUser
|
||||
case noConnectionUser(agentConnId: String)
|
||||
case noSndFileUser(agentSndFileId: String)
|
||||
case noRcvFileUser(agentRcvFileId: String)
|
||||
case userUnknown
|
||||
case activeUserExists
|
||||
case userExists
|
||||
case differentActiveUser
|
||||
case differentActiveUser(commandUserId: Int64, activeUserId: Int64)
|
||||
case cantDeleteActiveUser(userId: Int64)
|
||||
case cantDeleteLastUser(userId: Int64)
|
||||
case cantHideLastUser(userId: Int64)
|
||||
case hiddenUserAlwaysMuted(userId: Int64)
|
||||
case emptyUserPassword(userId: Int64)
|
||||
case userAlreadyHidden(userId: Int64)
|
||||
case userNotHidden(userId: Int64)
|
||||
case chatNotStarted
|
||||
case chatNotStopped
|
||||
case chatStoreChanged
|
||||
case invalidConnReq
|
||||
case invalidChatMessage(message: String)
|
||||
case invalidChatMessage(connection: Connection, message: String)
|
||||
case contactNotReady(contact: Contact)
|
||||
case groupUserRole
|
||||
case contactDisabled(contact: Contact)
|
||||
case connectionDisabled(connection: Connection)
|
||||
case groupUserRole(groupInfo: GroupInfo, requiredRole: GroupMemberRole)
|
||||
case groupMemberInitialRole(groupInfo: GroupInfo, initialRole: GroupMemberRole)
|
||||
case contactIncognitoCantInvite
|
||||
case groupIncognitoCantInvite
|
||||
case groupContactRole(contactName: ContactName)
|
||||
case groupDuplicateMember(contactName: ContactName)
|
||||
case groupDuplicateMemberId
|
||||
@@ -1381,23 +1418,49 @@ public enum ChatErrorType: Decodable {
|
||||
case groupCantResendInvitation(groupInfo: GroupInfo, contactName: ContactName)
|
||||
case groupInternal(message: String)
|
||||
case fileNotFound(message: String)
|
||||
case fileSize(filePath: String)
|
||||
case fileAlreadyReceiving(message: String)
|
||||
case fileCancelled(message: String)
|
||||
case fileCancel(fileId: Int64, message: String)
|
||||
case fileAlreadyExists(filePath: String)
|
||||
case fileRead(filePath: String, message: String)
|
||||
case fileWrite(filePath: String, message: String)
|
||||
case fileSend(fileId: Int64, agentError: String)
|
||||
case fileRcvChunk(message: String)
|
||||
case fileInternal(message: String)
|
||||
case fileImageType(filePath: String)
|
||||
case fileImageSize(filePath: String)
|
||||
case fileNotReceived(fileId: Int64)
|
||||
// case xFTPRcvFile
|
||||
// case xFTPSndFile
|
||||
case fallbackToSMPProhibited(fileId: Int64)
|
||||
case inlineFileProhibited(fileId: Int64)
|
||||
case invalidQuote
|
||||
case invalidChatItemUpdate
|
||||
case invalidChatItemDelete
|
||||
case hasCurrentCall
|
||||
case noCurrentCall
|
||||
case callContact(contactId: Int64)
|
||||
case callState
|
||||
case directMessagesProhibited(contact: Contact)
|
||||
case agentVersion
|
||||
case agentNoSubResult(agentConnId: String)
|
||||
case commandError(message: String)
|
||||
case serverProtocol
|
||||
case agentCommandError(message: String)
|
||||
case invalidFileDescription(message: String)
|
||||
case internalError(message: String)
|
||||
case exception(message: String)
|
||||
}
|
||||
|
||||
public enum StoreError: Decodable {
|
||||
case duplicateName
|
||||
case userNotFound(userId: Int64)
|
||||
case userNotFoundByName(contactName: ContactName)
|
||||
case userNotFoundByContactId(contactId: Int64)
|
||||
case userNotFoundByGroupId(groupId: Int64)
|
||||
case userNotFoundByFileId(fileId: Int64)
|
||||
case userNotFoundByContactRequestId(contactRequestId: Int64)
|
||||
case contactNotFound(contactId: Int64)
|
||||
case contactNotFoundByName(contactName: ContactName)
|
||||
case contactNotReady(contactName: ContactName)
|
||||
@@ -1407,6 +1470,9 @@ public enum StoreError: Decodable {
|
||||
case contactRequestNotFoundByName(contactName: ContactName)
|
||||
case groupNotFound(groupId: Int64)
|
||||
case groupNotFoundByName(groupName: GroupName)
|
||||
case groupMemberNameNotFound(groupId: Int64, groupMemberName: ContactName)
|
||||
case groupMemberNotFound(groupMemberId: Int64)
|
||||
case groupMemberNotFoundByMemberId(memberId: String)
|
||||
case groupWithoutUser
|
||||
case duplicateGroupMember
|
||||
case groupAlreadyJoined
|
||||
@@ -1414,9 +1480,16 @@ public enum StoreError: Decodable {
|
||||
case sndFileNotFound(fileId: Int64)
|
||||
case sndFileInvalid(fileId: Int64)
|
||||
case rcvFileNotFound(fileId: Int64)
|
||||
case rcvFileDescrNotFound(fileId: Int64)
|
||||
case fileNotFound(fileId: Int64)
|
||||
case rcvFileInvalid(fileId: Int64)
|
||||
case rcvFileInvalidDescrPart
|
||||
case sharedMsgIdNotFoundByFileId(fileId: Int64)
|
||||
case fileIdNotFoundBySharedMsgId(sharedMsgId: String)
|
||||
case sndFileNotFoundXFTP(agentSndFileId: String)
|
||||
case rcvFileNotFoundXFTP(agentRcvFileId: String)
|
||||
case connectionNotFound(agentConnId: String)
|
||||
case connectionNotFoundById(connId: Int64)
|
||||
case pendingConnectionNotFound(connId: Int64)
|
||||
case introNotFound
|
||||
case uniqueID
|
||||
@@ -1424,11 +1497,16 @@ public enum StoreError: Decodable {
|
||||
case noMsgDelivery(connId: Int64, agentMsgId: String)
|
||||
case badChatItem(itemId: Int64)
|
||||
case chatItemNotFound(itemId: Int64)
|
||||
case quotedChatItemNotFound
|
||||
case chatItemNotFoundByText(text: String)
|
||||
case chatItemSharedMsgIdNotFound(sharedMsgId: String)
|
||||
case chatItemNotFoundByFileId(fileId: Int64)
|
||||
case chatItemNotFoundByGroupId(groupId: Int64)
|
||||
case profileNotFound(profileId: Int64)
|
||||
case duplicateGroupLink(groupInfo: GroupInfo)
|
||||
case groupLinkNotFound(groupInfo: GroupInfo)
|
||||
case hostMemberIdNotFound(groupId: Int64)
|
||||
case contactNotFoundByFileId(fileId: Int64)
|
||||
case noGroupSndStatus(itemId: Int64, groupMemberId: Int64)
|
||||
}
|
||||
|
||||
public enum DatabaseError: Decodable {
|
||||
@@ -1448,11 +1526,12 @@ public enum AgentErrorType: Decodable {
|
||||
case CMD(cmdErr: CommandErrorType)
|
||||
case CONN(connErr: ConnectionErrorType)
|
||||
case SMP(smpErr: ProtocolErrorType)
|
||||
case XFTP(xftpErr: XFTPErrorType)
|
||||
case NTF(ntfErr: ProtocolErrorType)
|
||||
case XFTP(xftpErr: XFTPErrorType)
|
||||
case BROKER(brokerAddress: String, brokerErr: BrokerErrorType)
|
||||
case AGENT(agentErr: SMPAgentError)
|
||||
case INTERNAL(internalErr: String)
|
||||
case INACTIVE
|
||||
}
|
||||
|
||||
public enum CommandErrorType: Decodable {
|
||||
@@ -1472,9 +1551,10 @@ public enum ConnectionErrorType: Decodable {
|
||||
}
|
||||
|
||||
public enum BrokerErrorType: Decodable {
|
||||
case RESPONSE(smpErr: ProtocolErrorType)
|
||||
case RESPONSE(smpErr: String)
|
||||
case UNEXPECTED
|
||||
case NETWORK
|
||||
case HOST
|
||||
case TRANSPORT(transportErr: ProtocolTransportError)
|
||||
case TIMEOUT
|
||||
}
|
||||
@@ -1508,6 +1588,7 @@ public enum XFTPErrorType: Decodable {
|
||||
public enum ProtocolCommandError: Decodable {
|
||||
case UNKNOWN
|
||||
case SYNTAX
|
||||
case PROHIBITED
|
||||
case NO_AUTH
|
||||
case HAS_AUTH
|
||||
case NO_ENTITY
|
||||
@@ -1530,7 +1611,9 @@ public enum SMPAgentError: Decodable {
|
||||
case A_MESSAGE
|
||||
case A_PROHIBITED
|
||||
case A_VERSION
|
||||
case A_ENCRYPTION
|
||||
case A_CRYPTO
|
||||
case A_DUPLICATE
|
||||
case A_QUEUE(queueErr: String)
|
||||
}
|
||||
|
||||
public enum ArchiveError: Decodable {
|
||||
|
||||
@@ -1200,6 +1200,13 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat {
|
||||
}
|
||||
}
|
||||
|
||||
public var groupInfo: GroupInfo? {
|
||||
switch self {
|
||||
case let .group(groupInfo): return groupInfo
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
|
||||
// this works for features that are common for contacts and groups
|
||||
public func featureEnabled(_ feature: ChatFeature) -> Bool {
|
||||
switch self {
|
||||
@@ -2263,19 +2270,7 @@ public struct CIMeta: Decodable {
|
||||
}
|
||||
|
||||
public func statusIcon(_ metaColor: Color = .secondary) -> (String, Color)? {
|
||||
switch itemStatus {
|
||||
case .sndSent: return ("checkmark", metaColor)
|
||||
case let .sndRcvd(msgRcptStatus):
|
||||
switch msgRcptStatus {
|
||||
case .ok: return ("checkmark", metaColor) // ("checkmark.circle", metaColor)
|
||||
case .badMsgHash: return ("checkmark", .red) // ("checkmark.circle", .red)
|
||||
}
|
||||
case .sndErrorAuth: return ("multiply", .red)
|
||||
case .sndError: return ("exclamationmark.triangle.fill", .yellow)
|
||||
case .rcvNew: return ("circlebadge.fill", Color.accentColor)
|
||||
case .invalid: return ("questionmark", metaColor)
|
||||
default: return nil
|
||||
}
|
||||
itemStatus.statusIcon(metaColor)
|
||||
}
|
||||
|
||||
public static func getSample(_ id: Int64, _ ts: Date, _ text: String, _ status: CIStatus = .sndNew, itemDeleted: CIDeleted? = nil, itemEdited: Bool = false, itemLive: Bool = false, editable: Bool = true) -> CIMeta {
|
||||
@@ -2338,8 +2333,8 @@ private func recent(_ date: Date) -> Bool {
|
||||
|
||||
public enum CIStatus: Decodable {
|
||||
case sndNew
|
||||
case sndSent
|
||||
case sndRcvd(msgRcptStatus: MsgReceiptStatus)
|
||||
case sndSent(sndProgress: SndCIStatusProgress)
|
||||
case sndRcvd(msgRcptStatus: MsgReceiptStatus, sndProgress: SndCIStatusProgress)
|
||||
case sndErrorAuth
|
||||
case sndError(agentError: String)
|
||||
case rcvNew
|
||||
@@ -2358,6 +2353,49 @@ public enum CIStatus: Decodable {
|
||||
case .invalid: return "invalid"
|
||||
}
|
||||
}
|
||||
|
||||
public func statusIcon(_ metaColor: Color = .secondary) -> (String, Color)? {
|
||||
switch self {
|
||||
case .sndNew: return nil
|
||||
case .sndSent: return ("checkmark", metaColor)
|
||||
case let .sndRcvd(msgRcptStatus, _):
|
||||
switch msgRcptStatus {
|
||||
case .ok: return ("checkmark", metaColor)
|
||||
case .badMsgHash: return ("checkmark", .red)
|
||||
}
|
||||
case .sndErrorAuth: return ("multiply", .red)
|
||||
case .sndError: return ("exclamationmark.triangle.fill", .yellow)
|
||||
case .rcvNew: return ("circlebadge.fill", Color.accentColor)
|
||||
case .rcvRead: return nil
|
||||
case .invalid: return ("questionmark", metaColor)
|
||||
}
|
||||
}
|
||||
|
||||
public var statusText: String {
|
||||
switch self {
|
||||
case .sndNew: return NSLocalizedString("Sending message", comment: "item status text")
|
||||
case .sndSent: return NSLocalizedString("Message sent", comment: "item status text")
|
||||
case .sndRcvd: return NSLocalizedString("Sent message received", comment: "item status text")
|
||||
case .sndErrorAuth: return NSLocalizedString("Error sending message", comment: "item status text")
|
||||
case .sndError: return NSLocalizedString("Error sending message", comment: "item status text")
|
||||
case .rcvNew: return NSLocalizedString("Message received", comment: "item status text")
|
||||
case .rcvRead: return NSLocalizedString("Message read", comment: "item status text")
|
||||
case .invalid: return NSLocalizedString("Invalid status", comment: "item status text")
|
||||
}
|
||||
}
|
||||
|
||||
public var statusDescription: String {
|
||||
switch self {
|
||||
case .sndNew: return NSLocalizedString("Sending message is in progress or pending.", comment: "item status description")
|
||||
case .sndSent: return NSLocalizedString("Message has been sent to the recipient's relay.", comment: "item status description")
|
||||
case .sndRcvd: return NSLocalizedString("Message has been received by the recipient.", comment: "item status description")
|
||||
case .sndErrorAuth: return NSLocalizedString("Message delivery error. Most likely this recipient has deleted the connection with you.", comment: "item status description")
|
||||
case let .sndError(agentError): return String.localizedStringWithFormat(NSLocalizedString("Unexpected message delivery error: %@", comment: "item status description"), agentError)
|
||||
case .rcvNew: return NSLocalizedString("New message from this sender.", comment: "item status description")
|
||||
case .rcvRead: return NSLocalizedString("You've read this received message.", comment: "item status description")
|
||||
case let .invalid(text): return text
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public enum MsgReceiptStatus: String, Decodable {
|
||||
@@ -2365,6 +2403,11 @@ public enum MsgReceiptStatus: String, Decodable {
|
||||
case badMsgHash
|
||||
}
|
||||
|
||||
public enum SndCIStatusProgress: String, Decodable {
|
||||
case partial
|
||||
case complete
|
||||
}
|
||||
|
||||
public enum CIDeleted: Decodable {
|
||||
case deleted(deletedTs: Date?)
|
||||
case moderated(deletedTs: Date?, byGroupMember: GroupMember)
|
||||
@@ -3212,6 +3255,7 @@ public enum ChatItemTTL: Hashable, Identifiable, Comparable {
|
||||
|
||||
public struct ChatItemInfo: Decodable {
|
||||
public var itemVersions: [ChatItemVersion]
|
||||
public var memberDeliveryStatuses: [MemberDeliveryStatus]?
|
||||
}
|
||||
|
||||
public struct ChatItemVersion: Decodable {
|
||||
@@ -3221,3 +3265,8 @@ public struct ChatItemVersion: Decodable {
|
||||
public var itemVersionTs: Date
|
||||
public var createdAt: Date
|
||||
}
|
||||
|
||||
public struct MemberDeliveryStatus: Decodable {
|
||||
public var groupMemberId: Int64
|
||||
public var memberDeliveryStatus: CIStatus
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
local.properties
|
||||
common/src/commonMain/cpp/android/libs/
|
||||
common/src/commonMain/cpp/desktop/libs/
|
||||
common/src/commonMain/resources/libs/
|
||||
desktop/src/jvmMain/resources/libs/
|
||||
android/build
|
||||
android/release
|
||||
common/build
|
||||
|
||||
@@ -7,10 +7,6 @@ plugins {
|
||||
id("org.jetbrains.kotlin.plugin.serialization")
|
||||
}
|
||||
|
||||
repositories {
|
||||
maven("https://jitpack.io")
|
||||
}
|
||||
|
||||
android {
|
||||
compileSdkVersion(33)
|
||||
|
||||
@@ -124,9 +120,16 @@ dependencies {
|
||||
//implementation("androidx.compose.ui:ui:${rootProject.extra["compose.version"] as String}")
|
||||
//implementation("androidx.compose.material:material:$compose_version")
|
||||
//implementation("androidx.compose.ui:ui-tooling-preview:$compose_version")
|
||||
implementation("androidx.appcompat:appcompat:1.5.1")
|
||||
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.4.1")
|
||||
implementation("androidx.lifecycle:lifecycle-process:2.4.1")
|
||||
implementation("androidx.activity:activity-compose:1.5.0")
|
||||
val work_version = "2.7.1"
|
||||
implementation("androidx.work:work-runtime-ktx:$work_version")
|
||||
implementation("androidx.work:work-multiprocess:$work_version")
|
||||
|
||||
implementation("com.jakewharton:process-phoenix:2.1.2")
|
||||
|
||||
//implementation("androidx.compose.material:material-icons-extended:$compose_version")
|
||||
//implementation("androidx.compose.ui:ui-util:$compose_version")
|
||||
|
||||
|
||||
@@ -3,8 +3,8 @@ package chat.simplex.app
|
||||
import android.app.backup.BackupAgentHelper
|
||||
import android.app.backup.FullBackupDataOutput
|
||||
import android.content.Context
|
||||
import chat.simplex.app.model.AppPreferences
|
||||
import chat.simplex.app.model.AppPreferences.Companion.SHARED_PREFS_PRIVACY_FULL_BACKUP
|
||||
import chat.simplex.common.model.AppPreferences
|
||||
import chat.simplex.common.model.AppPreferences.Companion.SHARED_PREFS_PRIVACY_FULL_BACKUP
|
||||
|
||||
class BackupAgent: BackupAgentHelper() {
|
||||
override fun onFullBackup(data: FullBackupDataOutput?) {
|
||||
|
||||
@@ -1,84 +1,40 @@
|
||||
package chat.simplex.app
|
||||
|
||||
import android.app.Application
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.*
|
||||
import android.os.SystemClock.elapsedRealtime
|
||||
import android.util.Log
|
||||
import android.view.WindowManager
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.viewModels
|
||||
import androidx.compose.animation.core.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import androidx.lifecycle.*
|
||||
import chat.simplex.app.MainActivity.Companion.enteredBackground
|
||||
import chat.simplex.app.model.*
|
||||
import chat.simplex.app.model.NtfManager
|
||||
import chat.simplex.app.model.NtfManager.getUserIdFromIntent
|
||||
import chat.simplex.app.ui.theme.*
|
||||
import chat.simplex.app.views.SplashView
|
||||
import chat.simplex.app.views.call.ActiveCallView
|
||||
import chat.simplex.app.views.call.IncomingCallAlertView
|
||||
import chat.simplex.app.views.chat.ChatView
|
||||
import chat.simplex.app.views.chatlist.*
|
||||
import chat.simplex.app.views.database.DatabaseErrorView
|
||||
import chat.simplex.app.views.helpers.*
|
||||
import chat.simplex.app.views.helpers.DatabaseUtils.ksAppPassword
|
||||
import chat.simplex.app.views.helpers.DatabaseUtils.ksSelfDestructPassword
|
||||
import chat.simplex.app.views.localauth.SetAppPasscodeView
|
||||
import chat.simplex.app.views.newchat.*
|
||||
import chat.simplex.app.views.onboarding.*
|
||||
import chat.simplex.app.views.usersettings.LAMode
|
||||
import chat.simplex.app.views.usersettings.SetDeliveryReceiptsView
|
||||
import chat.simplex.common.*
|
||||
import chat.simplex.common.helpers.*
|
||||
import chat.simplex.common.model.*
|
||||
import chat.simplex.common.ui.theme.*
|
||||
import chat.simplex.common.views.chatlist.*
|
||||
import chat.simplex.common.views.helpers.*
|
||||
import chat.simplex.common.views.onboarding.*
|
||||
import chat.simplex.common.platform.*
|
||||
import chat.simplex.res.MR
|
||||
import dev.icerock.moko.resources.compose.painterResource
|
||||
import dev.icerock.moko.resources.compose.stringResource
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import java.lang.ref.WeakReference
|
||||
|
||||
class MainActivity: FragmentActivity() {
|
||||
companion object {
|
||||
/**
|
||||
* We don't want these values to be bound to Activity lifecycle since activities are changed often, for example, when a user
|
||||
* clicks on new message in notification. In this case savedInstanceState will be null (this prevents restoring the values)
|
||||
* See [SimplexService.onTaskRemoved] for another part of the logic which nullifies the values when app closed by the user
|
||||
* */
|
||||
val userAuthorized = mutableStateOf<Boolean?>(null)
|
||||
val enteredBackground = mutableStateOf<Long?>(null)
|
||||
// Remember result and show it after orientation change
|
||||
private val laFailed = mutableStateOf(false)
|
||||
|
||||
fun clearAuthState() {
|
||||
userAuthorized.value = null
|
||||
enteredBackground.value = null
|
||||
}
|
||||
}
|
||||
private val vm by viewModels<SimplexViewModel>()
|
||||
private val destroyedAfterBackPress = mutableStateOf(false)
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
applyAppLocale(ChatModel.controller.appPrefs.appLanguage)
|
||||
super.onCreate(savedInstanceState)
|
||||
SimplexApp.context.mainActivity = WeakReference(this)
|
||||
// testJson()
|
||||
val m = vm.chatModel
|
||||
mainActivity = WeakReference(this)
|
||||
// When call ended and orientation changes, it re-process old intent, it's unneeded.
|
||||
// Only needed to be processed on first creation of activity
|
||||
if (savedInstanceState == null) {
|
||||
processNotificationIntent(intent, m)
|
||||
processIntent(intent, m)
|
||||
processExternalIntent(intent, m)
|
||||
processNotificationIntent(intent)
|
||||
processIntent(intent)
|
||||
processExternalIntent(intent)
|
||||
}
|
||||
if (m.controller.appPrefs.privacyProtectScreen.get()) {
|
||||
if (ChatController.appPrefs.privacyProtectScreen.get()) {
|
||||
Log.d(TAG, "onCreate: set FLAG_SECURE")
|
||||
window.setFlags(
|
||||
WindowManager.LayoutParams.FLAG_SECURE,
|
||||
@@ -87,17 +43,7 @@ class MainActivity: FragmentActivity() {
|
||||
}
|
||||
setContent {
|
||||
SimpleXTheme {
|
||||
Surface(color = MaterialTheme.colors.background) {
|
||||
MainPage(
|
||||
m,
|
||||
userAuthorized,
|
||||
laFailed,
|
||||
destroyedAfterBackPress,
|
||||
::runAuthenticate,
|
||||
::setPerformLA,
|
||||
showLANotice = { showLANotice(m.controller.appPrefs.laNoticeShown) }
|
||||
)
|
||||
}
|
||||
AppScreen()
|
||||
}
|
||||
}
|
||||
SimplexApp.context.schedulePeriodicServiceRestartWorker()
|
||||
@@ -106,38 +52,29 @@ class MainActivity: FragmentActivity() {
|
||||
|
||||
override fun onNewIntent(intent: Intent?) {
|
||||
super.onNewIntent(intent)
|
||||
processIntent(intent, vm.chatModel)
|
||||
processExternalIntent(intent, vm.chatModel)
|
||||
processIntent(intent)
|
||||
processExternalIntent(intent)
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
val enteredBackgroundVal = enteredBackground.value
|
||||
val delay = vm.chatModel.controller.appPrefs.laLockDelay.get()
|
||||
if (enteredBackgroundVal == null || elapsedRealtime() - enteredBackgroundVal >= delay * 1000) {
|
||||
if (userAuthorized.value != false) {
|
||||
/** [runAuthenticate] will be called in [MainPage] if needed. Making like this prevents double showing of passcode on start */
|
||||
setAuthState()
|
||||
} else if (!vm.chatModel.activeCallViewIsVisible.value) {
|
||||
runAuthenticate()
|
||||
}
|
||||
}
|
||||
AppLock.recheckAuthState()
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
super.onPause()
|
||||
/**
|
||||
* When new activity is created after a click on notification, the old one receives onPause before
|
||||
* recreation but receives onStop after recreation. So using both (onPause and onStop) to prevent
|
||||
* unwanted multiple auth dialogs from [runAuthenticate]
|
||||
* */
|
||||
enteredBackground.value = elapsedRealtime()
|
||||
* When new activity is created after a click on notification, the old one receives onPause before
|
||||
* recreation but receives onStop after recreation. So using both (onPause and onStop) to prevent
|
||||
* unwanted multiple auth dialogs from [runAuthenticate]
|
||||
* */
|
||||
AppLock.appWasHidden()
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
super.onStop()
|
||||
VideoPlayer.stopAll()
|
||||
enteredBackground.value = elapsedRealtime()
|
||||
AppLock.appWasHidden()
|
||||
}
|
||||
|
||||
override fun onBackPressed() {
|
||||
@@ -150,481 +87,52 @@ class MainActivity: FragmentActivity() {
|
||||
super.onBackPressed()
|
||||
}
|
||||
|
||||
if (!onBackPressedDispatcher.hasEnabledCallbacks() && vm.chatModel.controller.appPrefs.performLA.get()) {
|
||||
if (!onBackPressedDispatcher.hasEnabledCallbacks() && ChatController.appPrefs.performLA.get()) {
|
||||
// When pressed Back and there is no one wants to process the back event, clear auth state to force re-auth on launch
|
||||
clearAuthState()
|
||||
laFailed.value = true
|
||||
destroyedAfterBackPress.value = true
|
||||
AppLock.clearAuthState()
|
||||
AppLock.laFailed.value = true
|
||||
AppLock.destroyedAfterBackPress.value = true
|
||||
}
|
||||
if (!onBackPressedDispatcher.hasEnabledCallbacks()) {
|
||||
// Drop shared content
|
||||
SimplexApp.context.chatModel.sharedContent.value = null
|
||||
}
|
||||
}
|
||||
|
||||
private fun setAuthState() {
|
||||
userAuthorized.value = !vm.chatModel.controller.appPrefs.performLA.get()
|
||||
}
|
||||
|
||||
private fun runAuthenticate() {
|
||||
val m = vm.chatModel
|
||||
setAuthState()
|
||||
if (userAuthorized.value == false) {
|
||||
// To make Main thread free in order to allow to Compose to show blank view that hiding content underneath of it faster on slow devices
|
||||
CoroutineScope(Dispatchers.Default).launch {
|
||||
delay(50)
|
||||
withContext(Dispatchers.Main) {
|
||||
authenticate(
|
||||
if (m.controller.appPrefs.laMode.get() == LAMode.SYSTEM)
|
||||
generalGetString(MR.strings.auth_unlock)
|
||||
else
|
||||
generalGetString(MR.strings.la_enter_app_passcode),
|
||||
if (m.controller.appPrefs.laMode.get() == LAMode.SYSTEM)
|
||||
generalGetString(MR.strings.auth_log_in_using_credential)
|
||||
else
|
||||
generalGetString(MR.strings.auth_unlock),
|
||||
selfDestruct = true,
|
||||
completed = { laResult ->
|
||||
when (laResult) {
|
||||
LAResult.Success ->
|
||||
userAuthorized.value = true
|
||||
is LAResult.Failed -> { /* Can be called multiple times on every failure */ }
|
||||
is LAResult.Error -> {
|
||||
laFailed.value = true
|
||||
if (m.controller.appPrefs.laMode.get() == LAMode.PASSCODE) {
|
||||
laFailedAlert()
|
||||
}
|
||||
}
|
||||
is LAResult.Unavailable -> {
|
||||
userAuthorized.value = true
|
||||
m.performLA.value = false
|
||||
m.controller.appPrefs.performLA.set(false)
|
||||
laUnavailableTurningOffAlert()
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun showLANotice(laNoticeShown: SharedPreference<Boolean>) {
|
||||
Log.d(TAG, "showLANotice")
|
||||
if (!laNoticeShown.get()) {
|
||||
laNoticeShown.set(true)
|
||||
AlertManager.shared.showAlertDialog(
|
||||
title = generalGetString(MR.strings.la_notice_title_simplex_lock),
|
||||
text = generalGetString(MR.strings.la_notice_to_protect_your_information_turn_on_simplex_lock_you_will_be_prompted_to_complete_authentication_before_this_feature_is_enabled),
|
||||
confirmText = generalGetString(MR.strings.la_notice_turn_on),
|
||||
onConfirm = {
|
||||
withBGApi { // to remove this call, change ordering of onConfirm call in AlertManager
|
||||
showChooseLAMode(laNoticeShown)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun showChooseLAMode(laNoticeShown: SharedPreference<Boolean>) {
|
||||
Log.d(TAG, "showLANotice")
|
||||
laNoticeShown.set(true)
|
||||
AlertManager.shared.showAlertDialogStacked(
|
||||
title = generalGetString(MR.strings.la_lock_mode),
|
||||
text = null,
|
||||
confirmText = generalGetString(MR.strings.la_lock_mode_passcode),
|
||||
dismissText = generalGetString(MR.strings.la_lock_mode_system),
|
||||
onConfirm = {
|
||||
AlertManager.shared.hideAlert()
|
||||
setPasscode()
|
||||
},
|
||||
onDismiss = {
|
||||
AlertManager.shared.hideAlert()
|
||||
initialEnableLA()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun initialEnableLA() {
|
||||
val m = vm.chatModel
|
||||
val appPrefs = m.controller.appPrefs
|
||||
m.controller.appPrefs.laMode.set(LAMode.SYSTEM)
|
||||
authenticate(
|
||||
generalGetString(MR.strings.auth_enable_simplex_lock),
|
||||
generalGetString(MR.strings.auth_confirm_credential),
|
||||
completed = { laResult ->
|
||||
when (laResult) {
|
||||
LAResult.Success -> {
|
||||
m.performLA.value = true
|
||||
appPrefs.performLA.set(true)
|
||||
laTurnedOnAlert()
|
||||
}
|
||||
is LAResult.Failed -> { /* Can be called multiple times on every failure */ }
|
||||
is LAResult.Error -> {
|
||||
m.performLA.value = false
|
||||
appPrefs.performLA.set(false)
|
||||
laFailedAlert()
|
||||
}
|
||||
is LAResult.Unavailable -> {
|
||||
m.performLA.value = false
|
||||
appPrefs.performLA.set(false)
|
||||
m.showAdvertiseLAUnavailableAlert.value = true
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun setPasscode() {
|
||||
val chatModel = vm.chatModel
|
||||
val appPrefs = chatModel.controller.appPrefs
|
||||
ModalManager.shared.showCustomModal { close ->
|
||||
Surface(Modifier.fillMaxSize(), color = MaterialTheme.colors.background) {
|
||||
SetAppPasscodeView(
|
||||
submit = {
|
||||
chatModel.performLA.value = true
|
||||
appPrefs.performLA.set(true)
|
||||
appPrefs.laMode.set(LAMode.PASSCODE)
|
||||
laTurnedOnAlert()
|
||||
},
|
||||
cancel = {
|
||||
chatModel.performLA.value = false
|
||||
appPrefs.performLA.set(false)
|
||||
laPasscodeNotSetAlert()
|
||||
},
|
||||
close = close
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun setPerformLA(on: Boolean) {
|
||||
vm.chatModel.controller.appPrefs.laNoticeShown.set(true)
|
||||
if (on) {
|
||||
enableLA()
|
||||
} else {
|
||||
disableLA()
|
||||
}
|
||||
}
|
||||
|
||||
private fun enableLA() {
|
||||
val m = vm.chatModel
|
||||
authenticate(
|
||||
if (m.controller.appPrefs.laMode.get() == LAMode.SYSTEM)
|
||||
generalGetString(MR.strings.auth_enable_simplex_lock)
|
||||
else
|
||||
generalGetString(MR.strings.new_passcode),
|
||||
if (m.controller.appPrefs.laMode.get() == LAMode.SYSTEM)
|
||||
generalGetString(MR.strings.auth_confirm_credential)
|
||||
else
|
||||
"",
|
||||
completed = { laResult ->
|
||||
val prefPerformLA = m.controller.appPrefs.performLA
|
||||
when (laResult) {
|
||||
LAResult.Success -> {
|
||||
m.performLA.value = true
|
||||
prefPerformLA.set(true)
|
||||
laTurnedOnAlert()
|
||||
}
|
||||
is LAResult.Failed -> { /* Can be called multiple times on every failure */ }
|
||||
is LAResult.Error -> {
|
||||
m.performLA.value = false
|
||||
prefPerformLA.set(false)
|
||||
laFailedAlert()
|
||||
}
|
||||
is LAResult.Unavailable -> {
|
||||
m.performLA.value = false
|
||||
prefPerformLA.set(false)
|
||||
laUnavailableInstructionAlert()
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun disableLA() {
|
||||
val m = vm.chatModel
|
||||
authenticate(
|
||||
if (m.controller.appPrefs.laMode.get() == LAMode.SYSTEM)
|
||||
generalGetString(MR.strings.auth_disable_simplex_lock)
|
||||
else
|
||||
generalGetString(MR.strings.la_enter_app_passcode),
|
||||
if (m.controller.appPrefs.laMode.get() == LAMode.SYSTEM)
|
||||
generalGetString(MR.strings.auth_confirm_credential)
|
||||
else
|
||||
generalGetString(MR.strings.auth_disable_simplex_lock),
|
||||
completed = { laResult ->
|
||||
val prefPerformLA = m.controller.appPrefs.performLA
|
||||
val selfDestructPref = m.controller.appPrefs.selfDestruct
|
||||
when (laResult) {
|
||||
LAResult.Success -> {
|
||||
m.performLA.value = false
|
||||
prefPerformLA.set(false)
|
||||
ksAppPassword.remove()
|
||||
selfDestructPref.set(false)
|
||||
ksSelfDestructPassword.remove()
|
||||
}
|
||||
is LAResult.Failed -> { /* Can be called multiple times on every failure */ }
|
||||
is LAResult.Error -> {
|
||||
m.performLA.value = true
|
||||
prefPerformLA.set(true)
|
||||
laFailedAlert()
|
||||
}
|
||||
is LAResult.Unavailable -> {
|
||||
m.performLA.value = false
|
||||
prefPerformLA.set(false)
|
||||
laUnavailableTurningOffAlert()
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
class SimplexViewModel(application: Application): AndroidViewModel(application) {
|
||||
val app = getApplication<SimplexApp>()
|
||||
val chatModel = app.chatModel
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MainPage(
|
||||
chatModel: ChatModel,
|
||||
userAuthorized: MutableState<Boolean?>,
|
||||
laFailed: MutableState<Boolean>,
|
||||
destroyedAfterBackPress: MutableState<Boolean>,
|
||||
runAuthenticate: () -> Unit,
|
||||
setPerformLA: (Boolean) -> Unit,
|
||||
showLANotice: () -> Unit
|
||||
) {
|
||||
var showChatDatabaseError by rememberSaveable {
|
||||
mutableStateOf(chatModel.chatDbStatus.value != DBMigrationResult.OK && chatModel.chatDbStatus.value != null)
|
||||
}
|
||||
LaunchedEffect(chatModel.chatDbStatus.value) {
|
||||
showChatDatabaseError = chatModel.chatDbStatus.value != DBMigrationResult.OK && chatModel.chatDbStatus.value != null
|
||||
}
|
||||
|
||||
var showAdvertiseLAAlert by remember { mutableStateOf(false) }
|
||||
LaunchedEffect(showAdvertiseLAAlert) {
|
||||
if (
|
||||
!chatModel.controller.appPrefs.laNoticeShown.get()
|
||||
&& showAdvertiseLAAlert
|
||||
&& chatModel.onboardingStage.value == OnboardingStage.OnboardingComplete
|
||||
&& chatModel.chats.isNotEmpty()
|
||||
&& chatModel.activeCallInvitation.value == null
|
||||
) {
|
||||
showLANotice()
|
||||
}
|
||||
}
|
||||
LaunchedEffect(chatModel.showAdvertiseLAUnavailableAlert.value) {
|
||||
if (chatModel.showAdvertiseLAUnavailableAlert.value) {
|
||||
laUnavailableInstructionAlert()
|
||||
}
|
||||
}
|
||||
LaunchedEffect(chatModel.clearOverlays.value) {
|
||||
if (chatModel.clearOverlays.value) {
|
||||
ModalManager.shared.closeModals()
|
||||
chatModel.clearOverlays.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AuthView() {
|
||||
Surface(color = MaterialTheme.colors.background) {
|
||||
Box(
|
||||
Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
SimpleButton(
|
||||
stringResource(MR.strings.auth_unlock),
|
||||
icon = painterResource(MR.images.ic_lock),
|
||||
click = {
|
||||
laFailed.value = false
|
||||
runAuthenticate()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Box {
|
||||
val onboarding = chatModel.onboardingStage.value
|
||||
val userCreated = chatModel.userCreated.value
|
||||
var showInitializationView by remember { mutableStateOf(false) }
|
||||
when {
|
||||
chatModel.chatDbStatus.value == null && showInitializationView -> InitializationView()
|
||||
showChatDatabaseError -> {
|
||||
chatModel.chatDbStatus.value?.let {
|
||||
DatabaseErrorView(chatModel.chatDbStatus, chatModel.controller.appPrefs)
|
||||
}
|
||||
}
|
||||
onboarding == null || userCreated == null -> SplashView()
|
||||
onboarding == OnboardingStage.OnboardingComplete && userCreated -> {
|
||||
Box {
|
||||
showAdvertiseLAAlert = true
|
||||
BoxWithConstraints {
|
||||
var currentChatId by rememberSaveable { mutableStateOf(chatModel.chatId.value) }
|
||||
val offset = remember { Animatable(if (chatModel.chatId.value == null) 0f else maxWidth.value) }
|
||||
Box(
|
||||
Modifier
|
||||
.graphicsLayer {
|
||||
translationX = -offset.value.dp.toPx()
|
||||
}
|
||||
) {
|
||||
if (chatModel.setDeliveryReceipts.value) {
|
||||
SetDeliveryReceiptsView(chatModel)
|
||||
} else {
|
||||
val stopped = chatModel.chatRunning.value == false
|
||||
if (chatModel.sharedContent.value == null)
|
||||
ChatListView(chatModel, setPerformLA, stopped)
|
||||
else
|
||||
ShareListView(chatModel, stopped)
|
||||
}
|
||||
}
|
||||
val scope = rememberCoroutineScope()
|
||||
val onComposed: () -> Unit = {
|
||||
scope.launch {
|
||||
offset.animateTo(
|
||||
if (chatModel.chatId.value == null) 0f else maxWidth.value,
|
||||
chatListAnimationSpec()
|
||||
)
|
||||
if (offset.value == 0f) {
|
||||
currentChatId = null
|
||||
}
|
||||
}
|
||||
}
|
||||
LaunchedEffect(Unit) {
|
||||
launch {
|
||||
snapshotFlow { chatModel.chatId.value }
|
||||
.distinctUntilChanged()
|
||||
.collect {
|
||||
if (it != null) currentChatId = it
|
||||
else onComposed()
|
||||
}
|
||||
}
|
||||
}
|
||||
Box (Modifier.graphicsLayer { translationX = maxWidth.toPx() - offset.value.dp.toPx() }) Box2@ {
|
||||
currentChatId?.let {
|
||||
ChatView(it, chatModel, onComposed)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
onboarding == OnboardingStage.Step1_SimpleXInfo -> SimpleXInfo(chatModel, onboarding = true)
|
||||
onboarding == OnboardingStage.Step2_CreateProfile -> CreateProfile(chatModel) {}
|
||||
onboarding == OnboardingStage.Step3_CreateSimpleXAddress -> CreateSimpleXAddress(chatModel)
|
||||
onboarding == OnboardingStage.Step4_SetNotificationsMode -> SetNotificationsMode(chatModel)
|
||||
}
|
||||
ModalManager.shared.showInView()
|
||||
val unauthorized = remember { derivedStateOf { userAuthorized.value != true } }
|
||||
if (unauthorized.value && !(chatModel.activeCallViewIsVisible.value && chatModel.showCallView.value)) {
|
||||
LaunchedEffect(Unit) {
|
||||
// With these constrains when user presses back button while on ChatList, activity destroys and shows auth request
|
||||
// while the screen moves to a launcher. Detect it and prevent showing the auth
|
||||
if (!(destroyedAfterBackPress.value && chatModel.controller.appPrefs.laMode.get() == LAMode.SYSTEM)) {
|
||||
runAuthenticate()
|
||||
}
|
||||
}
|
||||
if (chatModel.controller.appPrefs.performLA.get() && laFailed.value) {
|
||||
AuthView()
|
||||
} else {
|
||||
SplashView()
|
||||
}
|
||||
} else if (chatModel.showCallView.value) {
|
||||
ActiveCallView(chatModel)
|
||||
}
|
||||
ModalManager.shared.showPasscodeInView()
|
||||
val invitation = chatModel.activeCallInvitation.value
|
||||
if (invitation != null) IncomingCallAlertView(invitation, chatModel)
|
||||
AlertManager.shared.showInView()
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
delay(1000)
|
||||
if (chatModel.chatDbStatus.value == null) {
|
||||
showInitializationView = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DisposableEffectOnRotate {
|
||||
// When using lock delay = 0 and screen rotates, the app will be locked which is not useful.
|
||||
// Let's prolong the unlocked period to 3 sec for screen rotation to take place
|
||||
if (chatModel.controller.appPrefs.laLockDelay.get() == 0) {
|
||||
enteredBackground.value = elapsedRealtime() + 3000
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun InitializationView() {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
CircularProgressIndicator(
|
||||
Modifier
|
||||
.padding(bottom = DEFAULT_PADDING)
|
||||
.size(30.dp),
|
||||
color = MaterialTheme.colors.secondary,
|
||||
strokeWidth = 2.5.dp
|
||||
)
|
||||
Text(stringResource(MR.strings.opening_database))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun processNotificationIntent(intent: Intent?, chatModel: ChatModel) {
|
||||
fun processNotificationIntent(intent: Intent?) {
|
||||
val userId = getUserIdFromIntent(intent)
|
||||
when (intent?.action) {
|
||||
NtfManager.OpenChatAction -> {
|
||||
val chatId = intent.getStringExtra("chatId")
|
||||
Log.d(TAG, "processNotificationIntent: OpenChatAction $chatId")
|
||||
if (chatId != null) {
|
||||
withBGApi {
|
||||
awaitChatStartedIfNeeded(chatModel)
|
||||
if (userId != null && userId != chatModel.currentUser.value?.userId && chatModel.currentUser.value != null) {
|
||||
chatModel.controller.changeActiveUser(userId, null)
|
||||
}
|
||||
val cInfo = chatModel.getChat(chatId)?.chatInfo
|
||||
chatModel.clearOverlays.value = true
|
||||
if (cInfo != null && (cInfo is ChatInfo.Direct || cInfo is ChatInfo.Group)) openChat(cInfo, chatModel)
|
||||
}
|
||||
ntfManager.openChatAction(userId, chatId)
|
||||
}
|
||||
}
|
||||
NtfManager.ShowChatsAction -> {
|
||||
Log.d(TAG, "processNotificationIntent: ShowChatsAction")
|
||||
withBGApi {
|
||||
awaitChatStartedIfNeeded(chatModel)
|
||||
if (userId != null && userId != chatModel.currentUser.value?.userId && chatModel.currentUser.value != null) {
|
||||
chatModel.controller.changeActiveUser(userId, null)
|
||||
}
|
||||
chatModel.chatId.value = null
|
||||
chatModel.clearOverlays.value = true
|
||||
}
|
||||
ntfManager.showChatsAction(userId)
|
||||
}
|
||||
NtfManager.AcceptCallAction -> {
|
||||
val chatId = intent.getStringExtra("chatId")
|
||||
if (chatId == null || chatId == "") return
|
||||
Log.d(TAG, "processNotificationIntent: AcceptCallAction $chatId")
|
||||
chatModel.clearOverlays.value = true
|
||||
val invitation = chatModel.callInvitations[chatId]
|
||||
if (invitation == null) {
|
||||
AlertManager.shared.showAlertMsg(generalGetString(MR.strings.call_already_ended))
|
||||
} else {
|
||||
chatModel.callManager.acceptIncomingCall(invitation = invitation)
|
||||
}
|
||||
ntfManager.acceptCallAction(chatId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun processIntent(intent: Intent?, chatModel: ChatModel) {
|
||||
fun processIntent(intent: Intent?) {
|
||||
when (intent?.action) {
|
||||
"android.intent.action.VIEW" -> {
|
||||
val uri = intent.data
|
||||
if (uri != null) connectIfOpenedViaUri(uri, chatModel)
|
||||
if (uri != null) connectIfOpenedViaUri(uri.toURI(), ChatModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun processExternalIntent(intent: Intent?, chatModel: ChatModel) {
|
||||
fun processExternalIntent(intent: Intent?) {
|
||||
when (intent?.action) {
|
||||
Intent.ACTION_SEND -> {
|
||||
// Close active chat and show a list of chats
|
||||
@@ -640,13 +148,13 @@ fun processExternalIntent(intent: Intent?, chatModel: ChatModel) {
|
||||
isMediaIntent(intent) -> {
|
||||
val uri = intent.getParcelableExtra<Parcelable>(Intent.EXTRA_STREAM) as? Uri
|
||||
if (uri != null) {
|
||||
chatModel.sharedContent.value = SharedContent.Media(intent.getStringExtra(Intent.EXTRA_TEXT) ?: "", listOf(uri))
|
||||
chatModel.sharedContent.value = SharedContent.Media(intent.getStringExtra(Intent.EXTRA_TEXT) ?: "", listOf(uri.toURI()))
|
||||
} // All other mime types
|
||||
}
|
||||
else -> {
|
||||
val uri = intent.getParcelableExtra<Parcelable>(Intent.EXTRA_STREAM) as? Uri
|
||||
if (uri != null) {
|
||||
chatModel.sharedContent.value = SharedContent.File(intent.getStringExtra(Intent.EXTRA_TEXT) ?: "", uri)
|
||||
chatModel.sharedContent.value = SharedContent.File(intent.getStringExtra(Intent.EXTRA_TEXT) ?: "", uri.toURI())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -660,7 +168,7 @@ fun processExternalIntent(intent: Intent?, chatModel: ChatModel) {
|
||||
isMediaIntent(intent) -> {
|
||||
val uris = intent.getParcelableArrayListExtra<Parcelable>(Intent.EXTRA_STREAM) as? List<Uri>
|
||||
if (uris != null) {
|
||||
chatModel.sharedContent.value = SharedContent.Media(intent.getStringExtra(Intent.EXTRA_TEXT) ?: "", uris)
|
||||
chatModel.sharedContent.value = SharedContent.Media(intent.getStringExtra(Intent.EXTRA_TEXT) ?: "", uris.map { it.toURI() })
|
||||
} // All other mime types
|
||||
}
|
||||
else -> {}
|
||||
@@ -672,48 +180,6 @@ fun processExternalIntent(intent: Intent?, chatModel: ChatModel) {
|
||||
fun isMediaIntent(intent: Intent): Boolean =
|
||||
intent.type?.startsWith("image/") == true || intent.type?.startsWith("video/") == true
|
||||
|
||||
fun connectIfOpenedViaUri(uri: Uri, chatModel: ChatModel) {
|
||||
Log.d(TAG, "connectIfOpenedViaUri: opened via link")
|
||||
if (chatModel.currentUser.value == null) {
|
||||
chatModel.appOpenUrl.value = uri
|
||||
} else {
|
||||
withUriAction(uri) { linkType ->
|
||||
val title = when (linkType) {
|
||||
ConnectionLinkType.CONTACT -> generalGetString(MR.strings.connect_via_contact_link)
|
||||
ConnectionLinkType.INVITATION -> generalGetString(MR.strings.connect_via_invitation_link)
|
||||
ConnectionLinkType.GROUP -> generalGetString(MR.strings.connect_via_group_link)
|
||||
}
|
||||
AlertManager.shared.showAlertDialog(
|
||||
title = title,
|
||||
text = if (linkType == ConnectionLinkType.GROUP)
|
||||
generalGetString(MR.strings.you_will_join_group)
|
||||
else
|
||||
generalGetString(MR.strings.profile_will_be_sent_to_contact_sending_link),
|
||||
confirmText = generalGetString(MR.strings.connect_via_link_verb),
|
||||
onConfirm = {
|
||||
withApi {
|
||||
Log.d(TAG, "connectIfOpenedViaUri: connecting")
|
||||
connectViaUri(chatModel, linkType, uri)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun awaitChatStartedIfNeeded(chatModel: ChatModel, timeout: Long = 30_000) {
|
||||
// Still decrypting database
|
||||
if (chatModel.chatRunning.value == null) {
|
||||
val step = 50L
|
||||
for (i in 0..(timeout / step)) {
|
||||
if (chatModel.chatRunning.value == true || chatModel.onboardingStage.value == OnboardingStage.Step1_SimpleXInfo) {
|
||||
break
|
||||
}
|
||||
delay(step)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//fun testJson() {
|
||||
// val str: String = """
|
||||
// """.trimIndent()
|
||||
|
||||
+5
-2
@@ -1,10 +1,13 @@
|
||||
package chat.simplex.app.views.helpers
|
||||
package chat.simplex.app
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.work.*
|
||||
import chat.simplex.app.*
|
||||
import chat.simplex.app.SimplexService.Companion.showPassphraseNotification
|
||||
import chat.simplex.common.model.ChatController
|
||||
import chat.simplex.common.views.helpers.DBMigrationResult
|
||||
import chat.simplex.app.BuildConfig
|
||||
import kotlinx.coroutines.*
|
||||
import java.util.Date
|
||||
import java.util.concurrent.TimeUnit
|
||||
@@ -55,7 +58,7 @@ class MessagesFetcherWork(
|
||||
var shouldReschedule = true
|
||||
try {
|
||||
withTimeout(durationSeconds * 1000L) {
|
||||
val chatController = (applicationContext as SimplexApp).chatController
|
||||
val chatController = ChatController
|
||||
SimplexService.waitDbMigrationEnds(chatController)
|
||||
val chatDbStatus = chatController.chatModel.chatDbStatus.value
|
||||
if (chatDbStatus != DBMigrationResult.OK) {
|
||||
@@ -1,101 +1,31 @@
|
||||
package chat.simplex.app
|
||||
|
||||
import android.app.Application
|
||||
import android.net.LocalServerSocket
|
||||
import android.util.Log
|
||||
import chat.simplex.common.platform.Log
|
||||
import androidx.lifecycle.*
|
||||
import androidx.work.*
|
||||
import chat.simplex.app.model.*
|
||||
import chat.simplex.app.ui.theme.DefaultTheme
|
||||
import chat.simplex.app.views.helpers.*
|
||||
import chat.simplex.app.views.onboarding.OnboardingStage
|
||||
import chat.simplex.app.views.usersettings.NotificationsMode
|
||||
import chat.simplex.app.model.NtfManager
|
||||
import chat.simplex.common.helpers.APPLICATION_ID
|
||||
import chat.simplex.common.helpers.requiresIgnoringBattery
|
||||
import chat.simplex.common.model.*
|
||||
import chat.simplex.common.model.ChatController.appPrefs
|
||||
import chat.simplex.common.views.helpers.*
|
||||
import chat.simplex.common.views.onboarding.OnboardingStage
|
||||
import chat.simplex.common.platform.*
|
||||
import chat.simplex.common.views.call.RcvCallInvitation
|
||||
import com.jakewharton.processphoenix.ProcessPhoenix
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import java.io.*
|
||||
import java.lang.ref.WeakReference
|
||||
import java.util.*
|
||||
import java.util.concurrent.Semaphore
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.concurrent.thread
|
||||
|
||||
const val TAG = "SIMPLEX"
|
||||
|
||||
// ghc's rts
|
||||
external fun initHS()
|
||||
// android-support
|
||||
external fun pipeStdOutToSocket(socketName: String) : Int
|
||||
|
||||
// SimpleX API
|
||||
typealias ChatCtrl = Long
|
||||
external fun chatMigrateInit(dbPath: String, dbKey: String, confirm: String): Array<Any>
|
||||
external fun chatSendCmd(ctrl: ChatCtrl, msg: String): String
|
||||
external fun chatRecvMsg(ctrl: ChatCtrl): String
|
||||
external fun chatRecvMsgWait(ctrl: ChatCtrl, timeout: Int): String
|
||||
external fun chatParseMarkdown(str: String): String
|
||||
external fun chatParseServer(str: String): String
|
||||
external fun chatPasswordHash(pwd: String, salt: String): String
|
||||
|
||||
class SimplexApp: Application(), LifecycleEventObserver {
|
||||
var mainActivity: WeakReference<MainActivity> = WeakReference(null)
|
||||
val chatModel: ChatModel
|
||||
get() = chatController.chatModel
|
||||
val appPreferences: AppPreferences
|
||||
get() = chatController.appPrefs
|
||||
|
||||
val chatController: ChatController = ChatController
|
||||
var isAppOnForeground: Boolean = false
|
||||
|
||||
val defaultLocale: Locale = Locale.getDefault()
|
||||
|
||||
suspend fun initChatController(useKey: String? = null, confirmMigrations: MigrationConfirmation? = null, startChat: Boolean = true) {
|
||||
val dbKey = useKey ?: DatabaseUtils.useDatabaseKey()
|
||||
val dbAbsolutePathPrefix = getFilesDirectory()
|
||||
val confirm = confirmMigrations ?: if (appPreferences.confirmDBUpgrades.get()) MigrationConfirmation.Error else MigrationConfirmation.YesUp
|
||||
val migrated: Array<Any> = chatMigrateInit(dbAbsolutePathPrefix, dbKey, confirm.value)
|
||||
val res: DBMigrationResult = kotlin.runCatching {
|
||||
json.decodeFromString<DBMigrationResult>(migrated[0] as String)
|
||||
}.getOrElse { DBMigrationResult.Unknown(migrated[0] as String) }
|
||||
val ctrl = if (res is DBMigrationResult.OK) {
|
||||
migrated[1] as Long
|
||||
} else null
|
||||
chatController.ctrl = ctrl
|
||||
chatModel.chatDbEncrypted.value = dbKey != ""
|
||||
chatModel.chatDbStatus.value = res
|
||||
if (res != DBMigrationResult.OK) {
|
||||
Log.d(TAG, "Unable to migrate successfully: $res")
|
||||
} else if (startChat) {
|
||||
// If we migrated successfully means previous re-encryption process on database level finished successfully too
|
||||
if (appPreferences.encryptionStartedAt.get() != null) appPreferences.encryptionStartedAt.set(null)
|
||||
val user = chatController.apiGetActiveUser()
|
||||
if (user == null) {
|
||||
chatModel.controller.appPrefs.onboardingStage.set(OnboardingStage.Step1_SimpleXInfo)
|
||||
chatModel.controller.appPrefs.privacyDeliveryReceiptsSet.set(true)
|
||||
chatModel.onboardingStage.value = OnboardingStage.Step1_SimpleXInfo
|
||||
chatModel.currentUser.value = null
|
||||
chatModel.users.clear()
|
||||
} else {
|
||||
val savedOnboardingStage = appPreferences.onboardingStage.get()
|
||||
chatModel.onboardingStage.value = if (listOf(OnboardingStage.Step1_SimpleXInfo, OnboardingStage.Step2_CreateProfile).contains(savedOnboardingStage) && chatModel.users.size == 1) {
|
||||
OnboardingStage.Step3_CreateSimpleXAddress
|
||||
} else {
|
||||
savedOnboardingStage
|
||||
}
|
||||
if (chatModel.onboardingStage.value == OnboardingStage.OnboardingComplete && !chatModel.controller.appPrefs.privacyDeliveryReceiptsSet.get()) {
|
||||
chatModel.setDeliveryReceipts.value = true
|
||||
}
|
||||
chatController.startChat(user)
|
||||
// Prevents from showing "Enable notifications" alert when onboarding wasn't complete yet
|
||||
if (chatModel.onboardingStage.value == OnboardingStage.OnboardingComplete) {
|
||||
SimplexService.showBackgroundServiceNoticeIfNeeded()
|
||||
if (appPreferences.notificationsMode.get() == NotificationsMode.SERVICE.name)
|
||||
SimplexService.start()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
@@ -103,7 +33,11 @@ class SimplexApp: Application(), LifecycleEventObserver {
|
||||
return;
|
||||
}
|
||||
context = this
|
||||
context.getDir("temp", MODE_PRIVATE).deleteRecursively()
|
||||
initHaskell()
|
||||
initMultiplatform()
|
||||
tmpDir.deleteRecursively()
|
||||
tmpDir.mkdir()
|
||||
|
||||
withBGApi {
|
||||
initChatController()
|
||||
runMigrations()
|
||||
@@ -147,7 +81,7 @@ class SimplexApp: Application(), LifecycleEventObserver {
|
||||
* */
|
||||
if (chatModel.chatRunning.value != false &&
|
||||
chatModel.onboardingStage.value == OnboardingStage.OnboardingComplete &&
|
||||
appPreferences.notificationsMode.get() == NotificationsMode.SERVICE.name
|
||||
appPrefs.notificationsMode.get() == NotificationsMode.SERVICE
|
||||
) {
|
||||
SimplexService.start()
|
||||
}
|
||||
@@ -158,12 +92,12 @@ class SimplexApp: Application(), LifecycleEventObserver {
|
||||
}
|
||||
|
||||
fun allowToStartServiceAfterAppExit() = with(chatModel.controller) {
|
||||
appPrefs.notificationsMode.get() == NotificationsMode.SERVICE.name &&
|
||||
appPrefs.notificationsMode.get() == NotificationsMode.SERVICE &&
|
||||
(!NotificationsMode.SERVICE.requiresIgnoringBattery || SimplexService.isIgnoringBatteryOptimizations())
|
||||
}
|
||||
|
||||
private fun allowToStartPeriodically() = with(chatModel.controller) {
|
||||
appPrefs.notificationsMode.get() == NotificationsMode.PERIODIC.name &&
|
||||
appPrefs.notificationsMode.get() == NotificationsMode.PERIODIC &&
|
||||
(!NotificationsMode.PERIODIC.requiresIgnoringBattery || SimplexService.isIgnoringBatteryOptimizations())
|
||||
}
|
||||
|
||||
@@ -198,75 +132,73 @@ class SimplexApp: Application(), LifecycleEventObserver {
|
||||
MessagesFetcherWorker.scheduleWork()
|
||||
}
|
||||
|
||||
private fun runMigrations() {
|
||||
val lastMigration = chatModel.controller.appPrefs.lastMigratedVersionCode
|
||||
if (lastMigration.get() < BuildConfig.VERSION_CODE) {
|
||||
while (true) {
|
||||
if (lastMigration.get() < 117) {
|
||||
if (chatModel.controller.appPrefs.currentTheme.get() == DefaultTheme.DARK.name) {
|
||||
chatModel.controller.appPrefs.currentTheme.set(DefaultTheme.SIMPLEX.name)
|
||||
}
|
||||
lastMigration.set(117)
|
||||
} else {
|
||||
lastMigration.set(BuildConfig.VERSION_CODE)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
lateinit var context: SimplexApp private set
|
||||
}
|
||||
|
||||
init {
|
||||
val socketName = BuildConfig.APPLICATION_ID + ".local.socket.address.listen.native.cmd2"
|
||||
val s = Semaphore(0)
|
||||
thread(name="stdout/stderr pipe") {
|
||||
Log.d(TAG, "starting server")
|
||||
var server: LocalServerSocket? = null
|
||||
for (i in 0..100) {
|
||||
try {
|
||||
server = LocalServerSocket(socketName + i)
|
||||
break
|
||||
} catch (e: IOException) {
|
||||
Log.e(TAG, e.stackTraceToString())
|
||||
}
|
||||
private fun initMultiplatform() {
|
||||
androidAppContext = this
|
||||
APPLICATION_ID = BuildConfig.APPLICATION_ID
|
||||
ntfManager = object : chat.simplex.common.platform.NtfManager() {
|
||||
override fun notifyCallInvitation(invitation: RcvCallInvitation) = NtfManager.notifyCallInvitation(invitation)
|
||||
override fun hasNotificationsForChat(chatId: String): Boolean = NtfManager.hasNotificationsForChat(chatId)
|
||||
override fun cancelNotificationsForChat(chatId: String) = NtfManager.cancelNotificationsForChat(chatId)
|
||||
override fun displayNotification(user: User, chatId: String, displayName: String, msgText: String, image: String?, actions: List<Pair<NotificationAction, () -> Unit>>) = NtfManager.displayNotification(user, chatId, displayName, msgText, image, actions.map { it.first })
|
||||
override fun androidCreateNtfChannelsMaybeShowAlert() = NtfManager.createNtfChannelsMaybeShowAlert()
|
||||
override fun cancelCallNotification() = NtfManager.cancelCallNotification()
|
||||
override fun cancelAllNotifications() = NtfManager.cancelAllNotifications()
|
||||
}
|
||||
platform = object : PlatformInterface {
|
||||
override suspend fun androidServiceStart() {
|
||||
SimplexService.start()
|
||||
}
|
||||
|
||||
override fun androidServiceSafeStop() {
|
||||
SimplexService.safeStopService()
|
||||
}
|
||||
|
||||
override fun androidNotificationsModeChanged(mode: NotificationsMode) {
|
||||
if (mode.requiresIgnoringBattery && !SimplexService.isIgnoringBatteryOptimizations()) {
|
||||
appPrefs.backgroundServiceNoticeShown.set(false)
|
||||
}
|
||||
if (server == null) {
|
||||
throw Error("Unable to setup local server socket. Contact developers")
|
||||
SimplexService.StartReceiver.toggleReceiver(mode == NotificationsMode.SERVICE)
|
||||
CoroutineScope(Dispatchers.Default).launch {
|
||||
if (mode == NotificationsMode.SERVICE)
|
||||
SimplexService.start()
|
||||
else
|
||||
SimplexService.safeStopService()
|
||||
}
|
||||
Log.d(TAG, "started server")
|
||||
s.release()
|
||||
val receiver = server.accept()
|
||||
Log.d(TAG, "started receiver")
|
||||
val logbuffer = FifoQueue<String>(500)
|
||||
if (receiver != null) {
|
||||
val inStream = receiver.inputStream
|
||||
val inStreamReader = InputStreamReader(inStream)
|
||||
val input = BufferedReader(inStreamReader)
|
||||
Log.d(TAG, "starting receiver loop")
|
||||
while (true) {
|
||||
val line = input.readLine() ?: break
|
||||
Log.w("$TAG (stdout/stderr)", line)
|
||||
logbuffer.add(line)
|
||||
}
|
||||
Log.w(TAG, "exited receiver loop")
|
||||
|
||||
if (mode != NotificationsMode.PERIODIC) {
|
||||
MessagesFetcherWorker.cancelAll()
|
||||
}
|
||||
SimplexService.showBackgroundServiceNoticeIfNeeded()
|
||||
}
|
||||
|
||||
override fun androidChatStartedAfterBeingOff() {
|
||||
SimplexService.cancelPassphraseNotification()
|
||||
when (appPrefs.notificationsMode.get()) {
|
||||
NotificationsMode.SERVICE -> CoroutineScope(Dispatchers.Default).launch { platform.androidServiceStart() }
|
||||
NotificationsMode.PERIODIC -> SimplexApp.context.schedulePeriodicWakeUp()
|
||||
NotificationsMode.OFF -> {}
|
||||
}
|
||||
}
|
||||
|
||||
System.loadLibrary("app-lib")
|
||||
override fun androidChatStopped() {
|
||||
SimplexService.safeStopService()
|
||||
MessagesFetcherWorker.cancelAll()
|
||||
}
|
||||
|
||||
s.acquire()
|
||||
pipeStdOutToSocket(socketName)
|
||||
|
||||
initHS()
|
||||
override fun androidChatInitializedAndStarted() {
|
||||
// Prevents from showing "Enable notifications" alert when onboarding wasn't complete yet
|
||||
if (chatModel.onboardingStage.value == OnboardingStage.OnboardingComplete) {
|
||||
SimplexService.showBackgroundServiceNoticeIfNeeded()
|
||||
if (appPrefs.notificationsMode.get() == NotificationsMode.SERVICE)
|
||||
withBGApi {
|
||||
platform.androidServiceStart()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class FifoQueue<E>(private var capacity: Int) : LinkedList<E>() {
|
||||
override fun add(element: E): Boolean {
|
||||
if(size > capacity) removeFirst()
|
||||
return super.add(element)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,23 +7,26 @@ import android.content.pm.PackageManager
|
||||
import android.net.Uri
|
||||
import android.os.*
|
||||
import android.provider.Settings
|
||||
import android.util.Log
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import chat.simplex.common.platform.Log
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.work.*
|
||||
import chat.simplex.app.model.ChatController
|
||||
import chat.simplex.app.model.ChatModel
|
||||
import chat.simplex.app.views.helpers.*
|
||||
import chat.simplex.app.views.usersettings.NotificationsMode
|
||||
import chat.simplex.common.AppLock
|
||||
import chat.simplex.common.AppLock.clearAuthState
|
||||
import chat.simplex.common.helpers.requiresIgnoringBattery
|
||||
import chat.simplex.common.model.ChatController
|
||||
import chat.simplex.common.model.NotificationsMode
|
||||
import chat.simplex.common.platform.androidAppContext
|
||||
import chat.simplex.common.views.helpers.*
|
||||
import kotlinx.coroutines.*
|
||||
import chat.simplex.res.MR
|
||||
import dev.icerock.moko.resources.compose.painterResource
|
||||
import dev.icerock.moko.resources.compose.stringResource
|
||||
import kotlinx.coroutines.*
|
||||
|
||||
// based on:
|
||||
// https://robertohuertas.com/2019/06/29/android_foreground_services/
|
||||
@@ -97,7 +100,7 @@ class SimplexService: Service() {
|
||||
val self = this
|
||||
isStartingService = true
|
||||
withApi {
|
||||
val chatController = (application as SimplexApp).chatController
|
||||
val chatController = ChatController
|
||||
waitDbMigrationEnds(chatController)
|
||||
try {
|
||||
Log.w(TAG, "Starting foreground service")
|
||||
@@ -105,7 +108,7 @@ class SimplexService: Service() {
|
||||
if (chatDbStatus != DBMigrationResult.OK) {
|
||||
Log.w(chat.simplex.app.TAG, "SimplexService: problem with the database: $chatDbStatus")
|
||||
showPassphraseNotification(chatDbStatus)
|
||||
safeStopService(self)
|
||||
safeStopService()
|
||||
return@withApi
|
||||
}
|
||||
saveServiceState(self, ServiceState.STARTED)
|
||||
@@ -167,7 +170,7 @@ class SimplexService: Service() {
|
||||
// re-schedules the task when "Clear recent apps" is pressed
|
||||
override fun onTaskRemoved(rootIntent: Intent) {
|
||||
// Just to make sure that after restart of the app the user will need to re-authenticate
|
||||
MainActivity.clearAuthState()
|
||||
AppLock.clearAuthState()
|
||||
|
||||
// If notification service isn't enabled or battery optimization isn't disabled, we shouldn't restart the service
|
||||
if (!SimplexApp.context.allowToStartServiceAfterAppExit()) {
|
||||
@@ -265,9 +268,9 @@ class SimplexService: Service() {
|
||||
* If there is a need to stop the service, use this function only. It makes sure that the service will be stopped without an
|
||||
* exception related to foreground services lifecycle
|
||||
* */
|
||||
fun safeStopService(context: Context) {
|
||||
fun safeStopService() {
|
||||
if (isServiceStarted) {
|
||||
context.stopService(Intent(context, SimplexService::class.java))
|
||||
androidAppContext.stopService(Intent(androidAppContext, SimplexService::class.java))
|
||||
} else {
|
||||
stopAfterStart = true
|
||||
}
|
||||
@@ -276,9 +279,9 @@ class SimplexService: Service() {
|
||||
private suspend fun serviceAction(action: Action) {
|
||||
Log.d(TAG, "SimplexService serviceAction: ${action.name}")
|
||||
withContext(Dispatchers.IO) {
|
||||
Intent(SimplexApp.context, SimplexService::class.java).also {
|
||||
Intent(androidAppContext, SimplexService::class.java).also {
|
||||
it.action = action.name
|
||||
ContextCompat.startForegroundService(SimplexApp.context, it)
|
||||
ContextCompat.startForegroundService(androidAppContext, it)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -352,7 +355,7 @@ class SimplexService: Service() {
|
||||
|
||||
fun showBackgroundServiceNoticeIfNeeded() {
|
||||
val appPrefs = ChatController.appPrefs
|
||||
val mode = NotificationsMode.valueOf(appPrefs.notificationsMode.get()!!)
|
||||
val mode = appPrefs.notificationsMode.get()
|
||||
Log.d(TAG, "showBackgroundServiceNoticeIfNeeded")
|
||||
// Nothing to do if mode is OFF. Can be selected on on-boarding stage
|
||||
if (mode == NotificationsMode.OFF) return
|
||||
@@ -373,11 +376,10 @@ class SimplexService: Service() {
|
||||
if (appPrefs.backgroundServiceBatteryNoticeShown.get()) {
|
||||
// users have been presented with battery notice before - they did not allow ignoring optimizations -> disable service
|
||||
showDisablingServiceNotice(mode)
|
||||
appPrefs.notificationsMode.set(NotificationsMode.OFF.name)
|
||||
ChatModel.notificationsMode.value = NotificationsMode.OFF
|
||||
SimplexService.StartReceiver.toggleReceiver(false)
|
||||
appPrefs.notificationsMode.set(NotificationsMode.OFF)
|
||||
StartReceiver.toggleReceiver(false)
|
||||
MessagesFetcherWorker.cancelAll()
|
||||
SimplexService.safeStopService(SimplexApp.context)
|
||||
safeStopService()
|
||||
} else {
|
||||
// show battery optimization notice
|
||||
showBGServiceNoticeIgnoreOptimization(mode)
|
||||
@@ -489,18 +491,18 @@ class SimplexService: Service() {
|
||||
}
|
||||
|
||||
fun isIgnoringBatteryOptimizations(): Boolean {
|
||||
val powerManager = SimplexApp.context.getSystemService(Application.POWER_SERVICE) as PowerManager
|
||||
return powerManager.isIgnoringBatteryOptimizations(SimplexApp.context.packageName)
|
||||
val powerManager = androidAppContext.getSystemService(Application.POWER_SERVICE) as PowerManager
|
||||
return powerManager.isIgnoringBatteryOptimizations(androidAppContext.packageName)
|
||||
}
|
||||
|
||||
private fun askAboutIgnoringBatteryOptimization() {
|
||||
Intent().apply {
|
||||
@SuppressLint("BatteryLife")
|
||||
action = Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS
|
||||
data = Uri.parse("package:${SimplexApp.context.packageName}")
|
||||
data = Uri.parse("package:${androidAppContext.packageName}")
|
||||
// This flag is needed when you start a new activity from non-Activity context
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
SimplexApp.context.startActivity(this)
|
||||
androidAppContext.startActivity(this)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+19
-69
@@ -1,6 +1,5 @@
|
||||
package chat.simplex.app.model
|
||||
|
||||
import android.Manifest
|
||||
import android.app.*
|
||||
import android.app.TaskStackBuilder
|
||||
import android.content.*
|
||||
@@ -9,16 +8,20 @@ import android.graphics.BitmapFactory
|
||||
import android.hardware.display.DisplayManager
|
||||
import android.media.AudioAttributes
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import android.view.Display
|
||||
import androidx.compose.ui.graphics.asAndroidBitmap
|
||||
import androidx.core.app.*
|
||||
import chat.simplex.app.*
|
||||
import chat.simplex.app.views.call.*
|
||||
import chat.simplex.app.views.chatlist.acceptContactRequest
|
||||
import chat.simplex.app.views.helpers.*
|
||||
import chat.simplex.app.views.usersettings.NotificationPreviewMode
|
||||
import chat.simplex.res.MR
|
||||
import chat.simplex.app.TAG
|
||||
import chat.simplex.app.views.call.IncomingCallActivity
|
||||
import chat.simplex.app.views.call.getKeyguardManager
|
||||
import chat.simplex.common.views.helpers.*
|
||||
import chat.simplex.common.model.*
|
||||
import chat.simplex.common.platform.*
|
||||
import chat.simplex.common.views.call.CallMediaType
|
||||
import chat.simplex.common.views.call.RcvCallInvitation
|
||||
import kotlinx.datetime.Clock
|
||||
import chat.simplex.res.MR
|
||||
|
||||
object NtfManager {
|
||||
const val MessageChannel: String = "chat.simplex.app.MESSAGE_NOTIFICATION"
|
||||
@@ -33,7 +36,7 @@ object NtfManager {
|
||||
const val CallNotificationId: Int = -1
|
||||
private const val UserIdKey: String = "userId"
|
||||
private const val ChatIdKey: String = "chatId"
|
||||
private val appPreferences: AppPreferences by lazy { ChatController.appPrefs }
|
||||
private val appPreferences: AppPreferences = ChatController.appPrefs
|
||||
private val context: Context
|
||||
get() = SimplexApp.context
|
||||
|
||||
@@ -42,7 +45,7 @@ object NtfManager {
|
||||
return if (userId == -1L || userId == null) null else userId
|
||||
}
|
||||
|
||||
private val manager: NotificationManager = SimplexApp.context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
private val manager: NotificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
private var prevNtfTime = mutableMapOf<String, Long>()
|
||||
private val msgNtfTimeoutMs = 30000L
|
||||
|
||||
@@ -50,10 +53,6 @@ object NtfManager {
|
||||
if (manager.areNotificationsEnabled()) createNtfChannelsMaybeShowAlert()
|
||||
}
|
||||
|
||||
enum class NotificationAction {
|
||||
ACCEPT_CONTACT_REQUEST
|
||||
}
|
||||
|
||||
private fun callNotificationChannel(channelId: String, channelName: String): NotificationChannel {
|
||||
val callChannel = NotificationChannel(channelId, channelName, NotificationManager.IMPORTANCE_HIGH)
|
||||
val attrs = AudioAttributes.Builder()
|
||||
@@ -82,31 +81,6 @@ object NtfManager {
|
||||
}
|
||||
}
|
||||
|
||||
fun notifyContactRequestReceived(user: User, cInfo: ChatInfo.ContactRequest) {
|
||||
displayNotification(
|
||||
user = user,
|
||||
chatId = cInfo.id,
|
||||
displayName = cInfo.displayName,
|
||||
msgText = generalGetString(MR.strings.notification_new_contact_request),
|
||||
image = cInfo.image,
|
||||
listOf(NotificationAction.ACCEPT_CONTACT_REQUEST)
|
||||
)
|
||||
}
|
||||
|
||||
fun notifyContactConnected(user: User, contact: Contact) {
|
||||
displayNotification(
|
||||
user = user,
|
||||
chatId = contact.id,
|
||||
displayName = contact.displayName,
|
||||
msgText = generalGetString(MR.strings.notification_contact_connected)
|
||||
)
|
||||
}
|
||||
|
||||
fun notifyMessageReceived(user: User, cInfo: ChatInfo, cItem: ChatItem) {
|
||||
if (!cInfo.ntfsEnabled) return
|
||||
displayNotification(user = user, chatId = cInfo.id, displayName = cInfo.displayName, msgText = hideSecrets(cItem))
|
||||
}
|
||||
|
||||
fun displayNotification(user: User, chatId: String, displayName: String, msgText: String, image: String? = null, actions: List<NotificationAction> = emptyList()) {
|
||||
if (!user.showNotifications) return
|
||||
Log.d(TAG, "notifyMessageReceived $chatId")
|
||||
@@ -119,7 +93,7 @@ object NtfManager {
|
||||
val largeIcon = when {
|
||||
actions.isEmpty() -> null
|
||||
image == null || previewMode == NotificationPreviewMode.HIDDEN.name -> BitmapFactory.decodeResource(context.resources, R.drawable.icon)
|
||||
else -> base64ToBitmap(image)
|
||||
else -> base64ToBitmap(image).asAndroidBitmap()
|
||||
}
|
||||
val builder = NotificationCompat.Builder(context, MessageChannel)
|
||||
.setContentTitle(title)
|
||||
@@ -158,7 +132,7 @@ object NtfManager {
|
||||
|
||||
with(NotificationManagerCompat.from(context)) {
|
||||
// using cInfo.id only shows one notification per chat and updates it when the message arrives
|
||||
if (ActivityCompat.checkSelfPermission(SimplexApp.context, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED) {
|
||||
if (ActivityCompat.checkSelfPermission(SimplexApp.context, android.Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED) {
|
||||
notify(chatId.hashCode(), builder.build())
|
||||
notify(0, summary)
|
||||
}
|
||||
@@ -172,9 +146,9 @@ object NtfManager {
|
||||
"notifyCallInvitation pre-requests: " +
|
||||
"keyguard locked ${keyguardManager.isKeyguardLocked}, " +
|
||||
"callOnLockScreen ${appPreferences.callOnLockScreen.get()}, " +
|
||||
"onForeground ${SimplexApp.context.isAppOnForeground}"
|
||||
"onForeground ${isAppOnForeground}"
|
||||
)
|
||||
if (SimplexApp.context.isAppOnForeground) return
|
||||
if (isAppOnForeground) return
|
||||
val contactId = invitation.contact.id
|
||||
Log.d(TAG, "notifyCallInvitation $contactId")
|
||||
val image = invitation.contact.image
|
||||
@@ -212,7 +186,7 @@ object NtfManager {
|
||||
val largeIcon = if (image == null || previewMode == NotificationPreviewMode.HIDDEN.name)
|
||||
BitmapFactory.decodeResource(context.resources, R.drawable.icon)
|
||||
else
|
||||
base64ToBitmap(image)
|
||||
base64ToBitmap(image).asAndroidBitmap()
|
||||
|
||||
ntfBuilder = ntfBuilder
|
||||
.setContentTitle(title)
|
||||
@@ -227,7 +201,7 @@ object NtfManager {
|
||||
// This makes notification sound and vibration repeat endlessly
|
||||
notification.flags = notification.flags or NotificationCompat.FLAG_INSISTENT
|
||||
with(NotificationManagerCompat.from(context)) {
|
||||
if (ActivityCompat.checkSelfPermission(SimplexApp.context, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED) {
|
||||
if (ActivityCompat.checkSelfPermission(SimplexApp.context, android.Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED) {
|
||||
notify(CallNotificationId, notification)
|
||||
}
|
||||
}
|
||||
@@ -243,19 +217,6 @@ object NtfManager {
|
||||
|
||||
fun hasNotificationsForChat(chatId: String): Boolean = manager.activeNotifications.any { it.id == chatId.hashCode() }
|
||||
|
||||
private fun hideSecrets(cItem: ChatItem): String {
|
||||
val md = cItem.formattedText
|
||||
return if (md != null) {
|
||||
var res = ""
|
||||
for (ft in md) {
|
||||
res += if (ft.format is Format.Secret) "..." else ft.text
|
||||
}
|
||||
res
|
||||
} else {
|
||||
cItem.text
|
||||
}
|
||||
}
|
||||
|
||||
private fun chatPendingIntent(intentAction: String, userId: Long?, chatId: String? = null, broadcast: Boolean = false): PendingIntent {
|
||||
Log.d(TAG, "chatPendingIntent for $intentAction")
|
||||
val uniqueInt = (System.currentTimeMillis() and 0xfffffff).toInt()
|
||||
@@ -299,18 +260,7 @@ object NtfManager {
|
||||
val chatId = intent?.getStringExtra(ChatIdKey) ?: return
|
||||
val m = SimplexApp.context.chatModel
|
||||
when (intent.action) {
|
||||
NotificationAction.ACCEPT_CONTACT_REQUEST.name -> {
|
||||
val isCurrentUser = m.currentUser.value?.userId == userId
|
||||
val cInfo: ChatInfo.ContactRequest? = if (isCurrentUser) {
|
||||
(m.getChat(chatId)?.chatInfo as? ChatInfo.ContactRequest) ?: return
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val apiId = chatId.replace("<@", "").toLongOrNull() ?: return
|
||||
acceptContactRequest(apiId, cInfo, isCurrentUser, m)
|
||||
cancelNotificationsForChat(chatId)
|
||||
}
|
||||
|
||||
NotificationAction.ACCEPT_CONTACT_REQUEST.name -> ntfManager.acceptContactRequestAction(userId, chatId)
|
||||
RejectCallAction -> {
|
||||
val invitation = m.callInvitations[chatId]
|
||||
if (invitation != null) {
|
||||
+20
-22
@@ -4,16 +4,14 @@ import android.app.Activity
|
||||
import android.app.KeyguardManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.res.Configuration
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import chat.simplex.common.platform.Log
|
||||
import android.view.WindowManager
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.viewModels
|
||||
import androidx.compose.desktop.ui.tooling.preview.Preview
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.*
|
||||
@@ -24,27 +22,27 @@ import androidx.compose.ui.draw.scale
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.painter.Painter
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import dev.icerock.moko.resources.compose.painterResource
|
||||
import dev.icerock.moko.resources.compose.stringResource
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import chat.simplex.app.*
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.*
|
||||
import chat.simplex.common.model.*
|
||||
import chat.simplex.app.model.NtfManager.OpenChatAction
|
||||
import chat.simplex.app.ui.theme.*
|
||||
import chat.simplex.app.views.helpers.ProfileImage
|
||||
import chat.simplex.common.platform.ntfManager
|
||||
import chat.simplex.common.ui.theme.*
|
||||
import chat.simplex.common.views.call.*
|
||||
import chat.simplex.common.views.helpers.*
|
||||
import chat.simplex.res.MR
|
||||
import dev.icerock.moko.resources.compose.stringResource
|
||||
import kotlinx.datetime.Clock
|
||||
|
||||
class IncomingCallActivity: ComponentActivity() {
|
||||
private val vm by viewModels<SimplexViewModel>()
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContent { IncomingCallActivityView(vm.chatModel) }
|
||||
setContent { IncomingCallActivityView(ChatModel) }
|
||||
unlockForIncomingCall()
|
||||
}
|
||||
|
||||
@@ -103,7 +101,7 @@ fun IncomingCallActivityView(m: ChatModel) {
|
||||
) {
|
||||
if (showCallView) {
|
||||
Box {
|
||||
ActiveCallView(m)
|
||||
ActiveCallView()
|
||||
if (invitation != null) IncomingCallAlertView(invitation, m)
|
||||
}
|
||||
} else if (invitation != null) {
|
||||
@@ -121,7 +119,7 @@ fun IncomingCallLockScreenAlert(invitation: RcvCallInvitation, chatModel: ChatMo
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
// Cancel notification whatever happens next since otherwise sound from notification and from inside the app can co-exist
|
||||
chatModel.controller.ntfManager.cancelCallNotification()
|
||||
ntfManager.cancelCallNotification()
|
||||
}
|
||||
}
|
||||
IncomingCallLockScreenAlertLayout(
|
||||
@@ -131,7 +129,7 @@ fun IncomingCallLockScreenAlert(invitation: RcvCallInvitation, chatModel: ChatMo
|
||||
rejectCall = { cm.endCall(invitation = invitation) },
|
||||
ignoreCall = {
|
||||
chatModel.activeCallInvitation.value = null
|
||||
chatModel.controller.ntfManager.cancelCallNotification()
|
||||
ntfManager.cancelCallNotification()
|
||||
},
|
||||
acceptCall = { cm.acceptIncomingCall(invitation = invitation) },
|
||||
openApp = {
|
||||
@@ -171,18 +169,18 @@ fun IncomingCallLockScreenAlertLayout(
|
||||
Text(invitation.contact.chatViewName, style = MaterialTheme.typography.h2)
|
||||
Spacer(Modifier.fillMaxHeight().weight(1f))
|
||||
Row {
|
||||
LockScreenCallButton(stringResource(MR.strings.reject), painterResource(MR.images.ic_call_end_filled), Color.Red, rejectCall)
|
||||
LockScreenCallButton(stringResource(MR.strings.reject), painterResource(R.drawable.ic_call_end_filled), Color.Red, rejectCall)
|
||||
Spacer(Modifier.size(48.dp))
|
||||
LockScreenCallButton(stringResource(MR.strings.ignore), painterResource(MR.images.ic_close), MaterialTheme.colors.primary, ignoreCall)
|
||||
LockScreenCallButton(stringResource(MR.strings.ignore), painterResource(R.drawable.ic_close), MaterialTheme.colors.primary, ignoreCall)
|
||||
Spacer(Modifier.size(48.dp))
|
||||
LockScreenCallButton(stringResource(MR.strings.accept), painterResource(MR.images.ic_check_filled), SimplexGreen, acceptCall)
|
||||
LockScreenCallButton(stringResource(MR.strings.accept), painterResource(R.drawable.ic_check_filled), SimplexGreen, acceptCall)
|
||||
}
|
||||
} else if (callOnLockScreen == CallOnLockScreen.SHOW) {
|
||||
SimpleXLogo()
|
||||
Text(stringResource(MR.strings.open_simplex_chat_to_accept_call), textAlign = TextAlign.Center, lineHeight = 22.sp)
|
||||
Text(stringResource(MR.strings.allow_accepting_calls_from_lock_screen), textAlign = TextAlign.Center, style = MaterialTheme.typography.body2, lineHeight = 22.sp)
|
||||
Spacer(Modifier.fillMaxHeight().weight(1f))
|
||||
SimpleButton(text = stringResource(MR.strings.open_verb), icon = painterResource(MR.images.ic_check_filled), click = openApp)
|
||||
SimpleButton(text = stringResource(MR.strings.open_verb), icon = painterResource(R.drawable.ic_check_filled), click = openApp)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -190,7 +188,7 @@ fun IncomingCallLockScreenAlertLayout(
|
||||
@Composable
|
||||
private fun SimpleXLogo() {
|
||||
Image(
|
||||
painter = painterResource(if (isInDarkTheme()) MR.images.logo_light else MR.images.logo),
|
||||
painter = painterResource(if (isInDarkTheme()) R.drawable.logo_light else R.drawable.logo),
|
||||
contentDescription = stringResource(MR.strings.image_descr_simplex_logo),
|
||||
modifier = Modifier
|
||||
.padding(vertical = DEFAULT_PADDING)
|
||||
@@ -219,10 +217,10 @@ private fun LockScreenCallButton(text: String, icon: Painter, color: Color, acti
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(
|
||||
@Preview/*(
|
||||
uiMode = Configuration.UI_MODE_NIGHT_YES,
|
||||
showBackground = true
|
||||
)
|
||||
)*/
|
||||
@Composable
|
||||
fun PreviewIncomingCallLockScreenAlert() {
|
||||
SimpleXTheme(true) {
|
||||
|
||||
-62
@@ -1,62 +0,0 @@
|
||||
package chat.simplex.app.views.helpers
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import dev.icerock.moko.resources.compose.painterResource
|
||||
import dev.icerock.moko.resources.compose.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.views.newchat.ActionButton
|
||||
import chat.simplex.res.MR
|
||||
|
||||
sealed class AttachmentOption {
|
||||
object CameraPhoto: AttachmentOption()
|
||||
object GalleryImage: AttachmentOption()
|
||||
object GalleryVideo: AttachmentOption()
|
||||
object File: AttachmentOption()
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ChooseAttachmentView(
|
||||
attachmentOption: MutableState<AttachmentOption?>,
|
||||
hide: () -> Unit
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.wrapContentHeight()
|
||||
.onFocusChanged { focusState ->
|
||||
if (!focusState.hasFocus) hide()
|
||||
}
|
||||
) {
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp, vertical = 30.dp),
|
||||
horizontalArrangement = Arrangement.SpaceEvenly
|
||||
) {
|
||||
ActionButton(Modifier.fillMaxWidth(0.25f), null, stringResource(MR.strings.use_camera_button), icon = painterResource(MR.images.ic_camera_enhance)) {
|
||||
attachmentOption.value = AttachmentOption.CameraPhoto
|
||||
hide()
|
||||
}
|
||||
ActionButton(Modifier.fillMaxWidth(0.33f), null, stringResource(MR.strings.gallery_image_button), icon = painterResource(MR.images.ic_add_photo)) {
|
||||
attachmentOption.value = AttachmentOption.GalleryImage
|
||||
hide()
|
||||
}
|
||||
ActionButton(Modifier.fillMaxWidth(0.50f), null, stringResource(MR.strings.gallery_video_button), icon = painterResource(MR.images.ic_smart_display)) {
|
||||
attachmentOption.value = AttachmentOption.GalleryVideo
|
||||
hide()
|
||||
}
|
||||
ActionButton(Modifier.fillMaxWidth(1f), null, stringResource(MR.strings.choose_file), icon = painterResource(MR.images.ic_note_add)) {
|
||||
attachmentOption.value = AttachmentOption.File
|
||||
hide()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
-148
@@ -1,148 +0,0 @@
|
||||
package chat.simplex.app.views.helpers
|
||||
|
||||
import android.os.Build.VERSION.SDK_INT
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.biometric.BiometricManager
|
||||
import androidx.biometric.BiometricManager.Authenticators.*
|
||||
import androidx.biometric.BiometricPrompt
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Surface
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.SimplexApp
|
||||
import chat.simplex.app.views.helpers.DatabaseUtils.ksAppPassword
|
||||
import chat.simplex.app.views.localauth.LocalAuthView
|
||||
import chat.simplex.app.views.usersettings.LAMode
|
||||
import chat.simplex.res.MR
|
||||
|
||||
sealed class LAResult {
|
||||
object Success: LAResult()
|
||||
class Error(val errString: CharSequence): LAResult()
|
||||
class Failed(val errString: CharSequence? = null): LAResult()
|
||||
class Unavailable(val errString: CharSequence? = null): LAResult()
|
||||
}
|
||||
|
||||
data class LocalAuthRequest (
|
||||
val title: String?,
|
||||
val reason: String,
|
||||
val password: String,
|
||||
val selfDestruct: Boolean,
|
||||
val completed: (LAResult) -> Unit
|
||||
) {
|
||||
companion object {
|
||||
val sample = LocalAuthRequest(generalGetString(MR.strings.la_enter_app_passcode), generalGetString(MR.strings.la_authenticate), "", selfDestruct = false) { }
|
||||
}
|
||||
}
|
||||
|
||||
fun authenticate(
|
||||
promptTitle: String,
|
||||
promptSubtitle: String,
|
||||
selfDestruct: Boolean = false,
|
||||
usingLAMode: LAMode = SimplexApp.context.chatModel.controller.appPrefs.laMode.get(),
|
||||
completed: (LAResult) -> Unit
|
||||
) {
|
||||
val activity = SimplexApp.context.mainActivity.get() ?: return completed(LAResult.Error(""))
|
||||
when (usingLAMode) {
|
||||
LAMode.SYSTEM -> when {
|
||||
SDK_INT in 28..29 ->
|
||||
// KeyguardManager.isDeviceSecure()? https://developer.android.com/training/sign-in/biometric-auth#declare-supported-authentication-types
|
||||
authenticateWithBiometricManager(promptTitle, promptSubtitle, activity, completed, BIOMETRIC_WEAK or DEVICE_CREDENTIAL)
|
||||
SDK_INT > 29 ->
|
||||
authenticateWithBiometricManager(promptTitle, promptSubtitle, activity, completed, BIOMETRIC_STRONG or DEVICE_CREDENTIAL)
|
||||
else -> completed(LAResult.Unavailable())
|
||||
}
|
||||
LAMode.PASSCODE -> {
|
||||
val password = ksAppPassword.get() ?: return completed(LAResult.Unavailable(generalGetString(MR.strings.la_no_app_password)))
|
||||
ModalManager.shared.showPasscodeCustomModal { close ->
|
||||
BackHandler {
|
||||
close()
|
||||
completed(LAResult.Error(generalGetString(MR.strings.authentication_cancelled)))
|
||||
}
|
||||
Surface(Modifier.fillMaxSize(), color = MaterialTheme.colors.background) {
|
||||
LocalAuthView(SimplexApp.context.chatModel, LocalAuthRequest(promptTitle, promptSubtitle, password, selfDestruct && SimplexApp.context.chatModel.controller.appPrefs.selfDestruct.get()) {
|
||||
close()
|
||||
completed(it)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun authenticateWithBiometricManager(
|
||||
promptTitle: String,
|
||||
promptSubtitle: String,
|
||||
activity: FragmentActivity,
|
||||
completed: (LAResult) -> Unit,
|
||||
authenticators: Int
|
||||
) {
|
||||
val biometricManager = BiometricManager.from(activity)
|
||||
when (biometricManager.canAuthenticate(authenticators)) {
|
||||
BiometricManager.BIOMETRIC_SUCCESS -> {
|
||||
val executor = ContextCompat.getMainExecutor(activity)
|
||||
val biometricPrompt = BiometricPrompt(
|
||||
activity,
|
||||
executor,
|
||||
object: BiometricPrompt.AuthenticationCallback() {
|
||||
override fun onAuthenticationError(
|
||||
errorCode: Int,
|
||||
errString: CharSequence
|
||||
) {
|
||||
super.onAuthenticationError(errorCode, errString)
|
||||
completed(LAResult.Error(errString))
|
||||
}
|
||||
|
||||
override fun onAuthenticationSucceeded(
|
||||
result: BiometricPrompt.AuthenticationResult
|
||||
) {
|
||||
super.onAuthenticationSucceeded(result)
|
||||
completed(LAResult.Success)
|
||||
}
|
||||
|
||||
override fun onAuthenticationFailed() {
|
||||
super.onAuthenticationFailed()
|
||||
completed(LAResult.Failed())
|
||||
}
|
||||
}
|
||||
)
|
||||
val promptInfo = BiometricPrompt.PromptInfo.Builder()
|
||||
.setTitle(promptTitle)
|
||||
.setSubtitle(promptSubtitle)
|
||||
.setAllowedAuthenticators(authenticators)
|
||||
.setConfirmationRequired(false)
|
||||
.build()
|
||||
biometricPrompt.authenticate(promptInfo)
|
||||
}
|
||||
else -> completed(LAResult.Unavailable())
|
||||
}
|
||||
}
|
||||
|
||||
fun laTurnedOnAlert() = AlertManager.shared.showAlertMsg(
|
||||
generalGetString(MR.strings.auth_simplex_lock_turned_on),
|
||||
generalGetString(MR.strings.auth_you_will_be_required_to_authenticate_when_you_start_or_resume)
|
||||
)
|
||||
|
||||
fun laPasscodeNotSetAlert() = AlertManager.shared.showAlertMsg(
|
||||
generalGetString(MR.strings.lock_not_enabled),
|
||||
generalGetString(MR.strings.you_can_turn_on_lock)
|
||||
)
|
||||
|
||||
fun laFailedAlert() {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title = generalGetString(MR.strings.la_auth_failed),
|
||||
text = generalGetString(MR.strings.la_could_not_be_verified)
|
||||
)
|
||||
}
|
||||
|
||||
fun laUnavailableInstructionAlert() = AlertManager.shared.showAlertMsg(
|
||||
generalGetString(MR.strings.auth_unavailable),
|
||||
generalGetString(MR.strings.auth_device_authentication_is_not_enabled_you_can_turn_on_in_settings_once_enabled)
|
||||
)
|
||||
|
||||
fun laUnavailableTurningOffAlert() = AlertManager.shared.showAlertMsg(
|
||||
generalGetString(MR.strings.auth_unavailable),
|
||||
generalGetString(MR.strings.auth_device_authentication_is_disabled_turning_off)
|
||||
)
|
||||
@@ -1,128 +0,0 @@
|
||||
package chat.simplex.app.views.helpers
|
||||
|
||||
import android.Manifest
|
||||
import android.content.*
|
||||
import android.net.Uri
|
||||
import android.provider.MediaStore
|
||||
import android.util.Log
|
||||
import android.webkit.MimeTypeMap
|
||||
import android.widget.Toast
|
||||
import androidx.activity.compose.ManagedActivityResultLauncher
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.content.FileProvider
|
||||
import chat.simplex.app.*
|
||||
import chat.simplex.app.model.CIFile
|
||||
import chat.simplex.res.MR
|
||||
import java.io.BufferedOutputStream
|
||||
import java.io.File
|
||||
|
||||
fun shareText(text: String) {
|
||||
val sendIntent: Intent = Intent().apply {
|
||||
action = Intent.ACTION_SEND
|
||||
putExtra(Intent.EXTRA_TEXT, text)
|
||||
type = "text/plain"
|
||||
}
|
||||
val shareIntent = Intent.createChooser(sendIntent, null)
|
||||
// This flag is needed when you start a new activity from non-Activity context
|
||||
shareIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
SimplexApp.context.startActivity(shareIntent)
|
||||
}
|
||||
|
||||
fun shareFile(text: String, filePath: String) {
|
||||
val uri = FileProvider.getUriForFile(SimplexApp.context, "${BuildConfig.APPLICATION_ID}.provider", File(filePath))
|
||||
val ext = filePath.substringAfterLast(".")
|
||||
val mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(ext) ?: return
|
||||
val sendIntent: Intent = Intent().apply {
|
||||
action = Intent.ACTION_SEND
|
||||
/*if (text.isNotEmpty()) {
|
||||
putExtra(Intent.EXTRA_TEXT, text)
|
||||
}*/
|
||||
putExtra(Intent.EXTRA_STREAM, uri)
|
||||
type = mimeType
|
||||
}
|
||||
val shareIntent = Intent.createChooser(sendIntent, null)
|
||||
// This flag is needed when you start a new activity from non-Activity context
|
||||
shareIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
SimplexApp.context.startActivity(shareIntent)
|
||||
}
|
||||
|
||||
fun copyText(text: String) {
|
||||
val clipboard = ContextCompat.getSystemService(SimplexApp.context, ClipboardManager::class.java)
|
||||
clipboard?.setPrimaryClip(ClipData.newPlainText("text", text))
|
||||
}
|
||||
|
||||
fun sendEmail(subject: String, body: CharSequence) {
|
||||
val emailIntent = Intent(Intent.ACTION_SENDTO, Uri.parse("mailto:"))
|
||||
emailIntent.putExtra(Intent.EXTRA_SUBJECT, subject)
|
||||
emailIntent.putExtra(Intent.EXTRA_TEXT, body)
|
||||
// This flag is needed when you start a new activity from non-Activity context
|
||||
emailIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
try {
|
||||
SimplexApp.context.startActivity(emailIntent)
|
||||
} catch (e: ActivityNotFoundException) {
|
||||
Log.e(TAG, "No activity was found for handling email intent")
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun rememberSaveFileLauncher(ciFile: CIFile?): ManagedActivityResultLauncher<String, Uri?> =
|
||||
rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.CreateDocument(),
|
||||
onResult = { destination ->
|
||||
destination?.let {
|
||||
val cxt = SimplexApp.context
|
||||
val filePath = getLoadedFilePath(ciFile)
|
||||
if (filePath != null) {
|
||||
val contentResolver = cxt.contentResolver
|
||||
contentResolver.openOutputStream(destination)?.let { stream ->
|
||||
val outputStream = BufferedOutputStream(stream)
|
||||
File(filePath).inputStream().use { it.copyTo(outputStream) }
|
||||
outputStream.close()
|
||||
Toast.makeText(cxt, generalGetString(MR.strings.file_saved), Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
} else {
|
||||
Toast.makeText(cxt, generalGetString(MR.strings.file_not_found), Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
fun imageMimeType(fileName: String): String {
|
||||
val lowercaseName = fileName.lowercase()
|
||||
return when {
|
||||
lowercaseName.endsWith(".png") -> "image/png"
|
||||
lowercaseName.endsWith(".gif") -> "image/gif"
|
||||
lowercaseName.endsWith(".webp") -> "image/webp"
|
||||
lowercaseName.endsWith(".avif") -> "image/avif"
|
||||
lowercaseName.endsWith(".svg") -> "image/svg+xml"
|
||||
else -> "image/jpeg"
|
||||
}
|
||||
}
|
||||
|
||||
/** Before calling, make sure the user allows to write to external storage [Manifest.permission.WRITE_EXTERNAL_STORAGE] */
|
||||
fun saveImage(ciFile: CIFile?) {
|
||||
val cxt = SimplexApp.context
|
||||
val filePath = getLoadedFilePath(ciFile)
|
||||
val fileName = ciFile?.fileName
|
||||
if (filePath != null && fileName != null) {
|
||||
val values = ContentValues()
|
||||
values.put(MediaStore.Images.Media.DATE_TAKEN, System.currentTimeMillis())
|
||||
values.put(MediaStore.Images.Media.MIME_TYPE, imageMimeType(fileName))
|
||||
values.put(MediaStore.MediaColumns.DISPLAY_NAME, fileName)
|
||||
values.put(MediaStore.MediaColumns.TITLE, fileName)
|
||||
val uri = cxt.contentResolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values)
|
||||
uri?.let {
|
||||
cxt.contentResolver.openOutputStream(uri)?.let { stream ->
|
||||
val outputStream = BufferedOutputStream(stream)
|
||||
File(filePath).inputStream().use { it.copyTo(outputStream) }
|
||||
outputStream.close()
|
||||
Toast.makeText(cxt, generalGetString(MR.strings.image_saved), Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Toast.makeText(cxt, generalGetString(MR.strings.file_not_found), Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
@@ -1,687 +0,0 @@
|
||||
package chat.simplex.app.views.helpers
|
||||
|
||||
import android.app.Activity
|
||||
import android.app.Application
|
||||
//import android.app.LocaleManager
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.Context
|
||||
import android.content.res.Configuration
|
||||
import android.content.res.Resources
|
||||
import android.graphics.*
|
||||
import android.graphics.Typeface
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.media.MediaMetadataRetriever
|
||||
import android.net.Uri
|
||||
import android.os.*
|
||||
import android.provider.OpenableColumns
|
||||
import android.text.Spanned
|
||||
import android.text.SpannedString
|
||||
import android.text.style.*
|
||||
import android.util.Base64
|
||||
import android.util.Log
|
||||
import android.view.View
|
||||
import android.view.ViewTreeObserver
|
||||
import android.view.inputmethod.InputMethodManager
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.saveable.Saver
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.platform.*
|
||||
import androidx.compose.ui.text.*
|
||||
import androidx.compose.ui.text.font.*
|
||||
import androidx.compose.ui.text.style.BaselineShift
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.unit.*
|
||||
import androidx.core.content.FileProvider
|
||||
import androidx.core.graphics.ColorUtils
|
||||
import androidx.core.text.HtmlCompat
|
||||
import chat.simplex.app.*
|
||||
import chat.simplex.app.model.*
|
||||
import chat.simplex.app.ui.theme.ThemeOverrides
|
||||
import com.charleskorn.kaml.decodeFromStream
|
||||
import chat.simplex.res.MR
|
||||
import dev.icerock.moko.resources.StringResource
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.encodeToString
|
||||
import org.apache.commons.io.IOUtils
|
||||
import java.io.*
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
import kotlin.math.*
|
||||
|
||||
fun withApi(action: suspend CoroutineScope.() -> Unit): Job = withScope(GlobalScope, action)
|
||||
|
||||
fun withScope(scope: CoroutineScope, action: suspend CoroutineScope.() -> Unit): Job =
|
||||
scope.launch { withContext(Dispatchers.Main, action) }
|
||||
|
||||
fun withBGApi(action: suspend CoroutineScope.() -> Unit): Job =
|
||||
CoroutineScope(Dispatchers.Default).launch(block = action)
|
||||
|
||||
enum class KeyboardState {
|
||||
Opened, Closed
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun getKeyboardState(): State<KeyboardState> {
|
||||
val keyboardState = remember { mutableStateOf(KeyboardState.Closed) }
|
||||
val view = LocalView.current
|
||||
DisposableEffect(view) {
|
||||
val onGlobalListener = ViewTreeObserver.OnGlobalLayoutListener {
|
||||
val rect = Rect()
|
||||
view.getWindowVisibleDisplayFrame(rect)
|
||||
val screenHeight = view.rootView.height
|
||||
val keypadHeight = screenHeight - rect.bottom
|
||||
keyboardState.value = if (keypadHeight > screenHeight * 0.15) {
|
||||
KeyboardState.Opened
|
||||
} else {
|
||||
KeyboardState.Closed
|
||||
}
|
||||
}
|
||||
view.viewTreeObserver.addOnGlobalLayoutListener(onGlobalListener)
|
||||
|
||||
onDispose {
|
||||
view.viewTreeObserver.removeOnGlobalLayoutListener(onGlobalListener)
|
||||
}
|
||||
}
|
||||
|
||||
return keyboardState
|
||||
}
|
||||
|
||||
fun hideKeyboard(view: View) =
|
||||
(SimplexApp.context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager).hideSoftInputFromWindow(view.windowToken, 0)
|
||||
|
||||
// Resource to annotated string from
|
||||
// https://stackoverflow.com/questions/68549248/android-jetpack-compose-how-to-show-styled-text-from-string-resources
|
||||
fun generalGetString(id: StringResource): String {
|
||||
// prefer stringResource in Composable items to retain preview abilities
|
||||
return id.getString(SimplexApp.context)
|
||||
}
|
||||
|
||||
@Composable
|
||||
@ReadOnlyComposable
|
||||
private fun resources(): Resources {
|
||||
LocalConfiguration.current
|
||||
return LocalContext.current.resources
|
||||
}
|
||||
|
||||
fun Spanned.toHtmlWithoutParagraphs(): String {
|
||||
return HtmlCompat.toHtml(this, HtmlCompat.TO_HTML_PARAGRAPH_LINES_CONSECUTIVE)
|
||||
.substringAfter("<p dir=\"ltr\">").substringBeforeLast("</p>")
|
||||
}
|
||||
|
||||
fun Resources.getText(id: StringResource, vararg args: Any): CharSequence {
|
||||
val escapedArgs = args.map {
|
||||
if (it is Spanned) it.toHtmlWithoutParagraphs() else it
|
||||
}.toTypedArray()
|
||||
val resource = SpannedString(getText(id))
|
||||
val htmlResource = resource.toHtmlWithoutParagraphs()
|
||||
val formattedHtml = String.format(htmlResource, *escapedArgs)
|
||||
return HtmlCompat.fromHtml(formattedHtml, HtmlCompat.FROM_HTML_MODE_LEGACY)
|
||||
}
|
||||
|
||||
fun escapedHtmlToAnnotatedString(text: String, density: Density): AnnotatedString {
|
||||
return spannableStringToAnnotatedString(HtmlCompat.fromHtml(text, HtmlCompat.FROM_HTML_MODE_LEGACY), density)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun annotatedStringResource(id: StringResource): AnnotatedString {
|
||||
val density = LocalDensity.current
|
||||
return remember(id) {
|
||||
val text = id.getString(SimplexApp.context)
|
||||
escapedHtmlToAnnotatedString(text, density)
|
||||
}
|
||||
}
|
||||
|
||||
private fun spannableStringToAnnotatedString(
|
||||
text: CharSequence,
|
||||
density: Density,
|
||||
): AnnotatedString {
|
||||
return if (text is Spanned) {
|
||||
with(density) {
|
||||
buildAnnotatedString {
|
||||
append((text.toString()))
|
||||
text.getSpans(0, text.length, Any::class.java).forEach {
|
||||
val start = text.getSpanStart(it)
|
||||
val end = text.getSpanEnd(it)
|
||||
when (it) {
|
||||
is StyleSpan -> when (it.style) {
|
||||
Typeface.NORMAL -> addStyle(
|
||||
SpanStyle(
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontStyle = FontStyle.Normal,
|
||||
),
|
||||
start,
|
||||
end
|
||||
)
|
||||
Typeface.BOLD -> addStyle(
|
||||
SpanStyle(
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontStyle = FontStyle.Normal
|
||||
),
|
||||
start,
|
||||
end
|
||||
)
|
||||
Typeface.ITALIC -> addStyle(
|
||||
SpanStyle(
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontStyle = FontStyle.Italic
|
||||
),
|
||||
start,
|
||||
end
|
||||
)
|
||||
Typeface.BOLD_ITALIC -> addStyle(
|
||||
SpanStyle(
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontStyle = FontStyle.Italic
|
||||
),
|
||||
start,
|
||||
end
|
||||
)
|
||||
}
|
||||
is TypefaceSpan -> addStyle(
|
||||
SpanStyle(
|
||||
fontFamily = when (it.family) {
|
||||
FontFamily.SansSerif.name -> FontFamily.SansSerif
|
||||
FontFamily.Serif.name -> FontFamily.Serif
|
||||
FontFamily.Monospace.name -> FontFamily.Monospace
|
||||
FontFamily.Cursive.name -> FontFamily.Cursive
|
||||
else -> FontFamily.Default
|
||||
}
|
||||
),
|
||||
start,
|
||||
end
|
||||
)
|
||||
is AbsoluteSizeSpan -> addStyle(
|
||||
SpanStyle(fontSize = if (it.dip) it.size.dp.toSp() else it.size.toSp()),
|
||||
start,
|
||||
end
|
||||
)
|
||||
is RelativeSizeSpan -> addStyle(
|
||||
SpanStyle(fontSize = it.sizeChange.em),
|
||||
start,
|
||||
end
|
||||
)
|
||||
is StrikethroughSpan -> addStyle(
|
||||
SpanStyle(textDecoration = TextDecoration.LineThrough),
|
||||
start,
|
||||
end
|
||||
)
|
||||
is UnderlineSpan -> addStyle(
|
||||
SpanStyle(textDecoration = TextDecoration.Underline),
|
||||
start,
|
||||
end
|
||||
)
|
||||
is SuperscriptSpan -> addStyle(
|
||||
SpanStyle(baselineShift = BaselineShift.Superscript),
|
||||
start,
|
||||
end
|
||||
)
|
||||
is SubscriptSpan -> addStyle(
|
||||
SpanStyle(baselineShift = BaselineShift.Subscript),
|
||||
start,
|
||||
end
|
||||
)
|
||||
is ForegroundColorSpan -> addStyle(
|
||||
SpanStyle(color = Color(it.foregroundColor)),
|
||||
start,
|
||||
end
|
||||
)
|
||||
else -> addStyle(SpanStyle(color = Color.White), start, end)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
AnnotatedString(text.toString())
|
||||
}
|
||||
}
|
||||
|
||||
// maximum image file size to be auto-accepted
|
||||
const val MAX_IMAGE_SIZE: Long = 261_120 // 255KB
|
||||
const val MAX_IMAGE_SIZE_AUTO_RCV: Long = MAX_IMAGE_SIZE * 2
|
||||
const val MAX_VOICE_SIZE_AUTO_RCV: Long = MAX_IMAGE_SIZE * 2
|
||||
const val MAX_VIDEO_SIZE_AUTO_RCV: Long = 1_047_552 // 1023KB
|
||||
|
||||
const val MAX_VOICE_MILLIS_FOR_SENDING: Int = 300_000
|
||||
|
||||
const val MAX_FILE_SIZE_SMP: Long = 8000000
|
||||
|
||||
const val MAX_FILE_SIZE_XFTP: Long = 1_073_741_824 // 1GB
|
||||
|
||||
fun getFilesDirectory(): String {
|
||||
return SimplexApp.context.filesDir.toString()
|
||||
}
|
||||
|
||||
fun getTempFilesDirectory(): String {
|
||||
return "${getFilesDirectory()}/temp_files"
|
||||
}
|
||||
|
||||
fun getAppFilesDirectory(): String {
|
||||
return "${getFilesDirectory()}/app_files"
|
||||
}
|
||||
|
||||
fun getAppFilePath(fileName: String): String {
|
||||
return "${getAppFilesDirectory()}/$fileName"
|
||||
}
|
||||
|
||||
fun getAppFileUri(fileName: String): Uri {
|
||||
return Uri.parse("${getAppFilesDirectory()}/$fileName")
|
||||
}
|
||||
|
||||
|
||||
fun getLoadedFilePath(file: CIFile?): String? {
|
||||
return if (file?.filePath != null && file.loaded) {
|
||||
val filePath = getAppFilePath(file.filePath)
|
||||
if (File(filePath).exists()) filePath else null
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
// https://developer.android.com/training/data-storage/shared/documents-files#bitmap
|
||||
fun getLoadedImage(file: CIFile?): Bitmap? {
|
||||
val filePath = getLoadedFilePath(file)
|
||||
return if (filePath != null) {
|
||||
try {
|
||||
val uri = FileProvider.getUriForFile(SimplexApp.context, "${BuildConfig.APPLICATION_ID}.provider", File(filePath))
|
||||
val parcelFileDescriptor = SimplexApp.context.contentResolver.openFileDescriptor(uri, "r")
|
||||
val fileDescriptor = parcelFileDescriptor?.fileDescriptor
|
||||
val image = decodeSampledBitmapFromFileDescriptor(fileDescriptor, 1000, 1000)
|
||||
parcelFileDescriptor?.close()
|
||||
image
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
// https://developer.android.com/topic/performance/graphics/load-bitmap#load-bitmap
|
||||
private fun decodeSampledBitmapFromFileDescriptor(fileDescriptor: FileDescriptor?, reqWidth: Int, reqHeight: Int): Bitmap {
|
||||
// First decode with inJustDecodeBounds=true to check dimensions
|
||||
return BitmapFactory.Options().run {
|
||||
inJustDecodeBounds = true
|
||||
BitmapFactory.decodeFileDescriptor(fileDescriptor, null, this)
|
||||
// Calculate inSampleSize
|
||||
inSampleSize = calculateInSampleSize(this, reqWidth, reqHeight)
|
||||
// Decode bitmap with inSampleSize set
|
||||
inJustDecodeBounds = false
|
||||
|
||||
BitmapFactory.decodeFileDescriptor(fileDescriptor, null, this)
|
||||
}
|
||||
}
|
||||
|
||||
private fun calculateInSampleSize(options: BitmapFactory.Options, reqWidth: Int, reqHeight: Int): Int {
|
||||
// Raw height and width of image
|
||||
val (height: Int, width: Int) = options.run { outHeight to outWidth }
|
||||
var inSampleSize = 1
|
||||
|
||||
if (height > reqHeight || width > reqWidth) {
|
||||
val halfHeight: Int = height / 2
|
||||
val halfWidth: Int = width / 2
|
||||
// Calculate the largest inSampleSize value that is a power of 2 and keeps both
|
||||
// height and width larger than the requested height and width.
|
||||
while (halfHeight / inSampleSize >= reqHeight && halfWidth / inSampleSize >= reqWidth) {
|
||||
inSampleSize *= 2
|
||||
}
|
||||
}
|
||||
|
||||
return inSampleSize
|
||||
}
|
||||
|
||||
fun getFileName(uri: Uri): String? {
|
||||
return SimplexApp.context.contentResolver.query(uri, null, null, null, null)?.use { cursor ->
|
||||
val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
|
||||
cursor.moveToFirst()
|
||||
cursor.getString(nameIndex)
|
||||
}
|
||||
}
|
||||
|
||||
fun getAppFilePath(uri: Uri): String? {
|
||||
return SimplexApp.context.contentResolver.query(uri, null, null, null, null)?.use { cursor ->
|
||||
val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
|
||||
cursor.moveToFirst()
|
||||
getAppFilePath(cursor.getString(nameIndex))
|
||||
}
|
||||
}
|
||||
|
||||
fun getFileSize(uri: Uri): Long? {
|
||||
return SimplexApp.context.contentResolver.query(uri, null, null, null, null)?.use { cursor ->
|
||||
val sizeIndex = cursor.getColumnIndex(OpenableColumns.SIZE)
|
||||
cursor.moveToFirst()
|
||||
cursor.getLong(sizeIndex)
|
||||
}
|
||||
}
|
||||
|
||||
fun getBitmapFromUri(uri: Uri, withAlertOnException: Boolean = true): Bitmap? {
|
||||
return if (Build.VERSION.SDK_INT >= 28) {
|
||||
val source = ImageDecoder.createSource(SimplexApp.context.contentResolver, uri)
|
||||
try {
|
||||
ImageDecoder.decodeBitmap(source)
|
||||
} catch (e: android.graphics.ImageDecoder.DecodeException) {
|
||||
Log.e(TAG, "Unable to decode the image: ${e.stackTraceToString()}")
|
||||
if (withAlertOnException) {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title = generalGetString(MR.strings.image_decoding_exception_title),
|
||||
text = generalGetString(MR.strings.image_decoding_exception_desc)
|
||||
)
|
||||
}
|
||||
null
|
||||
}
|
||||
} else {
|
||||
BitmapFactory.decodeFile(getAppFilePath(uri))
|
||||
}
|
||||
}
|
||||
|
||||
fun getDrawableFromUri(uri: Uri, withAlertOnException: Boolean = true): Drawable? {
|
||||
return if (Build.VERSION.SDK_INT >= 28) {
|
||||
val source = ImageDecoder.createSource(SimplexApp.context.contentResolver, uri)
|
||||
try {
|
||||
ImageDecoder.decodeDrawable(source)
|
||||
} catch (e: android.graphics.ImageDecoder.DecodeException) {
|
||||
if (withAlertOnException) {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title = generalGetString(MR.strings.image_decoding_exception_title),
|
||||
text = generalGetString(MR.strings.image_decoding_exception_desc)
|
||||
)
|
||||
}
|
||||
Log.e(TAG, "Error while decoding drawable: ${e.stackTraceToString()}")
|
||||
null
|
||||
}
|
||||
} else {
|
||||
Drawable.createFromPath(getAppFilePath(uri))
|
||||
}
|
||||
}
|
||||
|
||||
fun getThemeFromUri(uri: Uri, withAlertOnException: Boolean = true): ThemeOverrides? {
|
||||
SimplexApp.context.contentResolver.openInputStream(uri).use {
|
||||
runCatching {
|
||||
return yaml.decodeFromStream<ThemeOverrides>(it!!)
|
||||
}.onFailure {
|
||||
if (withAlertOnException) {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title = generalGetString(MR.strings.import_theme_error),
|
||||
text = generalGetString(MR.strings.import_theme_error_desc),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
fun saveImage(uri: Uri): String? {
|
||||
val bitmap = getBitmapFromUri(uri) ?: return null
|
||||
return saveImage(bitmap)
|
||||
}
|
||||
|
||||
fun saveImage(image: Bitmap): String? {
|
||||
return try {
|
||||
val ext = if (image.hasAlpha()) "png" else "jpg"
|
||||
val dataResized = resizeImageToDataSize(image, ext == "png", maxDataSize = MAX_IMAGE_SIZE)
|
||||
val fileToSave = generateNewFileName("IMG", ext)
|
||||
val file = File(getAppFilePath(fileToSave))
|
||||
val output = FileOutputStream(file)
|
||||
dataResized.writeTo(output)
|
||||
output.flush()
|
||||
output.close()
|
||||
fileToSave
|
||||
} catch (e: Exception) {
|
||||
Log.e(chat.simplex.app.TAG, "Util.kt saveImage error: ${e.message}")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun saveAnimImage(uri: Uri): String? {
|
||||
return try {
|
||||
val filename = getFileName(uri)?.lowercase()
|
||||
var ext = when {
|
||||
// remove everything but extension
|
||||
filename?.contains(".") == true -> filename.replaceBeforeLast('.', "").replace(".", "")
|
||||
else -> "gif"
|
||||
}
|
||||
// Just in case the image has a strange extension
|
||||
if (ext.length < 3 || ext.length > 4) ext = "gif"
|
||||
val fileToSave = generateNewFileName("IMG", ext)
|
||||
val file = File(getAppFilePath(fileToSave))
|
||||
val output = FileOutputStream(file)
|
||||
SimplexApp.context.contentResolver.openInputStream(uri)!!.use { input ->
|
||||
output.use { output ->
|
||||
input.copyTo(output)
|
||||
}
|
||||
}
|
||||
fileToSave
|
||||
} catch (e: Exception) {
|
||||
Log.e(chat.simplex.app.TAG, "Util.kt saveAnimImage error: ${e.message}")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun saveTempImageUncompressed(image: Bitmap, asPng: Boolean): File? {
|
||||
return try {
|
||||
val ext = if (asPng) "png" else "jpg"
|
||||
val tmpDir = SimplexApp.context.getDir("temp", Application.MODE_PRIVATE)
|
||||
return File(tmpDir.absolutePath + File.separator + generateNewFileName("IMG", ext)).apply {
|
||||
outputStream().use { out ->
|
||||
image.compress(if (asPng) Bitmap.CompressFormat.PNG else Bitmap.CompressFormat.JPEG, 85, out)
|
||||
out.flush()
|
||||
}
|
||||
deleteOnExit()
|
||||
SimplexApp.context.chatModel.filesToDelete.add(this)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Util.kt saveTempImageUncompressed error: ${e.message}")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun saveFileFromUri(uri: Uri): String? {
|
||||
return try {
|
||||
val inputStream = SimplexApp.context.contentResolver.openInputStream(uri)
|
||||
val fileToSave = getFileName(uri)
|
||||
if (inputStream != null && fileToSave != null) {
|
||||
val destFileName = uniqueCombine(fileToSave)
|
||||
val destFile = File(getAppFilePath(destFileName))
|
||||
IOUtils.copy(inputStream, FileOutputStream(destFile))
|
||||
destFileName
|
||||
} else {
|
||||
Log.e(chat.simplex.app.TAG, "Util.kt saveFileFromUri null inputStream")
|
||||
null
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(chat.simplex.app.TAG, "Util.kt saveFileFromUri error: ${e.message}")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun generateNewFileName(prefix: String, ext: String): String {
|
||||
val sdf = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US)
|
||||
sdf.timeZone = TimeZone.getTimeZone("GMT")
|
||||
val timestamp = sdf.format(Date())
|
||||
return uniqueCombine("${prefix}_$timestamp.$ext")
|
||||
}
|
||||
|
||||
fun uniqueCombine(fileName: String): String {
|
||||
val orig = File(fileName)
|
||||
val name = orig.nameWithoutExtension
|
||||
val ext = orig.extension
|
||||
fun tryCombine(n: Int): String {
|
||||
val suffix = if (n == 0) "" else "_$n"
|
||||
val f = "$name$suffix.$ext"
|
||||
return if (File(getAppFilePath(f)).exists()) tryCombine(n + 1) else f
|
||||
}
|
||||
return tryCombine(0)
|
||||
}
|
||||
|
||||
fun formatBytes(bytes: Long): String {
|
||||
if (bytes == 0.toLong()) {
|
||||
return "0 bytes"
|
||||
}
|
||||
val bytesDouble = bytes.toDouble()
|
||||
val k = 1024.toDouble()
|
||||
val units = arrayOf("bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB")
|
||||
val i = floor(log2(bytesDouble) / log2(k))
|
||||
val size = bytesDouble / k.pow(i)
|
||||
val unit = units[i.toInt()]
|
||||
|
||||
return if (i <= 1) {
|
||||
String.format("%.0f %s", size, unit)
|
||||
} else {
|
||||
String.format("%.2f %s", size, unit)
|
||||
}
|
||||
}
|
||||
|
||||
fun removeFile(fileName: String): Boolean {
|
||||
val file = File(getAppFilePath(fileName))
|
||||
val fileDeleted = file.delete()
|
||||
if (!fileDeleted) {
|
||||
Log.e(chat.simplex.app.TAG, "Util.kt removeFile error")
|
||||
}
|
||||
return fileDeleted
|
||||
}
|
||||
|
||||
fun deleteAppFiles() {
|
||||
val dir = File(getAppFilesDirectory())
|
||||
try {
|
||||
dir.list()?.forEach {
|
||||
removeFile(it)
|
||||
}
|
||||
} catch (e: java.lang.Exception) {
|
||||
Log.e(TAG, "Util deleteAppFiles error: ${e.stackTraceToString()}")
|
||||
}
|
||||
}
|
||||
|
||||
fun directoryFileCountAndSize(dir: String): Pair<Int, Long> { // count, size in bytes
|
||||
var fileCount = 0
|
||||
var bytes = 0L
|
||||
try {
|
||||
File(dir).listFiles()?.forEach {
|
||||
fileCount++
|
||||
bytes += it.length()
|
||||
}
|
||||
} catch (e: java.lang.Exception) {
|
||||
Log.e(TAG, "Util directoryFileCountAndSize error: ${e.stackTraceToString()}")
|
||||
}
|
||||
return fileCount to bytes
|
||||
}
|
||||
|
||||
fun getMaxFileSize(fileProtocol: FileProtocol): Long {
|
||||
return when (fileProtocol) {
|
||||
FileProtocol.XFTP -> MAX_FILE_SIZE_XFTP
|
||||
FileProtocol.SMP -> MAX_FILE_SIZE_SMP
|
||||
}
|
||||
}
|
||||
|
||||
fun getBitmapFromVideo(uri: Uri, timestamp: Long? = null, random: Boolean = true): VideoPlayer.PreviewAndDuration {
|
||||
val mmr = MediaMetadataRetriever()
|
||||
mmr.setDataSource(SimplexApp.context, uri)
|
||||
val durationMs = mmr.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)?.toLong()
|
||||
val image = when {
|
||||
timestamp != null -> mmr.getFrameAtTime(timestamp * 1000, MediaMetadataRetriever.OPTION_CLOSEST)
|
||||
random -> mmr.frameAtTime
|
||||
else -> mmr.getFrameAtTime(0)
|
||||
}
|
||||
mmr.release()
|
||||
return VideoPlayer.PreviewAndDuration(image, durationMs, timestamp ?: 0)
|
||||
}
|
||||
|
||||
fun Color.darker(factor: Float = 0.1f): Color =
|
||||
Color(max(red * (1 - factor), 0f), max(green * (1 - factor), 0f), max(blue * (1 - factor), 0f), alpha)
|
||||
|
||||
fun Color.lighter(factor: Float = 0.1f): Color =
|
||||
Color(min(red * (1 + factor), 1f), min(green * (1 + factor), 1f), min(blue * (1 + factor), 1f), alpha)
|
||||
|
||||
fun Color.mixWith(color: Color, alpha: Float): Color =
|
||||
Color(ColorUtils.blendARGB(color.toArgb(), toArgb(), alpha))
|
||||
|
||||
fun ByteArray.toBase64String() = Base64.encodeToString(this, Base64.DEFAULT)
|
||||
|
||||
fun String.toByteArrayFromBase64() = Base64.decode(this, Base64.DEFAULT)
|
||||
|
||||
val LongRange.Companion.saver
|
||||
get() = Saver<MutableState<LongRange>, Pair<Long, Long>>(
|
||||
save = { it.value.first to it.value.last },
|
||||
restore = { mutableStateOf(it.first..it.second) }
|
||||
)
|
||||
|
||||
/* Make sure that T class has @Serializable annotation */
|
||||
inline fun <reified T> serializableSaver(): Saver<T, *> = Saver(
|
||||
save = { json.encodeToString(it) },
|
||||
restore = { json.decodeFromString(it) }
|
||||
)
|
||||
|
||||
fun saveAppLocale(pref: SharedPreference<String?>, activity: Activity, languageCode: String? = null) {
|
||||
// if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
// val localeManager = SimplexApp.context.getSystemService(LocaleManager::class.java)
|
||||
// localeManager.applicationLocales = LocaleList(Locale.forLanguageTag(languageCode ?: return))
|
||||
// } else {
|
||||
pref.set(languageCode)
|
||||
if (languageCode == null) {
|
||||
activity.applyLocale(SimplexApp.context.defaultLocale)
|
||||
}
|
||||
activity.recreate()
|
||||
// }
|
||||
}
|
||||
|
||||
fun Activity.applyAppLocale(pref: SharedPreference<String?>) {
|
||||
// if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
|
||||
val lang = pref.get() ?: return
|
||||
applyLocale(Locale.forLanguageTag(lang))
|
||||
// }
|
||||
}
|
||||
|
||||
private fun Activity.applyLocale(locale: Locale) {
|
||||
Locale.setDefault(locale)
|
||||
val appConf = Configuration(SimplexApp.context.resources.configuration).apply { setLocale(locale) }
|
||||
val activityConf = Configuration(resources.configuration).apply { setLocale(locale) }
|
||||
@Suppress("DEPRECATION")
|
||||
SimplexApp.context.resources.updateConfiguration(appConf, resources.displayMetrics)
|
||||
@Suppress("DEPRECATION")
|
||||
resources.updateConfiguration(activityConf, resources.displayMetrics)
|
||||
}
|
||||
|
||||
fun UriHandler.openUriCatching(uri: String) {
|
||||
try {
|
||||
openUri(uri)
|
||||
} catch (e: ActivityNotFoundException) {
|
||||
Log.e(TAG, e.stackTraceToString())
|
||||
}
|
||||
}
|
||||
|
||||
fun IntSize.Companion.Saver(): Saver<IntSize, *> = Saver(
|
||||
save = { it.width to it.height },
|
||||
restore = { IntSize(it.first, it.second) }
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun DisposableEffectOnGone(always: () -> Unit = {}, whenDispose: () -> Unit = {}, whenGone: () -> Unit) {
|
||||
val context = LocalContext.current
|
||||
DisposableEffect(Unit) {
|
||||
always()
|
||||
val activity = context as? Activity ?: return@DisposableEffect onDispose {}
|
||||
val orientation = activity.resources.configuration.orientation
|
||||
onDispose {
|
||||
whenDispose()
|
||||
if (orientation == activity.resources.configuration.orientation) {
|
||||
whenGone()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DisposableEffectOnRotate(always: () -> Unit = {}, whenDispose: () -> Unit = {}, whenRotate: () -> Unit) {
|
||||
val context = LocalContext.current
|
||||
DisposableEffect(Unit) {
|
||||
always()
|
||||
val activity = context as? Activity ?: return@DisposableEffect onDispose {}
|
||||
val orientation = activity.resources.configuration.orientation
|
||||
onDispose {
|
||||
whenDispose()
|
||||
if (orientation != activity.resources.configuration.orientation) {
|
||||
whenRotate()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
-424
@@ -1,424 +0,0 @@
|
||||
package chat.simplex.app.views.usersettings
|
||||
|
||||
import SectionBottomSpacer
|
||||
import SectionDividerSpaced
|
||||
import SectionItemView
|
||||
import SectionItemViewSpaceBetween
|
||||
import SectionSpacer
|
||||
import SectionView
|
||||
import android.app.Activity
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.content.pm.PackageManager.COMPONENT_ENABLED_STATE_DEFAULT
|
||||
import android.content.pm.PackageManager.COMPONENT_ENABLED_STATE_ENABLED
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import android.widget.Toast
|
||||
import androidx.activity.compose.ManagedActivityResultLauncher
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.MaterialTheme.colors
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.graphics.*
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import dev.icerock.moko.resources.compose.painterResource
|
||||
import dev.icerock.moko.resources.compose.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.graphics.drawable.toBitmap
|
||||
import chat.simplex.app.*
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.*
|
||||
import chat.simplex.app.ui.theme.*
|
||||
import chat.simplex.app.views.helpers.*
|
||||
import com.godaddy.android.colorpicker.*
|
||||
import chat.simplex.res.MR
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.serialization.encodeToString
|
||||
import java.io.BufferedOutputStream
|
||||
import java.util.*
|
||||
import kotlin.collections.ArrayList
|
||||
|
||||
enum class AppIcon(val resId: Int) {
|
||||
DEFAULT(R.mipmap.icon),
|
||||
DARK_BLUE(R.mipmap.icon_dark_blue),
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AppearanceView(m: ChatModel, showSettingsModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit)) {
|
||||
val appIcon = remember { mutableStateOf(findEnabledIcon()) }
|
||||
|
||||
fun setAppIcon(newIcon: AppIcon) {
|
||||
if (appIcon.value == newIcon) return
|
||||
val newComponent = ComponentName(BuildConfig.APPLICATION_ID, "chat.simplex.app.MainActivity_${newIcon.name.lowercase()}")
|
||||
val oldComponent = ComponentName(BuildConfig.APPLICATION_ID, "chat.simplex.app.MainActivity_${appIcon.value.name.lowercase()}")
|
||||
SimplexApp.context.packageManager.setComponentEnabledSetting(
|
||||
newComponent,
|
||||
COMPONENT_ENABLED_STATE_ENABLED, PackageManager.DONT_KILL_APP
|
||||
)
|
||||
|
||||
SimplexApp.context.packageManager.setComponentEnabledSetting(
|
||||
oldComponent,
|
||||
PackageManager.COMPONENT_ENABLED_STATE_DISABLED, PackageManager.DONT_KILL_APP
|
||||
)
|
||||
|
||||
appIcon.value = newIcon
|
||||
}
|
||||
|
||||
AppearanceLayout(
|
||||
appIcon,
|
||||
m.controller.appPrefs.appLanguage,
|
||||
m.controller.appPrefs.systemDarkTheme,
|
||||
changeIcon = ::setAppIcon,
|
||||
showSettingsModal = showSettingsModal,
|
||||
editColor = { name, initialColor ->
|
||||
ModalManager.shared.showModalCloseable { close ->
|
||||
ColorEditor(name, initialColor, close)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable fun AppearanceLayout(
|
||||
icon: MutableState<AppIcon>,
|
||||
languagePref: SharedPreference<String?>,
|
||||
systemDarkTheme: SharedPreference<String?>,
|
||||
changeIcon: (AppIcon) -> Unit,
|
||||
showSettingsModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit),
|
||||
editColor: (ThemeColor, Color) -> Unit,
|
||||
) {
|
||||
Column(
|
||||
Modifier.fillMaxWidth().verticalScroll(rememberScrollState()),
|
||||
) {
|
||||
AppBarTitle(stringResource(MR.strings.appearance_settings))
|
||||
SectionView(stringResource(MR.strings.settings_section_title_language), padding = PaddingValues()) {
|
||||
val context = LocalContext.current
|
||||
// if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
// SectionItemWithValue(
|
||||
// generalGetString(MR.strings.settings_section_title_language).lowercase().replaceFirstChar { if (it.isLowerCase()) it.titlecase(Locale.US) else it.toString() },
|
||||
// remember { mutableStateOf("system") },
|
||||
// listOf(ValueTitleDesc("system", generalGetString(MR.strings.change_verb), "")),
|
||||
// onSelected = { openSystemLangPicker(context as? Activity ?: return@SectionItemWithValue) }
|
||||
// )
|
||||
// } else {
|
||||
val state = rememberSaveable { mutableStateOf(languagePref.get() ?: "system") }
|
||||
LangSelector(state) {
|
||||
state.value = it
|
||||
withApi {
|
||||
delay(200)
|
||||
val activity = context as? Activity
|
||||
if (activity != null) {
|
||||
if (it == "system") {
|
||||
saveAppLocale(languagePref, activity)
|
||||
} else {
|
||||
saveAppLocale(languagePref, activity, it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// }
|
||||
}
|
||||
SectionDividerSpaced()
|
||||
|
||||
SectionView(stringResource(MR.strings.settings_section_title_icon), padding = PaddingValues(horizontal = DEFAULT_PADDING_HALF)) {
|
||||
LazyRow {
|
||||
items(AppIcon.values().size, { index -> AppIcon.values()[index] }) { index ->
|
||||
val item = AppIcon.values()[index]
|
||||
val mipmap = ContextCompat.getDrawable(LocalContext.current, item.resId)!!
|
||||
Image(
|
||||
bitmap = mipmap.toBitmap().asImageBitmap(),
|
||||
contentDescription = "",
|
||||
contentScale = ContentScale.Fit,
|
||||
modifier = Modifier
|
||||
.shadow(if (item == icon.value) 1.dp else 0.dp, ambientColor = colors.secondaryVariant)
|
||||
.size(70.dp)
|
||||
.clickable { changeIcon(item) }
|
||||
.padding(10.dp)
|
||||
)
|
||||
|
||||
if (index + 1 != AppIcon.values().size) {
|
||||
Spacer(Modifier.padding(horizontal = 4.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SectionDividerSpaced(maxTopPadding = true)
|
||||
val currentTheme by CurrentColors.collectAsState()
|
||||
SectionView(stringResource(MR.strings.settings_section_title_themes)) {
|
||||
val darkTheme = isSystemInDarkTheme()
|
||||
val state = remember { derivedStateOf { currentTheme.name } }
|
||||
ThemeSelector(state) {
|
||||
ThemeManager.applyTheme(it, darkTheme)
|
||||
}
|
||||
if (state.value == DefaultTheme.SYSTEM.name) {
|
||||
DarkThemeSelector(remember { systemDarkTheme.state }) {
|
||||
ThemeManager.changeDarkTheme(it, darkTheme)
|
||||
}
|
||||
}
|
||||
}
|
||||
SectionItemView(showSettingsModal { _ -> CustomizeThemeView(editColor) }) { Text(stringResource(MR.strings.customize_theme_title)) }
|
||||
SectionBottomSpacer()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CustomizeThemeView(editColor: (ThemeColor, Color) -> Unit) {
|
||||
Column(
|
||||
Modifier.fillMaxWidth().verticalScroll(rememberScrollState()),
|
||||
) {
|
||||
val currentTheme by CurrentColors.collectAsState()
|
||||
|
||||
AppBarTitle(stringResource(MR.strings.customize_theme_title))
|
||||
|
||||
SectionView(stringResource(MR.strings.theme_colors_section_title)) {
|
||||
SectionItemViewSpaceBetween({ editColor(ThemeColor.PRIMARY, currentTheme.colors.primary) }) {
|
||||
val title = generalGetString(MR.strings.color_primary)
|
||||
Text(title)
|
||||
Icon(painterResource(MR.images.ic_circle_filled), title, tint = colors.primary)
|
||||
}
|
||||
SectionItemViewSpaceBetween({ editColor(ThemeColor.PRIMARY_VARIANT, currentTheme.colors.primaryVariant) }) {
|
||||
val title = generalGetString(MR.strings.color_primary_variant)
|
||||
Text(title)
|
||||
Icon(painterResource(MR.images.ic_circle_filled), title, tint = colors.primaryVariant)
|
||||
}
|
||||
SectionItemViewSpaceBetween({ editColor(ThemeColor.SECONDARY, currentTheme.colors.secondary) }) {
|
||||
val title = generalGetString(MR.strings.color_secondary)
|
||||
Text(title)
|
||||
Icon(painterResource(MR.images.ic_circle_filled), title, tint = colors.secondary)
|
||||
}
|
||||
SectionItemViewSpaceBetween({ editColor(ThemeColor.SECONDARY_VARIANT, currentTheme.colors.secondaryVariant) }) {
|
||||
val title = generalGetString(MR.strings.color_secondary_variant)
|
||||
Text(title)
|
||||
Icon(painterResource(MR.images.ic_circle_filled), title, tint = colors.secondaryVariant)
|
||||
}
|
||||
SectionItemViewSpaceBetween({ editColor(ThemeColor.BACKGROUND, currentTheme.colors.background) }) {
|
||||
val title = generalGetString(MR.strings.color_background)
|
||||
Text(title)
|
||||
Icon(painterResource(MR.images.ic_circle_filled), title, tint = colors.background)
|
||||
}
|
||||
SectionItemViewSpaceBetween({ editColor(ThemeColor.SURFACE, currentTheme.colors.surface) }) {
|
||||
val title = generalGetString(MR.strings.color_surface)
|
||||
Text(title)
|
||||
Icon(painterResource(MR.images.ic_circle_filled), title, tint = colors.surface)
|
||||
}
|
||||
SectionItemViewSpaceBetween({ editColor(ThemeColor.TITLE, currentTheme.appColors.title) }) {
|
||||
val title = generalGetString(MR.strings.color_title)
|
||||
Text(title)
|
||||
Icon(painterResource(MR.images.ic_circle_filled), title, tint = currentTheme.appColors.title)
|
||||
}
|
||||
SectionItemViewSpaceBetween({ editColor(ThemeColor.SENT_MESSAGE, currentTheme.appColors.sentMessage) }) {
|
||||
val title = generalGetString(MR.strings.color_sent_message)
|
||||
Text(title)
|
||||
Icon(painterResource(MR.images.ic_circle_filled), title, tint = currentTheme.appColors.sentMessage)
|
||||
}
|
||||
SectionItemViewSpaceBetween({ editColor(ThemeColor.RECEIVED_MESSAGE, currentTheme.appColors.receivedMessage) }) {
|
||||
val title = generalGetString(MR.strings.color_received_message)
|
||||
Text(title)
|
||||
Icon(painterResource(MR.images.ic_circle_filled), title, tint = currentTheme.appColors.receivedMessage)
|
||||
}
|
||||
}
|
||||
val isInDarkTheme = isInDarkTheme()
|
||||
if (currentTheme.base.hasChangedAnyColor(currentTheme.colors, currentTheme.appColors)) {
|
||||
SectionItemView({ ThemeManager.resetAllThemeColors(darkForSystemTheme = isInDarkTheme) }) {
|
||||
Text(generalGetString(MR.strings.reset_color), color = colors.primary)
|
||||
}
|
||||
}
|
||||
SectionSpacer()
|
||||
SectionView {
|
||||
val theme = remember { mutableStateOf(null as String?) }
|
||||
val exportThemeLauncher = rememberSaveThemeLauncher(theme)
|
||||
SectionItemView({
|
||||
val overrides = ThemeManager.currentThemeOverridesForExport(isInDarkTheme)
|
||||
theme.value = yaml.encodeToString<ThemeOverrides>(overrides)
|
||||
exportThemeLauncher.launch("simplex.theme")
|
||||
}) {
|
||||
Text(generalGetString(MR.strings.export_theme), color = colors.primary)
|
||||
}
|
||||
|
||||
val importThemeLauncher = rememberGetContentLauncher { uri: Uri? ->
|
||||
if (uri != null) {
|
||||
val theme = getThemeFromUri(uri)
|
||||
if (theme != null) {
|
||||
ThemeManager.saveAndApplyThemeOverrides(theme, isInDarkTheme)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Can not limit to YAML mime type since it's unsupported by Android
|
||||
SectionItemView({ importThemeLauncher.launch("*/*") }) {
|
||||
Text(generalGetString(MR.strings.import_theme), color = colors.primary)
|
||||
}
|
||||
}
|
||||
SectionBottomSpacer()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ColorEditor(
|
||||
name: ThemeColor,
|
||||
initialColor: Color,
|
||||
close: () -> Unit,
|
||||
) {
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
) {
|
||||
AppBarTitle(name.text)
|
||||
var currentColor by remember { mutableStateOf(initialColor) }
|
||||
ColorPicker(initialColor) {
|
||||
currentColor = it
|
||||
}
|
||||
|
||||
SectionSpacer()
|
||||
val isInDarkTheme = isInDarkTheme()
|
||||
TextButton(
|
||||
onClick = {
|
||||
ThemeManager.saveAndApplyThemeColor(name, currentColor, isInDarkTheme)
|
||||
close()
|
||||
},
|
||||
Modifier.align(Alignment.CenterHorizontally),
|
||||
colors = ButtonDefaults.textButtonColors(contentColor = currentColor)
|
||||
) {
|
||||
Text(generalGetString(MR.strings.save_color))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ColorPicker(initialColor: Color, onColorChanged: (Color) -> Unit) {
|
||||
ClassicColorPicker(
|
||||
color = initialColor,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(300.dp),
|
||||
showAlphaBar = true,
|
||||
onColorChanged = { color: HsvColor ->
|
||||
onColorChanged(color.toColor())
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LangSelector(state: State<String>, onSelected: (String) -> Unit) {
|
||||
// Should be the same as in app/build.gradle's `android.defaultConfig.resConfigs`
|
||||
val supportedLanguages = mapOf(
|
||||
"system" to generalGetString(MR.strings.language_system),
|
||||
"en" to "English",
|
||||
"cs" to "Čeština",
|
||||
"de" to "Deutsch",
|
||||
"es" to "Español",
|
||||
"fr" to "Français",
|
||||
"it" to "Italiano",
|
||||
"ja" to "日本語",
|
||||
"nl" to "Nederlands",
|
||||
"pl" to "Polski",
|
||||
"pt-BR" to "Português (Brasil)",
|
||||
"ru" to "Русский",
|
||||
"zh-CN" to "简体中文"
|
||||
)
|
||||
val values by remember { mutableStateOf(supportedLanguages.map { it.key to it.value }) }
|
||||
ExposedDropDownSettingRow(
|
||||
generalGetString(MR.strings.settings_section_title_language).lowercase().replaceFirstChar { if (it.isLowerCase()) it.titlecase(Locale.US) else it.toString() },
|
||||
values,
|
||||
state,
|
||||
icon = null,
|
||||
enabled = remember { mutableStateOf(true) },
|
||||
onSelected = onSelected
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ThemeSelector(state: State<String>, onSelected: (String) -> Unit) {
|
||||
val darkTheme = isSystemInDarkTheme()
|
||||
val values by remember { mutableStateOf(ThemeManager.allThemes(darkTheme).map { it.second.name to it.third }) }
|
||||
ExposedDropDownSettingRow(
|
||||
generalGetString(MR.strings.theme),
|
||||
values,
|
||||
state,
|
||||
icon = null,
|
||||
enabled = remember { mutableStateOf(true) },
|
||||
onSelected = onSelected
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DarkThemeSelector(state: State<String?>, onSelected: (String) -> Unit) {
|
||||
val values by remember {
|
||||
val darkThemes = ArrayList<Pair<String, String>>()
|
||||
darkThemes.add(DefaultTheme.DARK.name to generalGetString(MR.strings.theme_dark))
|
||||
darkThemes.add(DefaultTheme.SIMPLEX.name to generalGetString(MR.strings.theme_simplex))
|
||||
mutableStateOf(darkThemes.toList())
|
||||
}
|
||||
ExposedDropDownSettingRow(
|
||||
generalGetString(MR.strings.dark_theme),
|
||||
values,
|
||||
state,
|
||||
icon = null,
|
||||
enabled = remember { mutableStateOf(true) },
|
||||
onSelected = { if (it != null) onSelected(it) }
|
||||
)
|
||||
}
|
||||
|
||||
//private fun openSystemLangPicker(activity: Activity) {
|
||||
// activity.startActivity(Intent(Settings.ACTION_APP_LOCALE_SETTINGS, Uri.parse("package:" + SimplexApp.context.packageName)))
|
||||
//}
|
||||
|
||||
@Composable
|
||||
private fun rememberSaveThemeLauncher(theme: MutableState<String?>): ManagedActivityResultLauncher<String, Uri?> =
|
||||
rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.CreateDocument(),
|
||||
onResult = { destination ->
|
||||
val cxt = SimplexApp.context
|
||||
try {
|
||||
destination?.let {
|
||||
val theme = theme.value
|
||||
if (theme != null) {
|
||||
val contentResolver = cxt.contentResolver
|
||||
contentResolver.openOutputStream(destination)?.let { stream ->
|
||||
BufferedOutputStream(stream).use { outputStream ->
|
||||
theme.byteInputStream().use { it.copyTo(outputStream) }
|
||||
}
|
||||
Toast.makeText(cxt, generalGetString(MR.strings.file_saved), Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Error) {
|
||||
Toast.makeText(cxt, generalGetString(MR.strings.error_saving_file), Toast.LENGTH_SHORT).show()
|
||||
Log.e(TAG, "rememberSaveThemeLauncher error saving theme $e")
|
||||
} finally {
|
||||
theme.value = null
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
private fun findEnabledIcon(): AppIcon = AppIcon.values().first { icon ->
|
||||
SimplexApp.context.packageManager.getComponentEnabledSetting(
|
||||
ComponentName(BuildConfig.APPLICATION_ID, "chat.simplex.app.MainActivity_${icon.name.lowercase()}")
|
||||
).let { it == COMPONENT_ENABLED_STATE_DEFAULT || it == COMPONENT_ENABLED_STATE_ENABLED }
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
fun PreviewAppearanceSettings() {
|
||||
SimpleXTheme {
|
||||
AppearanceLayout(
|
||||
icon = remember { mutableStateOf(AppIcon.DARK_BLUE) },
|
||||
languagePref = SharedPreference({ null }, {}),
|
||||
systemDarkTheme = SharedPreference({ null }, {}),
|
||||
changeIcon = {},
|
||||
showSettingsModal = { {} },
|
||||
editColor = { _, _ -> },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -55,6 +55,7 @@ allprojects {
|
||||
google()
|
||||
mavenCentral()
|
||||
maven("https://maven.pkg.jetbrains.space/public/p/compose/dev")
|
||||
maven("https://oss.sonatype.org/content/repositories/snapshots")
|
||||
maven("https://jitpack.io")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,7 +31,6 @@ kotlin {
|
||||
}
|
||||
|
||||
val commonMain by getting {
|
||||
kotlin.srcDir("./build/generated/moko/commonMain/src/")
|
||||
dependencies {
|
||||
api(compose.runtime)
|
||||
api(compose.foundation)
|
||||
@@ -57,54 +56,47 @@ kotlin {
|
||||
implementation(kotlin("test"))
|
||||
}
|
||||
}
|
||||
// LALAL CHANGE TO IMPLEMENTATION
|
||||
val androidMain by getting {
|
||||
kotlin.srcDir("./build/generated/moko/commonMain/src/")
|
||||
dependencies {
|
||||
api("androidx.appcompat:appcompat:1.5.1")
|
||||
api("androidx.core:core-ktx:1.9.0")
|
||||
api("androidx.activity:activity-compose:1.5.0")
|
||||
implementation("androidx.activity:activity-compose:1.5.0")
|
||||
val work_version = "2.7.1"
|
||||
api("androidx.work:work-runtime-ktx:$work_version")
|
||||
api("androidx.work:work-multiprocess:$work_version")
|
||||
api("com.google.accompanist:accompanist-insets:0.23.0")
|
||||
api("dev.icerock.moko:resources:0.22.3")
|
||||
implementation("androidx.work:work-runtime-ktx:$work_version")
|
||||
implementation("com.google.accompanist:accompanist-insets:0.23.0")
|
||||
implementation("dev.icerock.moko:resources:0.22.3")
|
||||
|
||||
// Video support
|
||||
api("com.google.android.exoplayer:exoplayer:2.17.1")
|
||||
implementation("com.google.android.exoplayer:exoplayer:2.17.1")
|
||||
|
||||
// Biometric authentication
|
||||
api("androidx.biometric:biometric:1.2.0-alpha04")
|
||||
implementation("androidx.biometric:biometric:1.2.0-alpha04")
|
||||
|
||||
//Barcode
|
||||
api("org.boofcv:boofcv-android:0.40.1")
|
||||
implementation("org.boofcv:boofcv-android:0.40.1")
|
||||
|
||||
//Camera Permission
|
||||
api("com.google.accompanist:accompanist-permissions:0.23.0")
|
||||
implementation("com.google.accompanist:accompanist-permissions:0.23.0")
|
||||
|
||||
api("androidx.webkit:webkit:1.4.0")
|
||||
implementation("androidx.webkit:webkit:1.4.0")
|
||||
|
||||
// GIFs support
|
||||
api("io.coil-kt:coil-compose:2.1.0")
|
||||
api("io.coil-kt:coil-gif:2.1.0")
|
||||
implementation("io.coil-kt:coil-compose:2.1.0")
|
||||
implementation("io.coil-kt:coil-gif:2.1.0")
|
||||
|
||||
api("com.jakewharton:process-phoenix:2.1.2")
|
||||
implementation("com.jakewharton:process-phoenix:2.1.2")
|
||||
|
||||
val camerax_version = "1.1.0-beta01"
|
||||
api("androidx.camera:camera-core:${camerax_version}")
|
||||
api("androidx.camera:camera-camera2:${camerax_version}")
|
||||
api("androidx.camera:camera-lifecycle:${camerax_version}")
|
||||
api("androidx.camera:camera-view:${camerax_version}")
|
||||
|
||||
// LALAL REMOVE
|
||||
api("org.jsoup:jsoup:1.13.1")
|
||||
api("com.godaddy.android.colorpicker:compose-color-picker-jvm:0.7.0")
|
||||
api("androidx.compose.ui:ui-tooling-preview:${extra["compose.version"]}")
|
||||
implementation("androidx.camera:camera-core:${camerax_version}")
|
||||
implementation("androidx.camera:camera-camera2:${camerax_version}")
|
||||
implementation("androidx.camera:camera-lifecycle:${camerax_version}")
|
||||
implementation("androidx.camera:camera-view:${camerax_version}")
|
||||
}
|
||||
}
|
||||
val desktopMain by getting {
|
||||
dependencies {
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-swing:1.7.1")
|
||||
implementation("com.github.Dansoftowner:jSystemThemeDetector:3.6")
|
||||
implementation("com.sshtools:two-slices:0.9.0-SNAPSHOT")
|
||||
implementation("org.slf4j:slf4j-simple:2.0.7")
|
||||
}
|
||||
}
|
||||
val desktopTest by getting
|
||||
@@ -122,6 +114,15 @@ android {
|
||||
sourceCompatibility = JavaVersion.VERSION_1_8
|
||||
targetCompatibility = JavaVersion.VERSION_1_8
|
||||
}
|
||||
val isAndroid = gradle.startParameter.taskNames.find {
|
||||
val lower = it.toLowerCase()
|
||||
lower.contains("release") || lower.startsWith("assemble") || lower.startsWith("install")
|
||||
} != null
|
||||
if (isAndroid) {
|
||||
// This is not needed on Android but can't be moved to desktopMain because MR lib don't support this.
|
||||
// No other ways to exclude a file work but it's large and should be excluded
|
||||
kotlin.sourceSets["commonMain"].resources.exclude("/MR/fonts/NotoColorEmoji-Regular.ttf")
|
||||
}
|
||||
}
|
||||
|
||||
multiplatformResources {
|
||||
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
package chat.simplex.common.helpers
|
||||
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import chat.simplex.common.model.NotificationsMode
|
||||
import java.net.URI
|
||||
|
||||
val NotificationsMode.requiresIgnoringBatterySinceSdk: Int get() = when(this) {
|
||||
NotificationsMode.OFF -> Int.MAX_VALUE
|
||||
NotificationsMode.PERIODIC -> Build.VERSION_CODES.M
|
||||
NotificationsMode.SERVICE -> Build.VERSION_CODES.S
|
||||
/*INSTANT -> Int.MAX_VALUE - for Firebase notifications */
|
||||
}
|
||||
|
||||
val NotificationsMode.requiresIgnoringBattery
|
||||
get() = requiresIgnoringBatterySinceSdk <= Build.VERSION.SDK_INT
|
||||
|
||||
lateinit var APPLICATION_ID: String
|
||||
|
||||
fun Uri.toURI(): URI = URI(toString())
|
||||
|
||||
fun URI.toUri(): Uri = Uri.parse(toString())
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
package chat.simplex.common.helpers
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.res.Configuration
|
||||
import chat.simplex.common.model.SharedPreference
|
||||
import chat.simplex.common.platform.androidAppContext
|
||||
import chat.simplex.common.platform.defaultLocale
|
||||
import java.util.*
|
||||
|
||||
fun Activity.saveAppLocale(pref: SharedPreference<String?>, languageCode: String? = null) {
|
||||
// if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
// val localeManager = SimplexApp.context.getSystemService(LocaleManager::class.java)
|
||||
// localeManager.applicationLocales = LocaleList(Locale.forLanguageTag(languageCode ?: return))
|
||||
// } else {
|
||||
pref.set(languageCode)
|
||||
if (languageCode == null) {
|
||||
applyLocale(defaultLocale)
|
||||
}
|
||||
recreate()
|
||||
// }
|
||||
}
|
||||
|
||||
fun Activity.applyAppLocale(pref: SharedPreference<String?>) {
|
||||
// if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
|
||||
val lang = pref.get() ?: return
|
||||
applyLocale(Locale.forLanguageTag(lang))
|
||||
// }
|
||||
}
|
||||
|
||||
private fun Activity.applyLocale(locale: Locale) {
|
||||
Locale.setDefault(locale)
|
||||
val appConf = Configuration(androidAppContext.resources.configuration).apply { setLocale(locale) }
|
||||
val activityConf = Configuration(resources.configuration).apply { setLocale(locale) }
|
||||
@Suppress("DEPRECATION")
|
||||
androidAppContext.resources.updateConfiguration(appConf, resources.displayMetrics)
|
||||
@Suppress("DEPRECATION")
|
||||
resources.updateConfiguration(activityConf, resources.displayMetrics)
|
||||
}
|
||||
+10
-14
@@ -1,22 +1,22 @@
|
||||
package chat.simplex.app.views.call
|
||||
package chat.simplex.common.helpers
|
||||
|
||||
import android.content.Context
|
||||
import android.media.*
|
||||
import android.net.Uri
|
||||
import android.os.VibrationEffect
|
||||
import android.os.Vibrator
|
||||
import androidx.core.content.ContextCompat
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.SimplexApp
|
||||
import chat.simplex.app.views.helpers.withScope
|
||||
import chat.simplex.common.R
|
||||
import chat.simplex.common.platform.SoundPlayerInterface
|
||||
import chat.simplex.common.platform.androidAppContext
|
||||
import chat.simplex.common.views.helpers.withScope
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
class SoundPlayer {
|
||||
object SoundPlayer: SoundPlayerInterface {
|
||||
private var player: MediaPlayer? = null
|
||||
var playing = false
|
||||
|
||||
fun start(scope: CoroutineScope, sound: Boolean) {
|
||||
override fun start(scope: CoroutineScope, sound: Boolean) {
|
||||
player?.reset()
|
||||
player = MediaPlayer().apply {
|
||||
setAudioAttributes(
|
||||
@@ -25,10 +25,10 @@ class SoundPlayer {
|
||||
.setUsage(AudioAttributes.USAGE_NOTIFICATION_RINGTONE)
|
||||
.build()
|
||||
)
|
||||
setDataSource(SimplexApp.context, Uri.parse("android.resource://" + SimplexApp.context.packageName + "/" + R.raw.ring_once))
|
||||
setDataSource(androidAppContext, Uri.parse("android.resource://" + androidAppContext.packageName + "/" + R.raw.ring_once))
|
||||
prepare()
|
||||
}
|
||||
val vibrator = ContextCompat.getSystemService(SimplexApp.context, Vibrator::class.java)
|
||||
val vibrator = ContextCompat.getSystemService(androidAppContext, Vibrator::class.java)
|
||||
val effect = VibrationEffect.createOneShot(250, VibrationEffect.DEFAULT_AMPLITUDE)
|
||||
playing = true
|
||||
withScope(scope) {
|
||||
@@ -40,12 +40,8 @@ class SoundPlayer {
|
||||
}
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
override fun stop() {
|
||||
playing = false
|
||||
player?.stop()
|
||||
}
|
||||
|
||||
companion object {
|
||||
val shared = SoundPlayer()
|
||||
}
|
||||
}
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
package chat.simplex.common.platform
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.net.LocalServerSocket
|
||||
import android.util.Log
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import chat.simplex.common.*
|
||||
import chat.simplex.common.platform.*
|
||||
import java.io.*
|
||||
import java.lang.ref.WeakReference
|
||||
import java.util.*
|
||||
import java.util.concurrent.Semaphore
|
||||
import kotlin.concurrent.thread
|
||||
import kotlin.random.Random
|
||||
|
||||
actual val appPlatform = AppPlatform.ANDROID
|
||||
|
||||
var isAppOnForeground: Boolean = false
|
||||
|
||||
@Suppress("ConstantLocale")
|
||||
val defaultLocale: Locale = Locale.getDefault()
|
||||
|
||||
@SuppressLint("StaticFieldLeak")
|
||||
lateinit var androidAppContext: Context
|
||||
lateinit var mainActivity: WeakReference<FragmentActivity>
|
||||
|
||||
fun initHaskell() {
|
||||
val socketName = "chat.simplex.app.local.socket.address.listen.native.cmd2" + Random.nextLong(100000)
|
||||
val s = Semaphore(0)
|
||||
thread(name="stdout/stderr pipe") {
|
||||
Log.d(TAG, "starting server")
|
||||
var server: LocalServerSocket? = null
|
||||
for (i in 0..100) {
|
||||
try {
|
||||
server = LocalServerSocket(socketName + i)
|
||||
break
|
||||
} catch (e: IOException) {
|
||||
Log.e(TAG, e.stackTraceToString())
|
||||
}
|
||||
}
|
||||
if (server == null) {
|
||||
throw Error("Unable to setup local server socket. Contact developers")
|
||||
}
|
||||
Log.d(TAG, "started server")
|
||||
s.release()
|
||||
val receiver = server.accept()
|
||||
Log.d(TAG, "started receiver")
|
||||
val logbuffer = FifoQueue<String>(500)
|
||||
if (receiver != null) {
|
||||
val inStream = receiver.inputStream
|
||||
val inStreamReader = InputStreamReader(inStream)
|
||||
val input = BufferedReader(inStreamReader)
|
||||
Log.d(TAG, "starting receiver loop")
|
||||
while (true) {
|
||||
val line = input.readLine() ?: break
|
||||
Log.w("$TAG (stdout/stderr)", line)
|
||||
logbuffer.add(line)
|
||||
}
|
||||
Log.w(TAG, "exited receiver loop")
|
||||
}
|
||||
}
|
||||
|
||||
System.loadLibrary("app-lib")
|
||||
|
||||
s.acquire()
|
||||
pipeStdOutToSocket(socketName)
|
||||
|
||||
initHS()
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
package chat.simplex.common.platform
|
||||
|
||||
import androidx.compose.runtime.*
|
||||
|
||||
@SuppressWarnings("MissingJvmstatic")
|
||||
@Composable
|
||||
actual fun BackHandler(enabled: Boolean, onBack: () -> Unit) {
|
||||
androidx.activity.compose.BackHandler(enabled, onBack)
|
||||
}
|
||||
+9
-10
@@ -1,24 +1,23 @@
|
||||
package chat.simplex.app.views.usersettings
|
||||
package chat.simplex.common.platform
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.security.keystore.KeyGenParameterSpec
|
||||
import android.security.keystore.KeyProperties
|
||||
import android.util.Log
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.TAG
|
||||
import chat.simplex.app.views.helpers.AlertManager
|
||||
import chat.simplex.app.views.helpers.generalGetString
|
||||
import chat.simplex.common.views.helpers.AlertManager
|
||||
import chat.simplex.common.views.helpers.generalGetString
|
||||
import chat.simplex.res.MR
|
||||
import java.security.KeyStore
|
||||
import javax.crypto.*
|
||||
import javax.crypto.spec.GCMParameterSpec
|
||||
|
||||
actual val cryptor: CryptorInterface = Cryptor()
|
||||
|
||||
@SuppressLint("ObsoleteSdkInt")
|
||||
internal class Cryptor {
|
||||
internal class Cryptor: CryptorInterface {
|
||||
private var keyStore: KeyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
|
||||
private var warningShown = false
|
||||
|
||||
fun decryptData(data: ByteArray, iv: ByteArray, alias: String): String? {
|
||||
override fun decryptData(data: ByteArray, iv: ByteArray, alias: String): String? {
|
||||
val secretKey = getSecretKey(alias)
|
||||
if (secretKey == null) {
|
||||
if (!warningShown) {
|
||||
@@ -37,13 +36,13 @@ internal class Cryptor {
|
||||
return runCatching { String(cipher.doFinal(data))}.onFailure { Log.e(TAG, "doFinal: ${it.stackTraceToString()}") }.getOrNull()
|
||||
}
|
||||
|
||||
fun encryptText(text: String, alias: String): Pair<ByteArray, ByteArray> {
|
||||
override fun encryptText(text: String, alias: String): Pair<ByteArray, ByteArray> {
|
||||
val cipher: Cipher = Cipher.getInstance(TRANSFORMATION)
|
||||
cipher.init(Cipher.ENCRYPT_MODE, createSecretKey(alias))
|
||||
return Pair(cipher.doFinal(text.toByteArray(charset("UTF-8"))), cipher.iv)
|
||||
}
|
||||
|
||||
fun deleteKey(alias: String) {
|
||||
override fun deleteKey(alias: String) {
|
||||
if (!keyStore.containsAlias(alias)) return
|
||||
keyStore.deleteEntry(alias)
|
||||
}
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
package chat.simplex.common.platform
|
||||
|
||||
import android.app.Application
|
||||
import android.net.Uri
|
||||
import androidx.activity.compose.ManagedActivityResultLauncher
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.runtime.Composable
|
||||
import chat.simplex.common.helpers.toURI
|
||||
import chat.simplex.common.helpers.toUri
|
||||
import java.io.*
|
||||
import java.net.URI
|
||||
|
||||
actual val dataDir: File = androidAppContext.dataDir
|
||||
actual val tmpDir: File = androidAppContext.getDir("temp", Application.MODE_PRIVATE)
|
||||
actual val filesDir: File = File(dataDir.absolutePath + File.separator + "files")
|
||||
actual val appFilesDir: File = File(filesDir.absolutePath + File.separator + "app_files")
|
||||
actual val coreTmpDir: File = File(filesDir.absolutePath + File.separator + "temp_files")
|
||||
actual val dbAbsolutePrefixPath: String = dataDir.absolutePath + File.separator + "files"
|
||||
|
||||
actual val chatDatabaseFileName: String = "files_chat.db"
|
||||
actual val agentDatabaseFileName: String = "files_agent.db"
|
||||
|
||||
actual val databaseExportDir: File = androidAppContext.cacheDir
|
||||
|
||||
@Composable
|
||||
actual fun rememberFileChooserLauncher(getContent: Boolean, rememberedValue: Any?, onResult: (URI?) -> Unit): FileChooserLauncher {
|
||||
val launcher = rememberLauncherForActivityResult(
|
||||
contract = if (getContent) ActivityResultContracts.GetContent() else ActivityResultContracts.CreateDocument(),
|
||||
onResult = { onResult(it?.toURI()) }
|
||||
)
|
||||
return FileChooserLauncher(launcher)
|
||||
}
|
||||
|
||||
@Composable
|
||||
actual fun rememberFileChooserMultipleLauncher(onResult: (List<URI>) -> Unit): FileChooserMultipleLauncher {
|
||||
val launcher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.GetMultipleContents(),
|
||||
onResult = { onResult(it.map { it.toURI() }) }
|
||||
)
|
||||
return FileChooserMultipleLauncher(launcher)
|
||||
}
|
||||
|
||||
actual class FileChooserLauncher actual constructor() {
|
||||
private lateinit var launcher: ManagedActivityResultLauncher<String, Uri?>
|
||||
|
||||
constructor(launcher: ManagedActivityResultLauncher<String, Uri?>): this() {
|
||||
this.launcher = launcher
|
||||
}
|
||||
|
||||
actual suspend fun launch(input: String) {
|
||||
launcher.launch(input)
|
||||
}
|
||||
}
|
||||
|
||||
actual class FileChooserMultipleLauncher actual constructor() {
|
||||
private lateinit var launcher: ManagedActivityResultLauncher<String, List<Uri>>
|
||||
|
||||
constructor(launcher: ManagedActivityResultLauncher<String, List<Uri>>): this() {
|
||||
this.launcher = launcher
|
||||
}
|
||||
|
||||
actual suspend fun launch(input: String) {
|
||||
launcher.launch(input)
|
||||
}
|
||||
}
|
||||
|
||||
actual fun URI.inputStream(): InputStream? = androidAppContext.contentResolver.openInputStream(toUri())
|
||||
actual fun URI.outputStream(): OutputStream = androidAppContext.contentResolver.openOutputStream(toUri())!!
|
||||
+116
@@ -0,0 +1,116 @@
|
||||
package chat.simplex.common.platform
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.graphics.drawable.AnimatedImageDrawable
|
||||
import android.os.Build
|
||||
import android.util.Base64
|
||||
import android.webkit.MimeTypeMap
|
||||
import androidx.compose.ui.graphics.*
|
||||
import androidx.core.graphics.applyCanvas
|
||||
import androidx.core.graphics.drawable.toBitmap
|
||||
import androidx.core.graphics.scale
|
||||
import boofcv.android.ConvertBitmap
|
||||
import boofcv.struct.image.GrayU8
|
||||
import chat.simplex.common.R
|
||||
import chat.simplex.common.views.helpers.errorBitmap
|
||||
import chat.simplex.common.views.helpers.getFileName
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.InputStream
|
||||
import java.net.URI
|
||||
import kotlin.math.min
|
||||
import kotlin.math.sqrt
|
||||
|
||||
actual fun base64ToBitmap(base64ImageString: String): ImageBitmap {
|
||||
val imageString = base64ImageString
|
||||
.removePrefix("data:image/png;base64,")
|
||||
.removePrefix("data:image/jpg;base64,")
|
||||
return try {
|
||||
val imageBytes = Base64.decode(imageString, Base64.NO_WRAP)
|
||||
BitmapFactory.decodeByteArray(imageBytes, 0, imageBytes.size).asImageBitmap()
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "base64ToBitmap error: $e")
|
||||
errorBitmap.asImageBitmap()
|
||||
}
|
||||
}
|
||||
|
||||
actual fun resizeImageToStrSize(image: ImageBitmap, maxDataSize: Long): String {
|
||||
var img = image
|
||||
var str = compressImageStr(img)
|
||||
while (str.length > maxDataSize) {
|
||||
val ratio = sqrt(str.length.toDouble() / maxDataSize.toDouble())
|
||||
val clippedRatio = min(ratio, 2.0)
|
||||
val width = (img.width.toDouble() / clippedRatio).toInt()
|
||||
val height = img.height * width / img.width
|
||||
img = Bitmap.createScaledBitmap(img.asAndroidBitmap(), width, height, true).asImageBitmap()
|
||||
str = compressImageStr(img)
|
||||
}
|
||||
return str
|
||||
}
|
||||
|
||||
// Inspired by https://github.com/MakeItEasyDev/Jetpack-Compose-Capture-Image-Or-Choose-from-Gallery
|
||||
actual fun cropToSquare(image: ImageBitmap): ImageBitmap {
|
||||
var xOffset = 0
|
||||
var yOffset = 0
|
||||
val side = min(image.height, image.width)
|
||||
if (image.height < image.width) {
|
||||
xOffset = (image.width - side) / 2
|
||||
} else {
|
||||
yOffset = (image.height - side) / 2
|
||||
}
|
||||
return Bitmap.createBitmap(image.asAndroidBitmap(), xOffset, yOffset, side, side).asImageBitmap()
|
||||
}
|
||||
|
||||
actual fun compressImageStr(bitmap: ImageBitmap): String {
|
||||
val usePng = bitmap.hasAlpha
|
||||
val ext = if (usePng) "png" else "jpg"
|
||||
return "data:image/$ext;base64," + Base64.encodeToString(compressImageData(bitmap, usePng).toByteArray(), Base64.NO_WRAP)
|
||||
}
|
||||
|
||||
actual fun compressImageData(bitmap: ImageBitmap, usePng: Boolean): ByteArrayOutputStream {
|
||||
val stream = ByteArrayOutputStream()
|
||||
bitmap.asAndroidBitmap().compress(if (!usePng) Bitmap.CompressFormat.JPEG else Bitmap.CompressFormat.PNG, 85, stream)
|
||||
return stream
|
||||
}
|
||||
|
||||
actual fun resizeImageToDataSize(image: ImageBitmap, usePng: Boolean, maxDataSize: Long): ByteArrayOutputStream {
|
||||
var img = image
|
||||
var stream = compressImageData(img, usePng)
|
||||
while (stream.size() > maxDataSize) {
|
||||
val ratio = sqrt(stream.size().toDouble() / maxDataSize.toDouble())
|
||||
val clippedRatio = min(ratio, 2.0)
|
||||
val width = (img.width.toDouble() / clippedRatio).toInt()
|
||||
val height = img.height * width / img.width
|
||||
img = Bitmap.createScaledBitmap(img.asAndroidBitmap(), width, height, true).asImageBitmap()
|
||||
stream = compressImageData(img, usePng)
|
||||
}
|
||||
return stream
|
||||
}
|
||||
|
||||
actual fun GrayU8.toImageBitmap(): ImageBitmap = ConvertBitmap.grayToBitmap(this, Bitmap.Config.RGB_565).asImageBitmap()
|
||||
|
||||
actual fun ImageBitmap.addLogo(): ImageBitmap = asAndroidBitmap().applyCanvas {
|
||||
val radius = (width * 0.16f) / 2
|
||||
val paint = android.graphics.Paint()
|
||||
paint.color = android.graphics.Color.WHITE
|
||||
drawCircle(width / 2f, height / 2f, radius, paint)
|
||||
val logo = androidAppContext.resources.getDrawable(R.drawable.icon_foreground_android_common, null).toBitmap()
|
||||
val logoSize = (width * 0.24).toInt()
|
||||
translate((width - logoSize) / 2f, (height - logoSize) / 2f)
|
||||
drawBitmap(logo, null, android.graphics.Rect(0, 0, logoSize, logoSize), null)
|
||||
}.asImageBitmap()
|
||||
|
||||
actual fun ImageBitmap.scale(width: Int, height: Int): ImageBitmap = asAndroidBitmap().scale(width, height).asImageBitmap()
|
||||
|
||||
actual fun isImage(uri: URI): Boolean =
|
||||
MimeTypeMap.getSingleton().getMimeTypeFromExtension(getFileName(uri)?.split(".")?.last())?.contains("image/") == true
|
||||
|
||||
actual fun isAnimImage(uri: URI, drawable: Any?): Boolean {
|
||||
val isAnimNewApi = Build.VERSION.SDK_INT >= 28 && drawable is AnimatedImageDrawable
|
||||
val isAnimOldApi = Build.VERSION.SDK_INT < 28 &&
|
||||
(getFileName(uri)?.endsWith(".gif") == true || getFileName(uri)?.endsWith(".webp") == true)
|
||||
return isAnimNewApi || isAnimOldApi
|
||||
}
|
||||
|
||||
actual fun loadImageBitmap(inputStream: InputStream): ImageBitmap =
|
||||
BitmapFactory.decodeStream(inputStream).asImageBitmap()
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
package chat.simplex.common.platform
|
||||
|
||||
import android.util.Log
|
||||
|
||||
actual object Log {
|
||||
actual fun d(tag: String, text: String) = Log.d(tag, text).run{}
|
||||
actual fun e(tag: String, text: String) = Log.e(tag, text).run{}
|
||||
actual fun i(tag: String, text: String) = Log.i(tag, text).run{}
|
||||
actual fun w(tag: String, text: String) = Log.w(tag, text).run{}
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
package chat.simplex.common.platform
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.painter.Painter
|
||||
import com.google.accompanist.insets.navigationBarsWithImePadding
|
||||
|
||||
actual fun Modifier.navigationBarsWithImePadding(): Modifier = navigationBarsWithImePadding()
|
||||
|
||||
@Composable
|
||||
actual fun ProvideWindowInsets(
|
||||
consumeWindowInsets: Boolean,
|
||||
windowInsetsAnimationsEnabled: Boolean,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
com.google.accompanist.insets.ProvideWindowInsets(content = content)
|
||||
}
|
||||
|
||||
@Composable
|
||||
actual fun Modifier.desktopOnExternalDrag(
|
||||
enabled: Boolean,
|
||||
onFiles: (List<String>) -> Unit,
|
||||
onImage: (Painter) -> Unit,
|
||||
onText: (String) -> Unit
|
||||
): Modifier = this
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
package chat.simplex.common.platform
|
||||
|
||||
actual fun allowedToShowNotification(): Boolean = !isAppOnForeground
|
||||
+162
@@ -0,0 +1,162 @@
|
||||
package chat.simplex.common.platform
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.text.InputType
|
||||
import android.util.Log
|
||||
import android.view.OnReceiveContentListener
|
||||
import android.view.ViewGroup
|
||||
import android.view.inputmethod.*
|
||||
import android.widget.EditText
|
||||
import android.widget.TextView
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.core.graphics.drawable.DrawableCompat
|
||||
import androidx.core.view.inputmethod.EditorInfoCompat
|
||||
import androidx.core.view.inputmethod.InputConnectionCompat
|
||||
import androidx.core.widget.doAfterTextChanged
|
||||
import androidx.core.widget.doOnTextChanged
|
||||
import chat.simplex.common.*
|
||||
import chat.simplex.common.R
|
||||
import chat.simplex.common.helpers.toURI
|
||||
import chat.simplex.common.model.ChatModel
|
||||
import chat.simplex.common.ui.theme.CurrentColors
|
||||
import chat.simplex.common.views.chat.*
|
||||
import chat.simplex.common.views.helpers.SharedContent
|
||||
import chat.simplex.common.views.helpers.generalGetString
|
||||
import chat.simplex.res.MR
|
||||
import dev.icerock.moko.resources.StringResource
|
||||
import kotlinx.coroutines.delay
|
||||
import java.lang.reflect.Field
|
||||
import java.net.URI
|
||||
|
||||
@Composable
|
||||
actual fun PlatformTextField(
|
||||
composeState: MutableState<ComposeState>,
|
||||
textStyle: MutableState<TextStyle>,
|
||||
showDeleteTextButton: MutableState<Boolean>,
|
||||
userIsObserver: Boolean,
|
||||
onMessageChange: (String) -> Unit,
|
||||
onUpArrow: () -> Unit,
|
||||
onDone: () -> Unit,
|
||||
) {
|
||||
val cs = composeState.value
|
||||
val textColor = MaterialTheme.colors.onBackground
|
||||
val tintColor = MaterialTheme.colors.secondaryVariant
|
||||
val padding = PaddingValues(12.dp, 7.dp, 45.dp, 0.dp)
|
||||
val paddingStart = with(LocalDensity.current) { 12.dp.roundToPx() }
|
||||
val paddingTop = with(LocalDensity.current) { 7.dp.roundToPx() }
|
||||
val paddingEnd = with(LocalDensity.current) { 45.dp.roundToPx() }
|
||||
val paddingBottom = with(LocalDensity.current) { 7.dp.roundToPx() }
|
||||
var showKeyboard by remember { mutableStateOf(false) }
|
||||
LaunchedEffect(cs.contextItem) {
|
||||
if (cs.contextItem is ComposeContextItem.QuotedItem) {
|
||||
delay(100)
|
||||
showKeyboard = true
|
||||
} else if (cs.contextItem is ComposeContextItem.EditingItem) {
|
||||
// Keyboard will not show up if we try to show it too fast
|
||||
delay(300)
|
||||
showKeyboard = true
|
||||
}
|
||||
}
|
||||
|
||||
AndroidView(modifier = Modifier, factory = {
|
||||
val editText = @SuppressLint("AppCompatCustomView") object: EditText(it) {
|
||||
override fun setOnReceiveContentListener(
|
||||
mimeTypes: Array<out String>?,
|
||||
listener: OnReceiveContentListener?
|
||||
) {
|
||||
super.setOnReceiveContentListener(mimeTypes, listener)
|
||||
}
|
||||
|
||||
override fun onCreateInputConnection(editorInfo: EditorInfo): InputConnection {
|
||||
val connection = super.onCreateInputConnection(editorInfo)
|
||||
EditorInfoCompat.setContentMimeTypes(editorInfo, arrayOf("image/*"))
|
||||
val onCommit = InputConnectionCompat.OnCommitContentListener { inputContentInfo, _, _ ->
|
||||
try {
|
||||
inputContentInfo.requestPermission()
|
||||
} catch (e: Exception) {
|
||||
return@OnCommitContentListener false
|
||||
}
|
||||
ChatModel.sharedContent.value = SharedContent.Media("", listOf(inputContentInfo.contentUri.toURI()))
|
||||
true
|
||||
}
|
||||
return InputConnectionCompat.createWrapper(connection, editorInfo, onCommit)
|
||||
}
|
||||
}
|
||||
editText.layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)
|
||||
editText.maxLines = 16
|
||||
editText.inputType = InputType.TYPE_TEXT_FLAG_CAP_SENTENCES or editText.inputType
|
||||
editText.setTextColor(textColor.toArgb())
|
||||
editText.textSize = textStyle.value.fontSize.value
|
||||
val drawable = androidAppContext.getDrawable(R.drawable.send_msg_view_background)!!
|
||||
DrawableCompat.setTint(drawable, tintColor.toArgb())
|
||||
editText.background = drawable
|
||||
editText.setPadding(paddingStart, paddingTop, paddingEnd, paddingBottom)
|
||||
editText.setText(cs.message)
|
||||
if (Build.VERSION.SDK_INT >= 29) {
|
||||
editText.textCursorDrawable?.let { DrawableCompat.setTint(it, CurrentColors.value.colors.secondary.toArgb()) }
|
||||
} else {
|
||||
try {
|
||||
val f: Field = TextView::class.java.getDeclaredField("mCursorDrawableRes")
|
||||
f.isAccessible = true
|
||||
f.set(editText, R.drawable.edit_text_cursor)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, e.stackTraceToString())
|
||||
}
|
||||
}
|
||||
editText.doOnTextChanged { text, _, _, _ ->
|
||||
if (!composeState.value.inProgress) {
|
||||
onMessageChange(text.toString())
|
||||
} else if (text.toString() != composeState.value.message) {
|
||||
editText.setText(composeState.value.message)
|
||||
}
|
||||
}
|
||||
editText.doAfterTextChanged { text -> if (composeState.value.preview is ComposePreview.VoicePreview && text.toString() != "") editText.setText("") }
|
||||
editText
|
||||
}) {
|
||||
it.setTextColor(textColor.toArgb())
|
||||
it.textSize = textStyle.value.fontSize.value
|
||||
DrawableCompat.setTint(it.background, tintColor.toArgb())
|
||||
it.isFocusable = composeState.value.preview !is ComposePreview.VoicePreview
|
||||
it.isFocusableInTouchMode = it.isFocusable
|
||||
if (cs.message != it.text.toString()) {
|
||||
it.setText(cs.message)
|
||||
// Set cursor to the end of the text
|
||||
it.setSelection(it.text.length)
|
||||
}
|
||||
if (showKeyboard) {
|
||||
it.requestFocus()
|
||||
val imm: InputMethodManager = androidAppContext.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
|
||||
imm.showSoftInput(it, InputMethodManager.SHOW_IMPLICIT)
|
||||
showKeyboard = false
|
||||
}
|
||||
showDeleteTextButton.value = it.lineCount >= 4 && !cs.inProgress
|
||||
}
|
||||
if (composeState.value.preview is ComposePreview.VoicePreview) {
|
||||
ComposeOverlay(MR.strings.voice_message_send_text, textStyle, padding)
|
||||
} else if (userIsObserver) {
|
||||
ComposeOverlay(MR.strings.you_are_observer, textStyle, padding)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ComposeOverlay(textId: StringResource, textStyle: MutableState<TextStyle>, padding: PaddingValues) {
|
||||
Text(
|
||||
generalGetString(textId),
|
||||
Modifier.padding(padding),
|
||||
color = MaterialTheme.colors.secondary,
|
||||
style = textStyle.value.copy(fontStyle = FontStyle.Italic)
|
||||
)
|
||||
}
|
||||
+24
-35
@@ -1,4 +1,4 @@
|
||||
package chat.simplex.app.views.helpers
|
||||
package chat.simplex.common.platform
|
||||
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
@@ -7,34 +7,22 @@ import android.media.AudioManager.AudioPlaybackCallback
|
||||
import android.media.MediaRecorder.MEDIA_RECORDER_INFO_MAX_DURATION_REACHED
|
||||
import android.media.MediaRecorder.MEDIA_RECORDER_INFO_MAX_FILESIZE_REACHED
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import androidx.compose.runtime.*
|
||||
import chat.simplex.app.*
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.ChatItem
|
||||
import chat.simplex.app.views.helpers.AudioPlayer.duration
|
||||
import chat.simplex.res.MR
|
||||
import chat.simplex.common.model.ChatItem
|
||||
import chat.simplex.common.platform.AudioPlayer.duration
|
||||
import chat.simplex.common.views.helpers.*
|
||||
import kotlinx.coroutines.*
|
||||
import java.io.*
|
||||
|
||||
interface Recorder {
|
||||
fun start(onProgressUpdate: (position: Int?, finished: Boolean) -> Unit): String
|
||||
fun stop(): Int
|
||||
}
|
||||
|
||||
class RecorderNative(): Recorder {
|
||||
companion object {
|
||||
// Allows to stop the recorder from outside without having the recorder in a variable
|
||||
var stopRecording: (() -> Unit)? = null
|
||||
const val extension = "m4a"
|
||||
}
|
||||
actual class RecorderNative: RecorderInterface {
|
||||
private var recorder: MediaRecorder? = null
|
||||
private var progressJob: Job? = null
|
||||
private var filePath: String? = null
|
||||
private var recStartedAt: Long? = null
|
||||
private fun initRecorder() =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
MediaRecorder(SimplexApp.context)
|
||||
MediaRecorder(androidAppContext)
|
||||
} else {
|
||||
MediaRecorder()
|
||||
}
|
||||
@@ -51,8 +39,7 @@ class RecorderNative(): Recorder {
|
||||
rec.setAudioSamplingRate(16000)
|
||||
rec.setAudioEncodingBitRate(32000)
|
||||
rec.setMaxDuration(MAX_VOICE_MILLIS_FOR_SENDING)
|
||||
val tmpDir = SimplexApp.context.getDir("temp", Application.MODE_PRIVATE)
|
||||
val fileToSave = File.createTempFile(generateNewFileName("voice", "${extension}_"), ".tmp", tmpDir)
|
||||
val fileToSave = File.createTempFile(generateNewFileName("voice", "${RecorderInterface.extension}_"), ".tmp", tmpDir)
|
||||
fileToSave.deleteOnExit()
|
||||
val path = fileToSave.absolutePath
|
||||
filePath = path
|
||||
@@ -75,13 +62,13 @@ class RecorderNative(): Recorder {
|
||||
stop()
|
||||
}
|
||||
}
|
||||
stopRecording = { stop() }
|
||||
RecorderInterface.stopRecording = { stop() }
|
||||
return path
|
||||
}
|
||||
|
||||
override fun stop(): Int {
|
||||
val path = filePath ?: return 0
|
||||
stopRecording = null
|
||||
RecorderInterface.stopRecording = null
|
||||
runCatching {
|
||||
recorder?.stop()
|
||||
}
|
||||
@@ -110,7 +97,7 @@ class RecorderNative(): Recorder {
|
||||
private fun realDuration(path: String): Int? = duration(path) ?: progress()
|
||||
}
|
||||
|
||||
object AudioPlayer {
|
||||
actual object AudioPlayer: AudioPlayerInterface {
|
||||
private val player = MediaPlayer().apply {
|
||||
setAudioAttributes(
|
||||
AudioAttributes.Builder()
|
||||
@@ -118,13 +105,13 @@ object AudioPlayer {
|
||||
.setUsage(AudioAttributes.USAGE_MEDIA)
|
||||
.build()
|
||||
)
|
||||
(SimplexApp.context.getSystemService(Context.AUDIO_SERVICE) as AudioManager)
|
||||
(androidAppContext.getSystemService(Context.AUDIO_SERVICE) as AudioManager)
|
||||
.registerAudioPlaybackCallback(object: AudioPlaybackCallback() {
|
||||
override fun onPlaybackConfigChanged(configs: MutableList<AudioPlaybackConfiguration>?) {
|
||||
if (configs?.any { it.audioAttributes.usage == AudioAttributes.USAGE_VOICE_COMMUNICATION } == true) {
|
||||
// In a process of making a call
|
||||
RecorderNative.stopRecording?.invoke()
|
||||
stop()
|
||||
RecorderInterface.stopRecording?.invoke()
|
||||
AudioPlayer.stop()
|
||||
}
|
||||
super.onPlaybackConfigChanged(configs)
|
||||
}
|
||||
@@ -154,7 +141,7 @@ object AudioPlayer {
|
||||
}
|
||||
|
||||
VideoPlayer.stopAll()
|
||||
RecorderNative.stopRecording?.invoke()
|
||||
RecorderInterface.stopRecording?.invoke()
|
||||
val current = currentlyPlaying.value
|
||||
if (current == null || current.first != filePath) {
|
||||
stopListener()
|
||||
@@ -208,16 +195,16 @@ object AudioPlayer {
|
||||
return player.currentPosition
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
override fun stop() {
|
||||
if (currentlyPlaying.value == null) return
|
||||
player.stop()
|
||||
stopListener()
|
||||
}
|
||||
|
||||
fun stop(item: ChatItem) = stop(item.file?.fileName)
|
||||
override fun stop(item: ChatItem) = stop(item.file?.fileName)
|
||||
|
||||
// FileName or filePath are ok
|
||||
fun stop(fileName: String?) {
|
||||
override fun stop(fileName: String?) {
|
||||
if (fileName != null && currentlyPlaying.value?.first?.endsWith(fileName) == true) {
|
||||
stop()
|
||||
}
|
||||
@@ -241,7 +228,7 @@ object AudioPlayer {
|
||||
progressJob = null
|
||||
}
|
||||
|
||||
fun play(
|
||||
override fun play(
|
||||
filePath: String?,
|
||||
audioPlaying: MutableState<Boolean>,
|
||||
progress: MutableState<Int>,
|
||||
@@ -269,19 +256,19 @@ object AudioPlayer {
|
||||
realDuration?.let { duration.value = it }
|
||||
}
|
||||
|
||||
fun pause(audioPlaying: MutableState<Boolean>, pro: MutableState<Int>) {
|
||||
override fun pause(audioPlaying: MutableState<Boolean>, pro: MutableState<Int>) {
|
||||
pro.value = pause()
|
||||
audioPlaying.value = false
|
||||
}
|
||||
|
||||
fun seekTo(ms: Int, pro: MutableState<Int>, filePath: String?) {
|
||||
override fun seekTo(ms: Int, pro: MutableState<Int>, filePath: String?) {
|
||||
pro.value = ms
|
||||
if (this.currentlyPlaying.value?.first == filePath) {
|
||||
if (currentlyPlaying.value?.first == filePath) {
|
||||
player.seekTo(ms)
|
||||
}
|
||||
}
|
||||
|
||||
fun duration(filePath: String): Int? {
|
||||
override fun duration(filePath: String): Int? {
|
||||
var res: Int? = null
|
||||
kotlin.runCatching {
|
||||
helperPlayer.setDataSource(filePath)
|
||||
@@ -294,3 +281,5 @@ object AudioPlayer {
|
||||
return res
|
||||
}
|
||||
}
|
||||
|
||||
actual typealias SoundPlayer = chat.simplex.common.helpers.SoundPlayer
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
package chat.simplex.common.platform
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.UiModeManager
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import android.content.res.Configuration
|
||||
import android.text.BidiFormatter
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.Font
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import chat.simplex.common.model.AppPreferences
|
||||
import com.russhwolf.settings.Settings
|
||||
import com.russhwolf.settings.SharedPreferencesSettings
|
||||
import dev.icerock.moko.resources.StringResource
|
||||
import dev.icerock.moko.resources.desc.desc
|
||||
|
||||
@SuppressLint("DiscouragedApi")
|
||||
@Composable
|
||||
actual fun font(name: String, res: String, weight: FontWeight, style: FontStyle): Font {
|
||||
val context = LocalContext.current
|
||||
val id = context.resources.getIdentifier(res, "font", context.packageName)
|
||||
return Font(id, weight, style)
|
||||
}
|
||||
|
||||
actual fun StringResource.localized(): String = desc().toString(context = androidAppContext)
|
||||
|
||||
actual fun isInNightMode() =
|
||||
(androidAppContext.getSystemService(Context.UI_MODE_SERVICE) as UiModeManager).nightMode == UiModeManager.MODE_NIGHT_YES
|
||||
|
||||
private val sharedPreferences: SharedPreferences by lazy { androidAppContext.getSharedPreferences(AppPreferences.SHARED_PREFS_ID, Context.MODE_PRIVATE) }
|
||||
private val sharedPreferencesThemes: SharedPreferences by lazy { androidAppContext.getSharedPreferences(AppPreferences.SHARED_PREFS_THEMES_ID, Context.MODE_PRIVATE) }
|
||||
|
||||
actual val settings: Settings by lazy { SharedPreferencesSettings(sharedPreferences) }
|
||||
actual val settingsThemes: Settings by lazy { SharedPreferencesSettings(sharedPreferencesThemes) }
|
||||
|
||||
actual fun screenOrientation(): ScreenOrientation = when (mainActivity.get()?.resources?.configuration?.orientation) {
|
||||
Configuration.ORIENTATION_PORTRAIT -> ScreenOrientation.PORTRAIT
|
||||
Configuration.ORIENTATION_LANDSCAPE -> ScreenOrientation.LANDSCAPE
|
||||
else -> ScreenOrientation.UNDEFINED
|
||||
}
|
||||
|
||||
@Composable
|
||||
actual fun screenWidth(): Dp = LocalConfiguration.current.screenWidthDp.dp
|
||||
|
||||
actual fun desktopExpandWindowToWidth(width: Dp) {}
|
||||
|
||||
actual fun isRtl(text: CharSequence): Boolean = BidiFormatter.getInstance().isRtl(text)
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
package chat.simplex.common.platform
|
||||
|
||||
import android.Manifest
|
||||
import android.content.*
|
||||
import android.content.Intent.FLAG_ACTIVITY_NEW_TASK
|
||||
import android.net.Uri
|
||||
import android.provider.MediaStore
|
||||
import android.webkit.MimeTypeMap
|
||||
import androidx.compose.ui.platform.ClipboardManager
|
||||
import androidx.compose.ui.platform.UriHandler
|
||||
import chat.simplex.common.helpers.toUri
|
||||
import chat.simplex.common.model.CIFile
|
||||
import chat.simplex.common.views.helpers.generalGetString
|
||||
import chat.simplex.common.views.helpers.getAppFileUri
|
||||
import java.io.BufferedOutputStream
|
||||
import java.io.File
|
||||
import chat.simplex.res.MR
|
||||
|
||||
actual fun ClipboardManager.shareText(text: String) {
|
||||
val sendIntent: Intent = Intent().apply {
|
||||
action = Intent.ACTION_SEND
|
||||
putExtra(Intent.EXTRA_TEXT, text)
|
||||
type = "text/plain"
|
||||
flags = FLAG_ACTIVITY_NEW_TASK
|
||||
}
|
||||
val shareIntent = Intent.createChooser(sendIntent, null)
|
||||
shareIntent.addFlags(FLAG_ACTIVITY_NEW_TASK)
|
||||
androidAppContext.startActivity(shareIntent)
|
||||
}
|
||||
|
||||
actual fun shareFile(text: String, filePath: String) {
|
||||
val uri = getAppFileUri(filePath.substringAfterLast(File.separator))
|
||||
val ext = filePath.substringAfterLast(".")
|
||||
val mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(ext) ?: return
|
||||
val sendIntent: Intent = Intent().apply {
|
||||
action = Intent.ACTION_SEND
|
||||
/*if (text.isNotEmpty()) {
|
||||
putExtra(Intent.EXTRA_TEXT, text)
|
||||
}*/
|
||||
putExtra(Intent.EXTRA_STREAM, uri.toUri())
|
||||
type = mimeType
|
||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK
|
||||
}
|
||||
val shareIntent = Intent.createChooser(sendIntent, null)
|
||||
shareIntent.addFlags(FLAG_ACTIVITY_NEW_TASK)
|
||||
androidAppContext.startActivity(shareIntent)
|
||||
}
|
||||
|
||||
actual fun UriHandler.sendEmail(subject: String, body: CharSequence) {
|
||||
val emailIntent = Intent(Intent.ACTION_SENDTO, Uri.parse("mailto:"))
|
||||
emailIntent.putExtra(Intent.EXTRA_SUBJECT, subject)
|
||||
emailIntent.putExtra(Intent.EXTRA_TEXT, body)
|
||||
emailIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
try {
|
||||
androidAppContext.startActivity(emailIntent)
|
||||
} catch (e: ActivityNotFoundException) {
|
||||
Log.e(TAG, "No activity was found for handling email intent")
|
||||
}
|
||||
}
|
||||
|
||||
fun imageMimeType(fileName: String): String {
|
||||
val lowercaseName = fileName.lowercase()
|
||||
return when {
|
||||
lowercaseName.endsWith(".png") -> "image/png"
|
||||
lowercaseName.endsWith(".gif") -> "image/gif"
|
||||
lowercaseName.endsWith(".webp") -> "image/webp"
|
||||
lowercaseName.endsWith(".avif") -> "image/avif"
|
||||
lowercaseName.endsWith(".svg") -> "image/svg+xml"
|
||||
else -> "image/jpeg"
|
||||
}
|
||||
}
|
||||
|
||||
/** Before calling, make sure the user allows to write to external storage [Manifest.permission.WRITE_EXTERNAL_STORAGE] */
|
||||
fun saveImage(ciFile: CIFile?) {
|
||||
val filePath = getLoadedFilePath(ciFile)
|
||||
val fileName = ciFile?.fileName
|
||||
if (filePath != null && fileName != null) {
|
||||
val values = ContentValues()
|
||||
values.put(MediaStore.Images.Media.DATE_TAKEN, System.currentTimeMillis())
|
||||
values.put(MediaStore.Images.Media.MIME_TYPE, imageMimeType(fileName))
|
||||
values.put(MediaStore.MediaColumns.DISPLAY_NAME, fileName)
|
||||
values.put(MediaStore.MediaColumns.TITLE, fileName)
|
||||
val uri = androidAppContext.contentResolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values)
|
||||
uri?.let {
|
||||
androidAppContext.contentResolver.openOutputStream(uri)?.let { stream ->
|
||||
val outputStream = BufferedOutputStream(stream)
|
||||
File(filePath).inputStream().use { it.copyTo(outputStream) }
|
||||
outputStream.close()
|
||||
showToast(generalGetString(MR.strings.image_saved))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
showToast(generalGetString(MR.strings.file_not_found))
|
||||
}
|
||||
}
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
package chat.simplex.common.platform
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.pm.ActivityInfo
|
||||
import android.graphics.Rect
|
||||
import android.os.Build
|
||||
import android.view.*
|
||||
import android.view.inputmethod.InputMethodManager
|
||||
import android.widget.Toast
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import chat.simplex.common.views.helpers.KeyboardState
|
||||
import androidx.compose.ui.platform.LocalContext as LocalContext1
|
||||
|
||||
actual fun showToast(text: String, timeout: Long) = Toast.makeText(androidAppContext, text, Toast.LENGTH_SHORT).show()
|
||||
|
||||
@Composable
|
||||
actual fun LockToCurrentOrientationUntilDispose() {
|
||||
val context = LocalContext1.current
|
||||
DisposableEffect(Unit) {
|
||||
val activity = (context as Activity?) ?: return@DisposableEffect onDispose {}
|
||||
val manager = context.getSystemService(Activity.WINDOW_SERVICE) as WindowManager
|
||||
val rotation = if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.Q) manager.defaultDisplay.rotation else activity.display?.rotation
|
||||
activity.requestedOrientation = when (rotation) {
|
||||
Surface.ROTATION_90 -> ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
|
||||
Surface.ROTATION_180 -> ActivityInfo.SCREEN_ORIENTATION_REVERSE_PORTRAIT
|
||||
Surface.ROTATION_270 -> ActivityInfo.SCREEN_ORIENTATION_REVERSE_LANDSCAPE
|
||||
else -> ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
|
||||
}
|
||||
// Unlock orientation
|
||||
onDispose { activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
actual fun LocalMultiplatformView(): Any? = LocalView.current
|
||||
|
||||
@Composable
|
||||
actual fun getKeyboardState(): State<KeyboardState> {
|
||||
val keyboardState = remember { mutableStateOf(KeyboardState.Closed) }
|
||||
val view = LocalView.current
|
||||
DisposableEffect(view) {
|
||||
val onGlobalListener = ViewTreeObserver.OnGlobalLayoutListener {
|
||||
val rect = Rect()
|
||||
view.getWindowVisibleDisplayFrame(rect)
|
||||
val screenHeight = view.rootView.height
|
||||
val keypadHeight = screenHeight - rect.bottom
|
||||
keyboardState.value = if (keypadHeight > screenHeight * 0.15) {
|
||||
KeyboardState.Opened
|
||||
} else {
|
||||
KeyboardState.Closed
|
||||
}
|
||||
}
|
||||
view.viewTreeObserver.addOnGlobalLayoutListener(onGlobalListener)
|
||||
|
||||
onDispose {
|
||||
view.viewTreeObserver.removeOnGlobalLayoutListener(onGlobalListener)
|
||||
}
|
||||
}
|
||||
|
||||
return keyboardState
|
||||
}
|
||||
|
||||
actual fun hideKeyboard(view: Any?) {
|
||||
// LALAL
|
||||
// LocalSoftwareKeyboardController.current?.hide()
|
||||
if (view is View) {
|
||||
(androidAppContext.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager).hideSoftInputFromWindow(view.windowToken, 0)
|
||||
}
|
||||
}
|
||||
+51
-55
@@ -1,45 +1,43 @@
|
||||
package chat.simplex.app.views.helpers
|
||||
package chat.simplex.common.platform
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.media.session.PlaybackState
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import chat.simplex.app.*
|
||||
import chat.simplex.app.R
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import chat.simplex.common.views.helpers.*
|
||||
import chat.simplex.res.MR
|
||||
import com.google.android.exoplayer2.*
|
||||
import com.google.android.exoplayer2.C.*
|
||||
import com.google.android.exoplayer2.audio.AudioAttributes
|
||||
import com.google.android.exoplayer2.source.ProgressiveMediaSource
|
||||
import com.google.android.exoplayer2.upstream.DefaultDataSource
|
||||
import com.google.android.exoplayer2.upstream.DefaultHttpDataSource
|
||||
import chat.simplex.res.MR
|
||||
import kotlinx.coroutines.*
|
||||
import java.io.File
|
||||
import java.net.URI
|
||||
|
||||
class VideoPlayer private constructor(
|
||||
private val uri: Uri,
|
||||
actual class VideoPlayer private constructor(
|
||||
private val uri: URI,
|
||||
private val gallery: Boolean,
|
||||
private val defaultPreview: Bitmap,
|
||||
private val defaultPreview: ImageBitmap,
|
||||
defaultDuration: Long,
|
||||
soundEnabled: Boolean,
|
||||
) {
|
||||
companion object {
|
||||
private val players: MutableMap<Pair<Uri, Boolean>, VideoPlayer> = mutableMapOf()
|
||||
private val previewsAndDurations: MutableMap<Uri, PreviewAndDuration> = mutableMapOf()
|
||||
soundEnabled: Boolean
|
||||
): VideoPlayerInterface {
|
||||
actual companion object {
|
||||
private val players: MutableMap<Pair<URI, Boolean>, VideoPlayer> = mutableMapOf()
|
||||
private val previewsAndDurations: MutableMap<URI, VideoPlayerInterface.PreviewAndDuration> = mutableMapOf()
|
||||
|
||||
fun getOrCreate(
|
||||
uri: Uri,
|
||||
actual fun getOrCreate(
|
||||
uri: URI,
|
||||
gallery: Boolean,
|
||||
defaultPreview: Bitmap,
|
||||
defaultPreview: ImageBitmap,
|
||||
defaultDuration: Long,
|
||||
soundEnabled: Boolean,
|
||||
soundEnabled: Boolean
|
||||
): VideoPlayer =
|
||||
players.getOrPut(uri to gallery) { VideoPlayer(uri, gallery, defaultPreview, defaultDuration, soundEnabled) }
|
||||
|
||||
fun enableSound(enable: Boolean, fileName: String?, gallery: Boolean): Boolean =
|
||||
actual fun enableSound(enable: Boolean, fileName: String?, gallery: Boolean): Boolean =
|
||||
player(fileName, gallery)?.enableSound(enable) == true
|
||||
|
||||
private fun player(fileName: String?, gallery: Boolean): VideoPlayer? {
|
||||
@@ -47,36 +45,34 @@ class VideoPlayer private constructor(
|
||||
return players.values.firstOrNull { player -> player.uri.path?.endsWith(fileName) == true && player.gallery == gallery }
|
||||
}
|
||||
|
||||
fun release(uri: Uri, gallery: Boolean, remove: Boolean) =
|
||||
player(uri.path, gallery)?.release(remove)
|
||||
actual fun release(uri: URI, gallery: Boolean, remove: Boolean) =
|
||||
player(uri.path, gallery)?.release(remove).run { }
|
||||
|
||||
fun stopAll() {
|
||||
actual fun stopAll() {
|
||||
players.values.forEach { it.stop() }
|
||||
}
|
||||
|
||||
fun releaseAll() {
|
||||
actual fun releaseAll() {
|
||||
players.values.forEach { it.release(false) }
|
||||
players.clear()
|
||||
previewsAndDurations.clear()
|
||||
}
|
||||
}
|
||||
|
||||
data class PreviewAndDuration(val preview: Bitmap?, val duration: Long?, val timestamp: Long)
|
||||
|
||||
private val currentVolume: Float
|
||||
val soundEnabled: MutableState<Boolean> = mutableStateOf(soundEnabled)
|
||||
val brokenVideo: MutableState<Boolean> = mutableStateOf(false)
|
||||
val videoPlaying: MutableState<Boolean> = mutableStateOf(false)
|
||||
val progress: MutableState<Long> = mutableStateOf(0L)
|
||||
val duration: MutableState<Long> = mutableStateOf(defaultDuration)
|
||||
val preview: MutableState<Bitmap> = mutableStateOf(defaultPreview)
|
||||
override val soundEnabled: MutableState<Boolean> = mutableStateOf(soundEnabled)
|
||||
override val brokenVideo: MutableState<Boolean> = mutableStateOf(false)
|
||||
override val videoPlaying: MutableState<Boolean> = mutableStateOf(false)
|
||||
override val progress: MutableState<Long> = mutableStateOf(0L)
|
||||
override val duration: MutableState<Long> = mutableStateOf(defaultDuration)
|
||||
override val preview: MutableState<ImageBitmap> = mutableStateOf(defaultPreview)
|
||||
|
||||
init {
|
||||
setPreviewAndDuration()
|
||||
}
|
||||
|
||||
val player = ExoPlayer.Builder(SimplexApp.context,
|
||||
DefaultRenderersFactory(SimplexApp.context))
|
||||
val player = ExoPlayer.Builder(androidAppContext,
|
||||
DefaultRenderersFactory(androidAppContext))
|
||||
/*.setLoadControl(DefaultLoadControl.Builder()
|
||||
.setPrioritizeTimeOverSizeThresholds(false) // Could probably save some megabytes in memory in case it will be needed
|
||||
.createDefaultLoadControl())*/
|
||||
@@ -84,20 +80,20 @@ class VideoPlayer private constructor(
|
||||
.setSeekForwardIncrementMs(10_000)
|
||||
.build()
|
||||
.apply {
|
||||
// Repeat the same track endlessly
|
||||
repeatMode = Player.REPEAT_MODE_ONE
|
||||
currentVolume = volume
|
||||
if (!soundEnabled) {
|
||||
volume = 0f
|
||||
// Repeat the same track endlessly
|
||||
repeatMode = Player.REPEAT_MODE_ONE
|
||||
currentVolume = volume
|
||||
if (!soundEnabled) {
|
||||
volume = 0f
|
||||
}
|
||||
setAudioAttributes(
|
||||
AudioAttributes.Builder()
|
||||
.setContentType(CONTENT_TYPE_MUSIC)
|
||||
.setUsage(USAGE_MEDIA)
|
||||
.build(),
|
||||
true // disallow to play multiple instances simultaneously
|
||||
)
|
||||
}
|
||||
setAudioAttributes(
|
||||
AudioAttributes.Builder()
|
||||
.setContentType(CONTENT_TYPE_MUSIC)
|
||||
.setUsage(USAGE_MEDIA)
|
||||
.build(),
|
||||
true // disallow to play multiple instances simultaneously
|
||||
)
|
||||
}
|
||||
|
||||
private val listener: MutableState<((position: Long?, state: TrackState) -> Unit)?> = mutableStateOf(null)
|
||||
private var progressJob: Job? = null
|
||||
@@ -115,14 +111,14 @@ class VideoPlayer private constructor(
|
||||
}
|
||||
|
||||
if (soundEnabled.value) {
|
||||
RecorderNative.stopRecording?.invoke()
|
||||
RecorderInterface.stopRecording?.invoke()
|
||||
}
|
||||
AudioPlayer.stop()
|
||||
stopAll()
|
||||
if (listener.value == null) {
|
||||
runCatching {
|
||||
val dataSourceFactory = DefaultDataSource.Factory(SimplexApp.context, DefaultHttpDataSource.Factory())
|
||||
val source = ProgressiveMediaSource.Factory(dataSourceFactory).createMediaSource(MediaItem.fromUri(uri))
|
||||
val dataSourceFactory = DefaultDataSource.Factory(androidAppContext, DefaultHttpDataSource.Factory())
|
||||
val source = ProgressiveMediaSource.Factory(dataSourceFactory).createMediaSource(MediaItem.fromUri(Uri.parse(uri.toString())))
|
||||
player.setMediaSource(source, seek ?: 0L)
|
||||
}.onFailure {
|
||||
Log.e(TAG, it.stackTraceToString())
|
||||
@@ -170,14 +166,14 @@ class VideoPlayer private constructor(
|
||||
override fun onIsPlayingChanged(isPlaying: Boolean) {
|
||||
super.onIsPlayingChanged(isPlaying)
|
||||
// Produce non-ideal transition from stopped to playing state while showing preview image in ChatView
|
||||
// videoPlaying.value = isPlaying
|
||||
// videoPlaying.value = isPlaying
|
||||
}
|
||||
})
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
override fun stop() {
|
||||
player.stop()
|
||||
stopListener()
|
||||
}
|
||||
@@ -199,7 +195,7 @@ class VideoPlayer private constructor(
|
||||
progressJob = null
|
||||
}
|
||||
|
||||
fun play(resetOnEnd: Boolean) {
|
||||
override fun play(resetOnEnd: Boolean) {
|
||||
if (progress.value == duration.value) {
|
||||
progress.value = 0
|
||||
}
|
||||
@@ -218,14 +214,14 @@ class VideoPlayer private constructor(
|
||||
}
|
||||
}
|
||||
|
||||
fun enableSound(enable: Boolean): Boolean {
|
||||
override fun enableSound(enable: Boolean): Boolean {
|
||||
if (soundEnabled.value == enable) return false
|
||||
soundEnabled.value = enable
|
||||
player.volume = if (enable) currentVolume else 0f
|
||||
return true
|
||||
}
|
||||
|
||||
fun release(remove: Boolean) {
|
||||
override fun release(remove: Boolean) {
|
||||
player.release()
|
||||
if (remove) {
|
||||
players.remove(uri to gallery)
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
package chat.simplex.common.ui.theme
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
|
||||
@Composable
|
||||
actual fun isSystemInDarkTheme(): Boolean = androidx.compose.foundation.isSystemInDarkTheme()
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
package chat.simplex.common.ui.theme
|
||||
|
||||
import androidx.compose.ui.text.font.*
|
||||
import chat.simplex.res.MR
|
||||
|
||||
actual val Inter: FontFamily = FontFamily(
|
||||
Font(MR.fonts.Inter.regular.fontResourceId),
|
||||
Font(MR.fonts.Inter.italic.fontResourceId, style = FontStyle.Italic),
|
||||
Font(MR.fonts.Inter.bold.fontResourceId, FontWeight.Bold),
|
||||
Font(MR.fonts.Inter.semibold.fontResourceId, FontWeight.SemiBold),
|
||||
Font(MR.fonts.Inter.medium.fontResourceId, FontWeight.Medium),
|
||||
Font(MR.fonts.Inter.light.fontResourceId, FontWeight.Light)
|
||||
)
|
||||
|
||||
actual val EmojiFont: FontFamily = FontFamily.Default
|
||||
+21
-22
@@ -1,4 +1,4 @@
|
||||
package chat.simplex.app.views.call
|
||||
package chat.simplex.common.views.call
|
||||
|
||||
import android.Manifest
|
||||
import android.annotation.SuppressLint
|
||||
@@ -9,10 +9,9 @@ import android.media.*
|
||||
import android.os.Build
|
||||
import android.os.PowerManager
|
||||
import android.os.PowerManager.PROXIMITY_SCREEN_OFF_WAKE_LOCK
|
||||
import android.util.Log
|
||||
import android.view.ViewGroup
|
||||
import android.webkit.*
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.desktop.ui.tooling.preview.Preview
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.runtime.*
|
||||
@@ -26,22 +25,21 @@ import androidx.compose.ui.platform.LocalLifecycleOwner
|
||||
import dev.icerock.moko.resources.compose.painterResource
|
||||
import dev.icerock.moko.resources.compose.stringResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.webkit.WebViewAssetLoader
|
||||
import androidx.webkit.WebViewClientCompat
|
||||
import chat.simplex.app.*
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.*
|
||||
import chat.simplex.app.ui.theme.*
|
||||
import chat.simplex.app.views.helpers.ProfileImage
|
||||
import chat.simplex.app.views.helpers.withApi
|
||||
import chat.simplex.app.views.usersettings.NotificationsMode
|
||||
import com.google.accompanist.permissions.rememberMultiplePermissionsState
|
||||
import chat.simplex.common.model.*
|
||||
import chat.simplex.common.ui.theme.*
|
||||
import chat.simplex.common.views.helpers.ProfileImage
|
||||
import chat.simplex.common.views.helpers.withApi
|
||||
import chat.simplex.common.model.ChatModel
|
||||
import chat.simplex.common.model.Contact
|
||||
import chat.simplex.common.platform.*
|
||||
import chat.simplex.res.MR
|
||||
import com.google.accompanist.permissions.rememberMultiplePermissionsState
|
||||
import dev.icerock.moko.resources.StringResource
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.serialization.decodeFromString
|
||||
@@ -49,20 +47,21 @@ import kotlinx.serialization.encodeToString
|
||||
|
||||
@SuppressLint("SourceLockedOrientationActivity")
|
||||
@Composable
|
||||
fun ActiveCallView(chatModel: ChatModel) {
|
||||
actual fun ActiveCallView() {
|
||||
val chatModel = ChatModel
|
||||
BackHandler(onBack = {
|
||||
val call = chatModel.activeCall.value
|
||||
if (call != null) withApi { chatModel.callManager.endCall(call) }
|
||||
})
|
||||
val audioViaBluetooth = rememberSaveable { mutableStateOf(false) }
|
||||
val ntfModeService = remember { chatModel.controller.appPrefs.notificationsMode.get() == NotificationsMode.SERVICE.name }
|
||||
val ntfModeService = remember { chatModel.controller.appPrefs.notificationsMode.get() == NotificationsMode.SERVICE }
|
||||
LaunchedEffect(Unit) {
|
||||
// Start service when call happening since it's not already started.
|
||||
// It's needed to prevent Android from shutting down a microphone after a minute or so when screen is off
|
||||
if (!ntfModeService) SimplexService.start()
|
||||
if (!ntfModeService) platform.androidServiceStart()
|
||||
}
|
||||
DisposableEffect(Unit) {
|
||||
val am = SimplexApp.context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
||||
val am = androidAppContext.getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
||||
var btDeviceCount = 0
|
||||
val audioCallback = object: AudioDeviceCallback() {
|
||||
override fun onAudioDevicesAdded(addedDevices: Array<out AudioDeviceInfo>) {
|
||||
@@ -89,16 +88,16 @@ fun ActiveCallView(chatModel: ChatModel) {
|
||||
}
|
||||
}
|
||||
am.registerAudioDeviceCallback(audioCallback, null)
|
||||
val pm = (SimplexApp.context.getSystemService(Context.POWER_SERVICE) as PowerManager)
|
||||
val pm = (androidAppContext.getSystemService(Context.POWER_SERVICE) as PowerManager)
|
||||
val proximityLock = if (pm.isWakeLockLevelSupported(PROXIMITY_SCREEN_OFF_WAKE_LOCK)) {
|
||||
pm.newWakeLock(PROXIMITY_SCREEN_OFF_WAKE_LOCK, SimplexApp.context.packageName + ":proximityLock")
|
||||
pm.newWakeLock(PROXIMITY_SCREEN_OFF_WAKE_LOCK, androidAppContext.packageName + ":proximityLock")
|
||||
} else {
|
||||
null
|
||||
}
|
||||
proximityLock?.acquire()
|
||||
onDispose {
|
||||
// Stop it when call ended
|
||||
if (!ntfModeService) SimplexService.safeStopService(SimplexApp.context)
|
||||
if (!ntfModeService) platform.androidServiceSafeStop()
|
||||
dropAudioManagerOverrides()
|
||||
am.unregisterAudioDeviceCallback(audioCallback)
|
||||
proximityLock?.release()
|
||||
@@ -217,7 +216,7 @@ private fun ActiveCallOverlay(call: Call, chatModel: ChatModel, audioViaBluetoot
|
||||
}
|
||||
|
||||
private fun setCallSound(speaker: Boolean, audioViaBluetooth: MutableState<Boolean>) {
|
||||
val am = SimplexApp.context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
||||
val am = androidAppContext.getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
||||
Log.d(TAG, "setCallSound: set audio mode, speaker enabled: $speaker")
|
||||
am.mode = AudioManager.MODE_IN_COMMUNICATION
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
@@ -243,7 +242,7 @@ private fun setCallSound(speaker: Boolean, audioViaBluetooth: MutableState<Boole
|
||||
}
|
||||
|
||||
private fun dropAudioManagerOverrides() {
|
||||
val am = SimplexApp.context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
||||
val am = androidAppContext.getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
||||
am.mode = AudioManager.MODE_NORMAL
|
||||
// Clear selected communication device to default value after we changed it in call
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
@@ -353,7 +352,7 @@ fun CallInfoView(call: Call, alignment: Alignment.Horizontal) {
|
||||
InfoText(call.callState.text)
|
||||
|
||||
val connInfo = call.connectionInfo
|
||||
// val connInfoText = if (connInfo == null) "" else " (${connInfo.text}, ${connInfo.protocolText})"
|
||||
// val connInfoText = if (connInfo == null) "" else " (${connInfo.text}, ${connInfo.protocolText})"
|
||||
val connInfoText = if (connInfo == null) "" else " (${connInfo.text})"
|
||||
InfoText(call.encryptionStatus + connInfoText)
|
||||
}
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
package chat.simplex.common.views.chat
|
||||
|
||||
import android.Manifest
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.Uri
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.core.content.ContextCompat
|
||||
import chat.simplex.common.helpers.toURI
|
||||
import chat.simplex.common.platform.*
|
||||
import chat.simplex.common.views.helpers.*
|
||||
import chat.simplex.res.MR
|
||||
import java.net.URI
|
||||
|
||||
@Composable
|
||||
actual fun AttachmentSelection(
|
||||
composeState: MutableState<ComposeState>,
|
||||
attachmentOption: MutableState<AttachmentOption?>,
|
||||
processPickedFile: (URI?, String?) -> Unit,
|
||||
processPickedMedia: (List<URI>, String?) -> Unit
|
||||
) {
|
||||
val cameraLauncher = rememberCameraLauncher { uri: Uri? ->
|
||||
if (uri != null) {
|
||||
val bitmap: ImageBitmap? = getBitmapFromUri(uri.toURI())
|
||||
if (bitmap != null) {
|
||||
val imagePreview = resizeImageToStrSize(bitmap, maxDataSize = 14000)
|
||||
composeState.value = composeState.value.copy(preview = ComposePreview.MediaPreview(listOf(imagePreview), listOf(UploadContent.SimpleImage(uri.toURI()))))
|
||||
}
|
||||
}
|
||||
}
|
||||
val cameraPermissionLauncher = rememberPermissionLauncher { isGranted: Boolean ->
|
||||
if (isGranted) {
|
||||
cameraLauncher.launchWithFallback()
|
||||
} else {
|
||||
showToast(generalGetString(MR.strings.toast_permission_denied))
|
||||
}
|
||||
}
|
||||
val galleryImageLauncher = rememberLauncherForActivityResult(contract = PickMultipleImagesFromGallery()) { processPickedMedia(it.map { it.toURI() }, null) }
|
||||
val galleryImageLauncherFallback = rememberGetMultipleContentsLauncher { processPickedMedia(it.map { it.toURI() }, null) }
|
||||
val galleryVideoLauncher = rememberLauncherForActivityResult(contract = PickMultipleVideosFromGallery()) { processPickedMedia(it.map { it.toURI() }, null) }
|
||||
val galleryVideoLauncherFallback = rememberGetMultipleContentsLauncher { processPickedMedia(it.map { it.toURI() }, null) }
|
||||
val filesLauncher = rememberGetContentLauncher { processPickedFile(it?.toURI(), null) }
|
||||
LaunchedEffect(attachmentOption.value) {
|
||||
when (attachmentOption.value) {
|
||||
AttachmentOption.CameraPhoto -> {
|
||||
when (PackageManager.PERMISSION_GRANTED) {
|
||||
ContextCompat.checkSelfPermission(androidAppContext, Manifest.permission.CAMERA) -> {
|
||||
cameraLauncher.launchWithFallback()
|
||||
}
|
||||
else -> {
|
||||
cameraPermissionLauncher.launch(Manifest.permission.CAMERA)
|
||||
}
|
||||
}
|
||||
attachmentOption.value = null
|
||||
}
|
||||
AttachmentOption.GalleryImage -> {
|
||||
try {
|
||||
galleryImageLauncher.launch(0)
|
||||
} catch (e: ActivityNotFoundException) {
|
||||
galleryImageLauncherFallback.launch("image/*")
|
||||
}
|
||||
attachmentOption.value = null
|
||||
}
|
||||
AttachmentOption.GalleryVideo -> {
|
||||
try {
|
||||
galleryVideoLauncher.launch(0)
|
||||
} catch (e: ActivityNotFoundException) {
|
||||
galleryVideoLauncherFallback.launch("video/*")
|
||||
}
|
||||
attachmentOption.value = null
|
||||
}
|
||||
AttachmentOption.File -> {
|
||||
filesLauncher.launch("*/*")
|
||||
attachmentOption.value = null
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
package chat.simplex.common.views.chat
|
||||
|
||||
import android.Manifest
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import chat.simplex.common.views.chat.ScanCodeLayout
|
||||
import com.google.accompanist.permissions.rememberPermissionState
|
||||
|
||||
@Composable
|
||||
actual fun ScanCodeView(verifyCode: (String?, cb: (Boolean) -> Unit) -> Unit, close: () -> Unit) {
|
||||
val cameraPermissionState = rememberPermissionState(permission = Manifest.permission.CAMERA)
|
||||
LaunchedEffect(Unit) {
|
||||
cameraPermissionState.launchPermissionRequest()
|
||||
}
|
||||
ScanCodeLayout(verifyCode, close)
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
package chat.simplex.common.views.chat
|
||||
|
||||
import android.Manifest
|
||||
import androidx.compose.runtime.Composable
|
||||
import com.google.accompanist.permissions.rememberMultiplePermissionsState
|
||||
|
||||
@Composable
|
||||
actual fun allowedToRecordVoiceByPlatform(): Boolean {
|
||||
val permissionsState = rememberMultiplePermissionsState(listOf(Manifest.permission.RECORD_AUDIO))
|
||||
return permissionsState.allPermissionsGranted
|
||||
}
|
||||
|
||||
@Composable
|
||||
actual fun VoiceButtonWithoutPermissionByPlatform() {
|
||||
val permissionsState = rememberMultiplePermissionsState(listOf(Manifest.permission.RECORD_AUDIO))
|
||||
VoiceButtonWithoutPermission { permissionsState.launchMultiplePermissionRequest() }
|
||||
}
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
package chat.simplex.common.views.chat.item
|
||||
|
||||
import android.os.Build.VERSION.SDK_INT
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.painter.BitmapPainter
|
||||
import androidx.compose.ui.graphics.painter.Painter
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import chat.simplex.common.helpers.toUri
|
||||
import chat.simplex.common.model.CIFile
|
||||
import chat.simplex.common.platform.*
|
||||
import chat.simplex.common.views.helpers.ModalManager
|
||||
import coil.ImageLoader
|
||||
import coil.compose.rememberAsyncImagePainter
|
||||
import coil.decode.GifDecoder
|
||||
import coil.decode.ImageDecoderDecoder
|
||||
import coil.request.ImageRequest
|
||||
import java.net.URI
|
||||
|
||||
@Composable
|
||||
actual fun SimpleAndAnimatedImageView(
|
||||
uri: URI,
|
||||
imageBitmap: ImageBitmap,
|
||||
file: CIFile?,
|
||||
imageProvider: () -> ImageGalleryProvider,
|
||||
ImageView: @Composable (painter: Painter, onClick: () -> Unit) -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val imagePainter = rememberAsyncImagePainter(
|
||||
ImageRequest.Builder(context).data(data = uri.toUri()).size(coil.size.Size.ORIGINAL).build(),
|
||||
placeholder = BitmapPainter(imageBitmap), // show original image while it's still loading by coil
|
||||
imageLoader = imageLoader
|
||||
)
|
||||
val view = LocalMultiplatformView()
|
||||
ImageView(imagePainter) {
|
||||
hideKeyboard(view)
|
||||
if (getLoadedFilePath(file) != null) {
|
||||
ModalManager.fullscreen.showCustomModal(animated = false) { close ->
|
||||
ImageFullScreenView(imageProvider, close)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val imageLoader = ImageLoader.Builder(androidAppContext)
|
||||
.components {
|
||||
if (SDK_INT >= 28) {
|
||||
add(ImageDecoderDecoder.Factory())
|
||||
} else {
|
||||
add(GifDecoder.Factory())
|
||||
}
|
||||
}
|
||||
.build()
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
package chat.simplex.common.views.chat.item
|
||||
|
||||
import android.graphics.Rect
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import chat.simplex.common.platform.VideoPlayer
|
||||
import com.google.android.exoplayer2.ui.AspectRatioFrameLayout.RESIZE_MODE_FIXED_WIDTH
|
||||
import com.google.android.exoplayer2.ui.StyledPlayerView
|
||||
|
||||
@Composable
|
||||
actual fun PlayerView(player: VideoPlayer, width: Dp, onClick: () -> Unit, onLongClick: () -> Unit, stop: () -> Unit) {
|
||||
AndroidView(
|
||||
factory = { ctx ->
|
||||
StyledPlayerView(ctx).apply {
|
||||
useController = false
|
||||
resizeMode = RESIZE_MODE_FIXED_WIDTH
|
||||
this.player = player.player
|
||||
}
|
||||
},
|
||||
Modifier
|
||||
.width(width)
|
||||
.combinedClickable(
|
||||
onLongClick = onLongClick,
|
||||
onClick = { if (player.player.playWhenReady) stop() else onClick() }
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
actual fun LocalWindowWidth(): Dp {
|
||||
val view = LocalView.current
|
||||
val density = LocalDensity.current.density
|
||||
return remember {
|
||||
val rect = Rect()
|
||||
view.getWindowVisibleDisplayFrame(rect)
|
||||
(rect.width() / density).dp
|
||||
}
|
||||
}
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
package chat.simplex.common.views.chat.item
|
||||
|
||||
import android.Manifest
|
||||
import android.os.Build
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.ui.unit.TextUnit
|
||||
import androidx.compose.ui.unit.sp
|
||||
import chat.simplex.common.model.ChatItem
|
||||
import chat.simplex.common.model.MsgContent
|
||||
import chat.simplex.common.platform.FileChooserLauncher
|
||||
import chat.simplex.common.platform.saveImage
|
||||
import chat.simplex.common.views.helpers.SharedContent
|
||||
import chat.simplex.common.views.helpers.withApi
|
||||
import chat.simplex.res.MR
|
||||
import com.google.accompanist.permissions.rememberPermissionState
|
||||
import dev.icerock.moko.resources.compose.painterResource
|
||||
import dev.icerock.moko.resources.compose.stringResource
|
||||
|
||||
@Composable
|
||||
actual fun ReactionIcon(text: String, fontSize: TextUnit) {
|
||||
Text(text, fontSize = fontSize)
|
||||
}
|
||||
|
||||
@Composable
|
||||
actual fun SaveContentItemAction(cItem: ChatItem, saveFileLauncher: FileChooserLauncher, showMenu: MutableState<Boolean>) {
|
||||
val writePermissionState = rememberPermissionState(permission = Manifest.permission.WRITE_EXTERNAL_STORAGE)
|
||||
ItemAction(stringResource(MR.strings.save_verb), painterResource(MR.images.ic_download), onClick = {
|
||||
when (cItem.content.msgContent) {
|
||||
is MsgContent.MCImage -> {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R || writePermissionState.hasPermission) {
|
||||
saveImage(cItem.file)
|
||||
} else {
|
||||
writePermissionState.launchPermissionRequest()
|
||||
}
|
||||
}
|
||||
is MsgContent.MCFile, is MsgContent.MCVoice, is MsgContent.MCVideo -> withApi { saveFileLauncher.launch(cItem.file?.fileName ?: "") }
|
||||
else -> {}
|
||||
}
|
||||
showMenu.value = false
|
||||
})
|
||||
}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
package chat.simplex.common.views.chat.item
|
||||
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
|
||||
@Composable
|
||||
actual fun EmojiText(text: String) {
|
||||
val s = text.trim()
|
||||
Text(s, style = if (s.codePoints().count() < 4) largeEmojiFont else mediumEmojiFont)
|
||||
}
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
package chat.simplex.common.views.chat.item
|
||||
|
||||
import android.os.Build
|
||||
import android.view.View
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.*
|
||||
import androidx.compose.ui.graphics.painter.BitmapPainter
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.core.view.isVisible
|
||||
import chat.simplex.common.helpers.toUri
|
||||
import chat.simplex.common.platform.VideoPlayer
|
||||
import chat.simplex.res.MR
|
||||
import coil.ImageLoader
|
||||
import coil.compose.rememberAsyncImagePainter
|
||||
import coil.decode.GifDecoder
|
||||
import coil.decode.ImageDecoderDecoder
|
||||
import coil.request.ImageRequest
|
||||
import coil.size.Size
|
||||
import com.google.android.exoplayer2.ui.AspectRatioFrameLayout
|
||||
import com.google.android.exoplayer2.ui.StyledPlayerView
|
||||
import dev.icerock.moko.resources.compose.stringResource
|
||||
import java.net.URI
|
||||
|
||||
@Composable
|
||||
actual fun FullScreenImageView(modifier: Modifier, uri: URI, imageBitmap: ImageBitmap) {
|
||||
// I'm making a new instance of imageLoader here because if I use one instance in multiple places
|
||||
// after end of composition here a GIF from the first instance will be paused automatically which isn't what I want
|
||||
val imageLoader = ImageLoader.Builder(LocalContext.current)
|
||||
.components {
|
||||
if (Build.VERSION.SDK_INT >= 28) {
|
||||
add(ImageDecoderDecoder.Factory())
|
||||
} else {
|
||||
add(GifDecoder.Factory())
|
||||
}
|
||||
}
|
||||
.build()
|
||||
Image(
|
||||
rememberAsyncImagePainter(
|
||||
ImageRequest.Builder(LocalContext.current).data(data = uri.toUri()).size(Size.ORIGINAL).build(),
|
||||
placeholder = BitmapPainter(imageBitmap), // show original image while it's still loading by coil
|
||||
imageLoader = imageLoader
|
||||
),
|
||||
contentDescription = stringResource(MR.strings.image_descr),
|
||||
contentScale = ContentScale.Fit,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
actual fun FullScreenVideoView(player: VideoPlayer, modifier: Modifier) {
|
||||
AndroidView(
|
||||
factory = { ctx ->
|
||||
StyledPlayerView(ctx).apply {
|
||||
resizeMode = if (ctx.resources.configuration.screenWidthDp > ctx.resources.configuration.screenHeightDp) {
|
||||
AspectRatioFrameLayout.RESIZE_MODE_FIXED_HEIGHT
|
||||
} else {
|
||||
AspectRatioFrameLayout.RESIZE_MODE_FIXED_WIDTH
|
||||
}
|
||||
setShowPreviousButton(false)
|
||||
setShowNextButton(false)
|
||||
setShowSubtitleButton(false)
|
||||
setShowVrButton(false)
|
||||
controllerAutoShow = false
|
||||
findViewById<View>(com.google.android.exoplayer2.R.id.exo_controls_background).setBackgroundColor(Color.Black.copy(alpha = 0.3f).toArgb())
|
||||
findViewById<View>(com.google.android.exoplayer2.R.id.exo_settings).isVisible = false
|
||||
this.player = player.player
|
||||
}
|
||||
},
|
||||
modifier
|
||||
)
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
package chat.simplex.common.views.helpers
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.ui.Modifier
|
||||
import chat.simplex.common.views.newchat.ActionButton
|
||||
import chat.simplex.res.MR
|
||||
import dev.icerock.moko.resources.compose.painterResource
|
||||
import dev.icerock.moko.resources.compose.stringResource
|
||||
|
||||
@Composable
|
||||
actual fun ChooseAttachmentButtons(attachmentOption: MutableState<AttachmentOption?>, hide: () -> Unit) {
|
||||
ActionButton(Modifier.fillMaxWidth(0.25f), null, stringResource(MR.strings.use_camera_button), icon = painterResource(MR.images.ic_camera_enhance)) {
|
||||
attachmentOption.value = AttachmentOption.CameraPhoto
|
||||
hide()
|
||||
}
|
||||
ActionButton(Modifier.fillMaxWidth(0.33f), null, stringResource(MR.strings.gallery_image_button), icon = painterResource(MR.images.ic_add_photo)) {
|
||||
attachmentOption.value = AttachmentOption.GalleryImage
|
||||
hide()
|
||||
}
|
||||
ActionButton(Modifier.fillMaxWidth(0.50f), null, stringResource(MR.strings.gallery_video_button), icon = painterResource(MR.images.ic_smart_display)) {
|
||||
attachmentOption.value = AttachmentOption.GalleryVideo
|
||||
hide()
|
||||
}
|
||||
ActionButton(Modifier.fillMaxWidth(1f), null, stringResource(MR.strings.choose_file), icon = painterResource(MR.images.ic_note_add)) {
|
||||
attachmentOption.value = AttachmentOption.File
|
||||
hide()
|
||||
}
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
package chat.simplex.common.views.helpers
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.window.Dialog
|
||||
|
||||
@Composable
|
||||
actual fun DefaultDialog(
|
||||
onDismissRequest: () -> Unit,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
Dialog(
|
||||
onDismissRequest = onDismissRequest
|
||||
) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
package chat.simplex.common.views.helpers
|
||||
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.DpOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.PopupProperties
|
||||
|
||||
actual fun Modifier.onRightClick(action: () -> Unit): Modifier = this
|
||||
|
||||
actual interface DefaultExposedDropdownMenuBoxScope {
|
||||
@Composable
|
||||
actual fun DefaultExposedDropdownMenu(
|
||||
expanded: Boolean,
|
||||
onDismissRequest: () -> Unit,
|
||||
modifier: Modifier,
|
||||
content: @Composable ColumnScope.() -> Unit
|
||||
) {
|
||||
DropdownMenu(expanded, onDismissRequest, modifier, content = content)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DropdownMenu(
|
||||
expanded: Boolean,
|
||||
onDismissRequest: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
offset: DpOffset = DpOffset(0.dp, 0.dp),
|
||||
properties: PopupProperties = PopupProperties(focusable = true),
|
||||
content: @Composable ColumnScope.() -> Unit
|
||||
) {
|
||||
androidx.compose.material.DropdownMenu(expanded, onDismissRequest, modifier, offset, properties, content)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
actual fun DefaultExposedDropdownMenuBox(
|
||||
expanded: Boolean,
|
||||
onExpandedChange: (Boolean) -> Unit,
|
||||
modifier: Modifier,
|
||||
content: @Composable DefaultExposedDropdownMenuBoxScope.() -> Unit
|
||||
) {
|
||||
val scope = remember { object : DefaultExposedDropdownMenuBoxScope {} }
|
||||
androidx.compose.material.ExposedDropdownMenuBox(expanded, onExpandedChange, modifier, content = {
|
||||
scope.content()
|
||||
})
|
||||
}
|
||||
+78
-86
@@ -1,8 +1,7 @@
|
||||
package chat.simplex.app.views.helpers
|
||||
package chat.simplex.common.views.helpers
|
||||
|
||||
import android.Manifest
|
||||
import android.app.Activity
|
||||
import android.app.Application
|
||||
import android.content.*
|
||||
import android.content.Intent.FLAG_ACTIVITY_NEW_TASK
|
||||
import android.content.pm.PackageManager
|
||||
@@ -10,8 +9,6 @@ import android.graphics.*
|
||||
import android.net.Uri
|
||||
import android.provider.MediaStore
|
||||
import android.util.Base64
|
||||
import android.util.Log
|
||||
import android.widget.Toast
|
||||
import androidx.activity.compose.ManagedActivityResultLauncher
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContract
|
||||
@@ -23,103 +20,35 @@ import androidx.compose.runtime.saveable.Saver
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import dev.icerock.moko.resources.compose.painterResource
|
||||
import dev.icerock.moko.resources.compose.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.content.FileProvider
|
||||
import chat.simplex.app.*
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.ChatModel
|
||||
import chat.simplex.app.model.json
|
||||
import chat.simplex.app.views.chat.PickFromGallery
|
||||
import chat.simplex.app.views.newchat.ActionButton
|
||||
import chat.simplex.common.helpers.APPLICATION_ID
|
||||
import chat.simplex.common.helpers.toURI
|
||||
import chat.simplex.common.model.ChatModel
|
||||
import chat.simplex.common.model.json
|
||||
import chat.simplex.common.platform.*
|
||||
import chat.simplex.common.views.newchat.ActionButton
|
||||
import chat.simplex.res.MR
|
||||
import kotlinx.serialization.builtins.*
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.File
|
||||
import kotlin.math.min
|
||||
import kotlin.math.sqrt
|
||||
|
||||
// Inspired by https://github.com/MakeItEasyDev/Jetpack-Compose-Capture-Image-Or-Choose-from-Gallery
|
||||
fun cropToSquare(image: Bitmap): Bitmap {
|
||||
var xOffset = 0
|
||||
var yOffset = 0
|
||||
val side = min(image.height, image.width)
|
||||
if (image.height < image.width) {
|
||||
xOffset = (image.width - side) / 2
|
||||
} else {
|
||||
yOffset = (image.height - side) / 2
|
||||
}
|
||||
return Bitmap.createBitmap(image, xOffset, yOffset, side, side)
|
||||
}
|
||||
|
||||
fun resizeImageToStrSize(image: Bitmap, maxDataSize: Long): String {
|
||||
var img = image
|
||||
var str = compressImageStr(img)
|
||||
while (str.length > maxDataSize) {
|
||||
val ratio = sqrt(str.length.toDouble() / maxDataSize.toDouble())
|
||||
val clippedRatio = min(ratio, 2.0)
|
||||
val width = (img.width.toDouble() / clippedRatio).toInt()
|
||||
val height = img.height * width / img.width
|
||||
img = Bitmap.createScaledBitmap(img, width, height, true)
|
||||
str = compressImageStr(img)
|
||||
}
|
||||
return str
|
||||
}
|
||||
|
||||
private fun compressImageStr(bitmap: Bitmap): String {
|
||||
val usePng = bitmap.hasAlpha()
|
||||
val ext = if (usePng) "png" else "jpg"
|
||||
return "data:image/$ext;base64," + Base64.encodeToString(compressImageData(bitmap, usePng).toByteArray(), Base64.NO_WRAP)
|
||||
}
|
||||
|
||||
fun resizeImageToDataSize(image: Bitmap, usePng: Boolean, maxDataSize: Long): ByteArrayOutputStream {
|
||||
var img = image
|
||||
var stream = compressImageData(img, usePng)
|
||||
while (stream.size() > maxDataSize) {
|
||||
val ratio = sqrt(stream.size().toDouble() / maxDataSize.toDouble())
|
||||
val clippedRatio = min(ratio, 2.0)
|
||||
val width = (img.width.toDouble() / clippedRatio).toInt()
|
||||
val height = img.height * width / img.width
|
||||
img = Bitmap.createScaledBitmap(img, width, height, true)
|
||||
stream = compressImageData(img, usePng)
|
||||
}
|
||||
return stream
|
||||
}
|
||||
|
||||
private fun compressImageData(bitmap: Bitmap, usePng: Boolean): ByteArrayOutputStream {
|
||||
val stream = ByteArrayOutputStream()
|
||||
bitmap.compress(if (!usePng) Bitmap.CompressFormat.JPEG else Bitmap.CompressFormat.PNG, 85, stream)
|
||||
return stream
|
||||
}
|
||||
import java.net.URI
|
||||
|
||||
val errorBitmapBytes = Base64.decode("iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAAAXNSR0IArs4c6QAAAKVJREFUeF7t1kENACEUQ0FQhnVQ9lfGO+xggITQdvbMzArPey+8fa3tAfwAEdABZQspQStgBssEcgAIkSAJkiAJljtEgiRIgmUCSZAESZAESZAEyx0iQRIkwTKBJEiCv5fgvTd1wDmn7QAP4AeIgA4oW0gJWgEzWCZwbQ7gAA7ggLKFOIADOKBMIAeAEAmSIAmSYLlDJEiCJFgmkARJkARJ8N8S/ADTZUewBvnTOQAAAABJRU5ErkJggg==", Base64.NO_WRAP)
|
||||
val errorBitmap: Bitmap = BitmapFactory.decodeByteArray(errorBitmapBytes, 0, errorBitmapBytes.size)
|
||||
|
||||
fun base64ToBitmap(base64ImageString: String): Bitmap {
|
||||
val imageString = base64ImageString
|
||||
.removePrefix("data:image/png;base64,")
|
||||
.removePrefix("data:image/jpg;base64,")
|
||||
try {
|
||||
val imageBytes = Base64.decode(imageString, Base64.NO_WRAP)
|
||||
return BitmapFactory.decodeByteArray(imageBytes, 0, imageBytes.size)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "base64ToBitmap error: $e")
|
||||
return errorBitmap
|
||||
}
|
||||
}
|
||||
|
||||
class CustomTakePicturePreview(var uri: Uri?, var tmpFile: File?): ActivityResultContract<Void?, Uri?>() {
|
||||
@CallSuper
|
||||
override fun createIntent(context: Context, input: Void?): Intent {
|
||||
val tmpDir = SimplexApp.context.getDir("temp", Application.MODE_PRIVATE)
|
||||
tmpFile = File.createTempFile("image", ".bmp", tmpDir)
|
||||
// Since the class should return Uri, the file should be deleted somewhere else. And in order to be sure, delegate this to system
|
||||
tmpFile?.deleteOnExit()
|
||||
ChatModel.filesToDelete.add(tmpFile!!)
|
||||
uri = FileProvider.getUriForFile(context, "${BuildConfig.APPLICATION_ID}.provider", tmpFile!!)
|
||||
uri = FileProvider.getUriForFile(context, "$APPLICATION_ID.provider", tmpFile!!)
|
||||
return Intent(MediaStore.ACTION_IMAGE_CAPTURE)
|
||||
.putExtra(MediaStore.EXTRA_OUTPUT, uri)
|
||||
}
|
||||
@@ -201,7 +130,7 @@ fun ManagedActivityResultLauncher<Void?, Uri?>.launchWithFallback() {
|
||||
|
||||
try {
|
||||
// Try to open any camera just to capture an image, will not be returned like with previous intent
|
||||
SimplexApp.context.startActivity(Intent(MediaStore.INTENT_ACTION_STILL_IMAGE_CAMERA).also { it.addFlags(FLAG_ACTIVITY_NEW_TASK) })
|
||||
androidAppContext.startActivity(Intent(MediaStore.INTENT_ACTION_STILL_IMAGE_CAMERA).also { it.addFlags(FLAG_ACTIVITY_NEW_TASK) })
|
||||
} catch (e: ActivityNotFoundException) {
|
||||
// No camera apps available at all
|
||||
Log.e(TAG, "Camera launcher2: " + e.stackTraceToString())
|
||||
@@ -210,14 +139,15 @@ fun ManagedActivityResultLauncher<Void?, Uri?>.launchWithFallback() {
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun GetImageBottomSheet(
|
||||
imageBitmap: MutableState<Uri?>,
|
||||
onImageChange: (Bitmap) -> Unit,
|
||||
actual fun GetImageBottomSheet(
|
||||
imageBitmap: MutableState<URI?>,
|
||||
onImageChange: (ImageBitmap) -> Unit,
|
||||
hideBottomSheet: () -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val processPickedImage = { uri: Uri? ->
|
||||
if (uri != null) {
|
||||
val uri = uri.toURI()
|
||||
val bitmap = getBitmapFromUri(uri)
|
||||
if (bitmap != null) {
|
||||
imageBitmap.value = uri
|
||||
@@ -233,7 +163,7 @@ fun GetImageBottomSheet(
|
||||
cameraLauncher.launchWithFallback()
|
||||
hideBottomSheet()
|
||||
} else {
|
||||
Toast.makeText(context, generalGetString(MR.strings.toast_permission_denied), Toast.LENGTH_SHORT).show()
|
||||
showToast(generalGetString(MR.strings.toast_permission_denied))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -273,3 +203,65 @@ fun GetImageBottomSheet(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class PickFromGallery: ActivityResultContract<Int, Uri?>() {
|
||||
override fun createIntent(context: Context, input: Int) =
|
||||
Intent(Intent.ACTION_PICK, MediaStore.Images.Media.INTERNAL_CONTENT_URI).apply {
|
||||
type = "image/*"
|
||||
}
|
||||
|
||||
override fun parseResult(resultCode: Int, intent: Intent?): Uri? = intent?.data
|
||||
}
|
||||
|
||||
class PickMultipleImagesFromGallery: ActivityResultContract<Int, List<Uri>>() {
|
||||
override fun createIntent(context: Context, input: Int) =
|
||||
Intent(Intent.ACTION_PICK, MediaStore.Images.Media.INTERNAL_CONTENT_URI).apply {
|
||||
putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true)
|
||||
type = "image/*"
|
||||
}
|
||||
|
||||
override fun parseResult(resultCode: Int, intent: Intent?): List<Uri> =
|
||||
if (intent?.data != null)
|
||||
listOf(intent.data!!)
|
||||
else if (intent?.clipData != null)
|
||||
with(intent.clipData!!) {
|
||||
val uris = ArrayList<Uri>()
|
||||
for (i in 0 until kotlin.math.min(itemCount, 10)) {
|
||||
val uri = getItemAt(i).uri
|
||||
if (uri != null) uris.add(uri)
|
||||
}
|
||||
if (itemCount > 10) {
|
||||
AlertManager.shared.showAlertMsg(MR.strings.images_limit_title, MR.strings.images_limit_desc)
|
||||
}
|
||||
uris
|
||||
}
|
||||
else
|
||||
emptyList()
|
||||
}
|
||||
|
||||
|
||||
class PickMultipleVideosFromGallery: ActivityResultContract<Int, List<Uri>>() {
|
||||
override fun createIntent(context: Context, input: Int) =
|
||||
Intent(Intent.ACTION_PICK, MediaStore.Video.Media.INTERNAL_CONTENT_URI).apply {
|
||||
putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true)
|
||||
type = "video/*"
|
||||
}
|
||||
|
||||
override fun parseResult(resultCode: Int, intent: Intent?): List<Uri> =
|
||||
if (intent?.data != null)
|
||||
listOf(intent.data!!)
|
||||
else if (intent?.clipData != null)
|
||||
with(intent.clipData!!) {
|
||||
val uris = ArrayList<Uri>()
|
||||
for (i in 0 until kotlin.math.min(itemCount, 10)) {
|
||||
val uri = getItemAt(i).uri
|
||||
if (uri != null) uris.add(uri)
|
||||
}
|
||||
if (itemCount > 10) {
|
||||
AlertManager.shared.showAlertMsg(MR.strings.videos_limit_title, MR.strings.videos_limit_desc)
|
||||
}
|
||||
uris
|
||||
}
|
||||
else
|
||||
emptyList()
|
||||
}
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
package chat.simplex.common.views.helpers
|
||||
|
||||
import android.os.Build.VERSION.SDK_INT
|
||||
import androidx.biometric.BiometricManager
|
||||
import androidx.biometric.BiometricManager.Authenticators.*
|
||||
import androidx.biometric.BiometricPrompt
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import chat.simplex.common.platform.mainActivity
|
||||
import chat.simplex.common.views.usersettings.LAMode
|
||||
|
||||
actual fun authenticate(
|
||||
promptTitle: String,
|
||||
promptSubtitle: String,
|
||||
selfDestruct: Boolean,
|
||||
usingLAMode: LAMode,
|
||||
completed: (LAResult) -> Unit
|
||||
) {
|
||||
val activity = mainActivity.get() ?: return completed(LAResult.Error(""))
|
||||
when (usingLAMode) {
|
||||
LAMode.SYSTEM -> when {
|
||||
SDK_INT in 28..29 ->
|
||||
// KeyguardManager.isDeviceSecure()? https://developer.android.com/training/sign-in/biometric-auth#declare-supported-authentication-types
|
||||
authenticateWithBiometricManager(promptTitle, promptSubtitle, activity, completed, BIOMETRIC_WEAK or DEVICE_CREDENTIAL)
|
||||
SDK_INT > 29 ->
|
||||
authenticateWithBiometricManager(promptTitle, promptSubtitle, activity, completed, BIOMETRIC_STRONG or DEVICE_CREDENTIAL)
|
||||
else -> completed(LAResult.Unavailable())
|
||||
}
|
||||
LAMode.PASSCODE -> {
|
||||
authenticateWithPasscode(promptTitle, promptSubtitle, selfDestruct, completed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun authenticateWithBiometricManager(
|
||||
promptTitle: String,
|
||||
promptSubtitle: String,
|
||||
activity: FragmentActivity,
|
||||
completed: (LAResult) -> Unit,
|
||||
authenticators: Int
|
||||
) {
|
||||
val biometricManager = BiometricManager.from(activity)
|
||||
when (biometricManager.canAuthenticate(authenticators)) {
|
||||
BiometricManager.BIOMETRIC_SUCCESS -> {
|
||||
val executor = ContextCompat.getMainExecutor(activity)
|
||||
val biometricPrompt = BiometricPrompt(
|
||||
activity,
|
||||
executor,
|
||||
object: BiometricPrompt.AuthenticationCallback() {
|
||||
override fun onAuthenticationError(
|
||||
errorCode: Int,
|
||||
errString: CharSequence
|
||||
) {
|
||||
super.onAuthenticationError(errorCode, errString)
|
||||
completed(LAResult.Error(errString))
|
||||
}
|
||||
|
||||
override fun onAuthenticationSucceeded(
|
||||
result: BiometricPrompt.AuthenticationResult
|
||||
) {
|
||||
super.onAuthenticationSucceeded(result)
|
||||
completed(LAResult.Success)
|
||||
}
|
||||
|
||||
override fun onAuthenticationFailed() {
|
||||
super.onAuthenticationFailed()
|
||||
completed(LAResult.Failed())
|
||||
}
|
||||
}
|
||||
)
|
||||
val promptInfo = BiometricPrompt.PromptInfo.Builder()
|
||||
.setTitle(promptTitle)
|
||||
.setSubtitle(promptSubtitle)
|
||||
.setAllowedAuthenticators(authenticators)
|
||||
.setConfirmationRequired(false)
|
||||
.build()
|
||||
biometricPrompt.authenticate(promptInfo)
|
||||
}
|
||||
else -> completed(LAResult.Unavailable())
|
||||
}
|
||||
}
|
||||
+310
@@ -0,0 +1,310 @@
|
||||
package chat.simplex.common.views.helpers
|
||||
|
||||
import android.app.Application
|
||||
import android.content.res.Resources
|
||||
import android.graphics.*
|
||||
import android.graphics.Typeface
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.media.MediaMetadataRetriever
|
||||
import android.os.*
|
||||
import android.provider.OpenableColumns
|
||||
import android.text.Spanned
|
||||
import android.text.SpannedString
|
||||
import android.text.style.*
|
||||
import android.util.Base64
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.graphics.*
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.*
|
||||
import androidx.compose.ui.text.*
|
||||
import androidx.compose.ui.text.font.*
|
||||
import androidx.compose.ui.text.style.BaselineShift
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.unit.*
|
||||
import androidx.core.content.FileProvider
|
||||
import androidx.core.text.HtmlCompat
|
||||
import chat.simplex.common.helpers.*
|
||||
import chat.simplex.common.model.*
|
||||
import chat.simplex.common.platform.*
|
||||
import chat.simplex.res.MR
|
||||
import dev.icerock.moko.resources.StringResource
|
||||
import java.io.*
|
||||
import java.net.URI
|
||||
|
||||
fun Spanned.toHtmlWithoutParagraphs(): String {
|
||||
return HtmlCompat.toHtml(this, HtmlCompat.TO_HTML_PARAGRAPH_LINES_CONSECUTIVE)
|
||||
.substringAfter("<p dir=\"ltr\">").substringBeforeLast("</p>")
|
||||
}
|
||||
|
||||
fun Resources.getText(id: StringResource, vararg args: Any): CharSequence {
|
||||
val escapedArgs = args.map {
|
||||
if (it is Spanned) it.toHtmlWithoutParagraphs() else it
|
||||
}.toTypedArray()
|
||||
val resource = SpannedString(getText(id))
|
||||
val htmlResource = resource.toHtmlWithoutParagraphs()
|
||||
val formattedHtml = String.format(htmlResource, *escapedArgs)
|
||||
return HtmlCompat.fromHtml(formattedHtml, HtmlCompat.FROM_HTML_MODE_LEGACY)
|
||||
}
|
||||
|
||||
actual fun escapedHtmlToAnnotatedString(text: String, density: Density): AnnotatedString {
|
||||
return spannableStringToAnnotatedString(HtmlCompat.fromHtml(text, HtmlCompat.FROM_HTML_MODE_LEGACY), density)
|
||||
}
|
||||
|
||||
private fun spannableStringToAnnotatedString(
|
||||
text: CharSequence,
|
||||
density: Density,
|
||||
): AnnotatedString {
|
||||
return if (text is Spanned) {
|
||||
with(density) {
|
||||
buildAnnotatedString {
|
||||
append((text.toString()))
|
||||
text.getSpans(0, text.length, Any::class.java).forEach {
|
||||
val start = text.getSpanStart(it)
|
||||
val end = text.getSpanEnd(it)
|
||||
when (it) {
|
||||
is StyleSpan -> when (it.style) {
|
||||
Typeface.NORMAL -> addStyle(
|
||||
SpanStyle(
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontStyle = FontStyle.Normal,
|
||||
),
|
||||
start,
|
||||
end
|
||||
)
|
||||
Typeface.BOLD -> addStyle(
|
||||
SpanStyle(
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontStyle = FontStyle.Normal
|
||||
),
|
||||
start,
|
||||
end
|
||||
)
|
||||
Typeface.ITALIC -> addStyle(
|
||||
SpanStyle(
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontStyle = FontStyle.Italic
|
||||
),
|
||||
start,
|
||||
end
|
||||
)
|
||||
Typeface.BOLD_ITALIC -> addStyle(
|
||||
SpanStyle(
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontStyle = FontStyle.Italic
|
||||
),
|
||||
start,
|
||||
end
|
||||
)
|
||||
}
|
||||
is TypefaceSpan -> addStyle(
|
||||
SpanStyle(
|
||||
fontFamily = when (it.family) {
|
||||
FontFamily.SansSerif.name -> FontFamily.SansSerif
|
||||
FontFamily.Serif.name -> FontFamily.Serif
|
||||
FontFamily.Monospace.name -> FontFamily.Monospace
|
||||
FontFamily.Cursive.name -> FontFamily.Cursive
|
||||
else -> FontFamily.Default
|
||||
}
|
||||
),
|
||||
start,
|
||||
end
|
||||
)
|
||||
is AbsoluteSizeSpan -> addStyle(
|
||||
SpanStyle(fontSize = if (it.dip) it.size.dp.toSp() else it.size.toSp()),
|
||||
start,
|
||||
end
|
||||
)
|
||||
is RelativeSizeSpan -> addStyle(
|
||||
SpanStyle(fontSize = it.sizeChange.em),
|
||||
start,
|
||||
end
|
||||
)
|
||||
is StrikethroughSpan -> addStyle(
|
||||
SpanStyle(textDecoration = TextDecoration.LineThrough),
|
||||
start,
|
||||
end
|
||||
)
|
||||
is UnderlineSpan -> addStyle(
|
||||
SpanStyle(textDecoration = TextDecoration.Underline),
|
||||
start,
|
||||
end
|
||||
)
|
||||
is SuperscriptSpan -> addStyle(
|
||||
SpanStyle(baselineShift = BaselineShift.Superscript),
|
||||
start,
|
||||
end
|
||||
)
|
||||
is SubscriptSpan -> addStyle(
|
||||
SpanStyle(baselineShift = BaselineShift.Subscript),
|
||||
start,
|
||||
end
|
||||
)
|
||||
is ForegroundColorSpan -> addStyle(
|
||||
SpanStyle(color = Color(it.foregroundColor)),
|
||||
start,
|
||||
end
|
||||
)
|
||||
else -> addStyle(SpanStyle(color = Color.White), start, end)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
AnnotatedString(text.toString())
|
||||
}
|
||||
}
|
||||
|
||||
actual fun getAppFileUri(fileName: String): URI =
|
||||
FileProvider.getUriForFile(androidAppContext, "$APPLICATION_ID.provider", File(getAppFilePath(fileName))).toURI()
|
||||
|
||||
// https://developer.android.com/training/data-storage/shared/documents-files#bitmap
|
||||
actual fun getLoadedImage(file: CIFile?): ImageBitmap? {
|
||||
val filePath = getLoadedFilePath(file)
|
||||
return if (filePath != null) {
|
||||
try {
|
||||
val uri = getAppFileUri(filePath.substringAfterLast(File.separator))
|
||||
val parcelFileDescriptor = androidAppContext.contentResolver.openFileDescriptor(uri.toUri(), "r")
|
||||
val fileDescriptor = parcelFileDescriptor?.fileDescriptor
|
||||
val image = decodeSampledBitmapFromFileDescriptor(fileDescriptor, 1000, 1000)
|
||||
parcelFileDescriptor?.close()
|
||||
image.asImageBitmap()
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
// https://developer.android.com/topic/performance/graphics/load-bitmap#load-bitmap
|
||||
private fun decodeSampledBitmapFromFileDescriptor(fileDescriptor: FileDescriptor?, reqWidth: Int, reqHeight: Int): Bitmap {
|
||||
// First decode with inJustDecodeBounds=true to check dimensions
|
||||
return BitmapFactory.Options().run {
|
||||
inJustDecodeBounds = true
|
||||
BitmapFactory.decodeFileDescriptor(fileDescriptor, null, this)
|
||||
// Calculate inSampleSize
|
||||
inSampleSize = calculateInSampleSize(this, reqWidth, reqHeight)
|
||||
// Decode bitmap with inSampleSize set
|
||||
inJustDecodeBounds = false
|
||||
|
||||
BitmapFactory.decodeFileDescriptor(fileDescriptor, null, this)
|
||||
}
|
||||
}
|
||||
|
||||
private fun calculateInSampleSize(options: BitmapFactory.Options, reqWidth: Int, reqHeight: Int): Int {
|
||||
// Raw height and width of image
|
||||
val (height: Int, width: Int) = options.run { outHeight to outWidth }
|
||||
var inSampleSize = 1
|
||||
|
||||
if (height > reqHeight || width > reqWidth) {
|
||||
val halfHeight: Int = height / 2
|
||||
val halfWidth: Int = width / 2
|
||||
// Calculate the largest inSampleSize value that is a power of 2 and keeps both
|
||||
// height and width larger than the requested height and width.
|
||||
while (halfHeight / inSampleSize >= reqHeight && halfWidth / inSampleSize >= reqWidth) {
|
||||
inSampleSize *= 2
|
||||
}
|
||||
}
|
||||
|
||||
return inSampleSize
|
||||
}
|
||||
|
||||
actual fun getFileName(uri: URI): String? {
|
||||
return androidAppContext.contentResolver.query(uri.toUri(), null, null, null, null)?.use { cursor ->
|
||||
val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
|
||||
cursor.moveToFirst()
|
||||
cursor.getString(nameIndex)
|
||||
}
|
||||
}
|
||||
|
||||
actual fun getAppFilePath(uri: URI): String? {
|
||||
return androidAppContext.contentResolver.query(uri.toUri(), null, null, null, null)?.use { cursor ->
|
||||
val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
|
||||
cursor.moveToFirst()
|
||||
getAppFilePath(cursor.getString(nameIndex))
|
||||
}
|
||||
}
|
||||
|
||||
actual fun getFileSize(uri: URI): Long? {
|
||||
return androidAppContext.contentResolver.query(uri.toUri(), null, null, null, null)?.use { cursor ->
|
||||
val sizeIndex = cursor.getColumnIndex(OpenableColumns.SIZE)
|
||||
cursor.moveToFirst()
|
||||
cursor.getLong(sizeIndex)
|
||||
}
|
||||
}
|
||||
|
||||
actual fun getBitmapFromUri(uri: URI, withAlertOnException: Boolean): ImageBitmap? {
|
||||
return if (Build.VERSION.SDK_INT >= 28) {
|
||||
val source = ImageDecoder.createSource(androidAppContext.contentResolver, uri.toUri())
|
||||
try {
|
||||
ImageDecoder.decodeBitmap(source)
|
||||
} catch (e: android.graphics.ImageDecoder.DecodeException) {
|
||||
Log.e(TAG, "Unable to decode the image: ${e.stackTraceToString()}")
|
||||
if (withAlertOnException) {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title = generalGetString(MR.strings.image_decoding_exception_title),
|
||||
text = generalGetString(MR.strings.image_decoding_exception_desc)
|
||||
)
|
||||
}
|
||||
null
|
||||
}
|
||||
} else {
|
||||
BitmapFactory.decodeFile(getAppFilePath(uri))
|
||||
}?.asImageBitmap()
|
||||
}
|
||||
|
||||
actual fun getDrawableFromUri(uri: URI, withAlertOnException: Boolean): Any? {
|
||||
return if (Build.VERSION.SDK_INT >= 28) {
|
||||
val source = ImageDecoder.createSource(androidAppContext.contentResolver, uri.toUri())
|
||||
try {
|
||||
ImageDecoder.decodeDrawable(source)
|
||||
} catch (e: android.graphics.ImageDecoder.DecodeException) {
|
||||
if (withAlertOnException) {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title = generalGetString(MR.strings.image_decoding_exception_title),
|
||||
text = generalGetString(MR.strings.image_decoding_exception_desc)
|
||||
)
|
||||
}
|
||||
Log.e(TAG, "Error while decoding drawable: ${e.stackTraceToString()}")
|
||||
null
|
||||
}
|
||||
} else {
|
||||
Drawable.createFromPath(getAppFilePath(uri))
|
||||
}
|
||||
}
|
||||
|
||||
actual suspend fun saveTempImageUncompressed(image: ImageBitmap, asPng: Boolean): File? {
|
||||
return try {
|
||||
val ext = if (asPng) "png" else "jpg"
|
||||
tmpDir.mkdir()
|
||||
return File(tmpDir.absolutePath + File.separator + generateNewFileName("IMG", ext)).apply {
|
||||
outputStream().use { out ->
|
||||
image.asAndroidBitmap().compress(if (asPng) Bitmap.CompressFormat.PNG else Bitmap.CompressFormat.JPEG, 85, out)
|
||||
out.flush()
|
||||
}
|
||||
deleteOnExit()
|
||||
ChatModel.filesToDelete.add(this)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Util.kt saveTempImageUncompressed error: ${e.message}")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
actual fun getBitmapFromVideo(uri: URI, timestamp: Long?, random: Boolean): VideoPlayerInterface.PreviewAndDuration {
|
||||
val mmr = MediaMetadataRetriever()
|
||||
mmr.setDataSource(androidAppContext, uri.toUri())
|
||||
val durationMs = mmr.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)?.toLong()
|
||||
val image = when {
|
||||
timestamp != null -> mmr.getFrameAtTime(timestamp * 1000, MediaMetadataRetriever.OPTION_CLOSEST)
|
||||
random -> mmr.frameAtTime
|
||||
else -> mmr.getFrameAtTime(0)
|
||||
}
|
||||
mmr.release()
|
||||
return VideoPlayerInterface.PreviewAndDuration(image?.asImageBitmap(), durationMs, timestamp ?: 0)
|
||||
}
|
||||
|
||||
actual fun ByteArray.toBase64StringForPassphrase(): String = Base64.encodeToString(this, Base64.DEFAULT)
|
||||
|
||||
actual fun String.toByteArrayFromBase64ForPassphrase(): ByteArray = Base64.decode(this, Base64.DEFAULT)
|
||||
+3
-8
@@ -1,4 +1,4 @@
|
||||
package chat.simplex.app.views.newchat
|
||||
package chat.simplex.common.views.newchat
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.*
|
||||
@@ -8,16 +8,11 @@ import androidx.compose.ui.graphics.Color
|
||||
import dev.icerock.moko.resources.compose.painterResource
|
||||
import dev.icerock.moko.resources.compose.stringResource
|
||||
import androidx.compose.ui.unit.sp
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.ChatModel
|
||||
import chat.simplex.common.model.ChatModel
|
||||
import chat.simplex.res.MR
|
||||
|
||||
enum class ConnectViaLinkTab {
|
||||
SCAN, PASTE
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ConnectViaLinkView(m: ChatModel, close: () -> Unit) {
|
||||
actual fun ConnectViaLinkView(m: ChatModel, close: () -> Unit) {
|
||||
val selection = remember {
|
||||
mutableStateOf(
|
||||
runCatching { ConnectViaLinkTab.valueOf(m.controller.appPrefs.connectViaLinkTab.get()!!) }.getOrDefault(ConnectViaLinkTab.SCAN)
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
package chat.simplex.common.views.newchat
|
||||
|
||||
import androidx.compose.ui.graphics.*
|
||||
|
||||
actual fun ImageBitmap.replaceColor(from: Int, to: Int): ImageBitmap {
|
||||
val pixels = IntArray(width * height)
|
||||
val bitmap = this.asAndroidBitmap()
|
||||
bitmap.getPixels(pixels, 0, width, 0, 0, width, height)
|
||||
var i = 0
|
||||
while (i < pixels.size) {
|
||||
if (pixels[i] == from) {
|
||||
pixels[i] = to
|
||||
}
|
||||
i++
|
||||
}
|
||||
bitmap.setPixels(pixels, 0, width, 0, 0, width, height)
|
||||
return bitmap.asImageBitmap()
|
||||
}
|
||||
+3
-3
@@ -1,4 +1,4 @@
|
||||
package chat.simplex.app.views.newchat
|
||||
package chat.simplex.common.views.newchat
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.util.Log
|
||||
@@ -18,14 +18,14 @@ import boofcv.alg.color.ColorFormat
|
||||
import boofcv.android.ConvertCameraImage
|
||||
import boofcv.factory.fiducial.FactoryFiducial
|
||||
import boofcv.struct.image.GrayU8
|
||||
import chat.simplex.app.TAG
|
||||
import chat.simplex.common.platform.TAG
|
||||
import com.google.common.util.concurrent.ListenableFuture
|
||||
import java.util.concurrent.*
|
||||
|
||||
// Adapted from learntodroid - https://gist.github.com/learntodroid/8f839be0b29d0378f843af70607bd7f5
|
||||
|
||||
@Composable
|
||||
fun QRCodeScanner(onBarcode: (String) -> Unit) {
|
||||
actual fun QRCodeScanner(onBarcode: (String) -> Unit) {
|
||||
val context = LocalContext.current
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
var preview by remember { mutableStateOf<Preview?>(null) }
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
package chat.simplex.common.views.newchat
|
||||
|
||||
import android.Manifest
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import chat.simplex.common.model.ChatModel
|
||||
import com.google.accompanist.permissions.rememberPermissionState
|
||||
|
||||
@Composable
|
||||
actual fun ScanToConnectView(chatModel: ChatModel, close: () -> Unit) {
|
||||
val cameraPermissionState = rememberPermissionState(permission = Manifest.permission.CAMERA)
|
||||
LaunchedEffect(Unit) {
|
||||
cameraPermissionState.launchPermissionRequest()
|
||||
}
|
||||
ConnectContactLayout(
|
||||
chatModelIncognito = chatModel.incognito.value,
|
||||
close
|
||||
)
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
package chat.simplex.common.views.onboarding
|
||||
|
||||
import android.Manifest
|
||||
import android.os.Build
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import chat.simplex.common.platform.ntfManager
|
||||
import com.google.accompanist.permissions.rememberPermissionState
|
||||
|
||||
@Composable
|
||||
actual fun SetNotificationsModeAdditions() {
|
||||
if (Build.VERSION.SDK_INT >= 33) {
|
||||
val notificationsPermissionState = rememberPermissionState(Manifest.permission.POST_NOTIFICATIONS)
|
||||
LaunchedEffect(notificationsPermissionState.hasPermission) {
|
||||
if (notificationsPermissionState.hasPermission) {
|
||||
ntfManager.androidCreateNtfChannelsMaybeShowAlert()
|
||||
} else {
|
||||
notificationsPermissionState.launchPermissionRequest()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
LaunchedEffect(Unit) {
|
||||
ntfManager.androidCreateNtfChannelsMaybeShowAlert()
|
||||
}
|
||||
}
|
||||
}
|
||||
+169
@@ -0,0 +1,169 @@
|
||||
package chat.simplex.common.views.usersettings
|
||||
|
||||
import SectionBottomSpacer
|
||||
import SectionDividerSpaced
|
||||
import SectionView
|
||||
import android.app.Activity
|
||||
import android.content.ComponentName
|
||||
import android.content.pm.PackageManager
|
||||
import android.content.pm.PackageManager.COMPONENT_ENABLED_STATE_DEFAULT
|
||||
import android.content.pm.PackageManager.COMPONENT_ENABLED_STATE_ENABLED
|
||||
import androidx.compose.desktop.ui.tooling.preview.Preview
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.material.MaterialTheme.colors
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.graphics.*
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import dev.icerock.moko.resources.compose.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.graphics.drawable.toBitmap
|
||||
import chat.simplex.common.R
|
||||
import chat.simplex.common.model.*
|
||||
import chat.simplex.common.ui.theme.*
|
||||
import chat.simplex.common.views.helpers.*
|
||||
import chat.simplex.common.model.ChatModel
|
||||
import chat.simplex.common.platform.*
|
||||
import chat.simplex.common.helpers.APPLICATION_ID
|
||||
import chat.simplex.common.helpers.saveAppLocale
|
||||
import chat.simplex.common.views.usersettings.AppearanceScope.ColorEditor
|
||||
import chat.simplex.res.MR
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
enum class AppIcon(val resId: Int) {
|
||||
DEFAULT(R.drawable.icon_round_common),
|
||||
DARK_BLUE(R.drawable.icon_dark_blue_round_common),
|
||||
}
|
||||
|
||||
@Composable
|
||||
actual fun AppearanceView(m: ChatModel, showSettingsModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit)) {
|
||||
val appIcon = remember { mutableStateOf(findEnabledIcon()) }
|
||||
|
||||
fun setAppIcon(newIcon: AppIcon) {
|
||||
if (appIcon.value == newIcon) return
|
||||
val newComponent = ComponentName(APPLICATION_ID, "chat.simplex.app.MainActivity_${newIcon.name.lowercase()}")
|
||||
val oldComponent = ComponentName(APPLICATION_ID, "chat.simplex.app.MainActivity_${appIcon.value.name.lowercase()}")
|
||||
androidAppContext.packageManager.setComponentEnabledSetting(
|
||||
newComponent,
|
||||
COMPONENT_ENABLED_STATE_ENABLED, PackageManager.DONT_KILL_APP
|
||||
)
|
||||
|
||||
androidAppContext.packageManager.setComponentEnabledSetting(
|
||||
oldComponent,
|
||||
PackageManager.COMPONENT_ENABLED_STATE_DISABLED, PackageManager.DONT_KILL_APP
|
||||
)
|
||||
|
||||
appIcon.value = newIcon
|
||||
}
|
||||
|
||||
AppearanceScope.AppearanceLayout(
|
||||
appIcon,
|
||||
m.controller.appPrefs.appLanguage,
|
||||
m.controller.appPrefs.systemDarkTheme,
|
||||
changeIcon = ::setAppIcon,
|
||||
showSettingsModal = showSettingsModal,
|
||||
editColor = { name, initialColor ->
|
||||
ModalManager.start.showModalCloseable { close ->
|
||||
ColorEditor(name, initialColor, close)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AppearanceScope.AppearanceLayout(
|
||||
icon: MutableState<AppIcon>,
|
||||
languagePref: SharedPreference<String?>,
|
||||
systemDarkTheme: SharedPreference<String?>,
|
||||
changeIcon: (AppIcon) -> Unit,
|
||||
showSettingsModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit),
|
||||
editColor: (ThemeColor, Color) -> Unit,
|
||||
) {
|
||||
Column(
|
||||
Modifier.fillMaxWidth().verticalScroll(rememberScrollState()),
|
||||
) {
|
||||
AppBarTitle(stringResource(MR.strings.appearance_settings))
|
||||
SectionView(stringResource(MR.strings.settings_section_title_language), padding = PaddingValues()) {
|
||||
val context = LocalContext.current
|
||||
// if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
// SectionItemWithValue(
|
||||
// generalGetString(MR.strings.settings_section_title_language).lowercase().replaceFirstChar { if (it.isLowerCase()) it.titlecase(Locale.US) else it.toString() },
|
||||
// remember { mutableStateOf("system") },
|
||||
// listOf(ValueTitleDesc("system", generalGetString(MR.strings.change_verb), "")),
|
||||
// onSelected = { openSystemLangPicker(context as? Activity ?: return@SectionItemWithValue) }
|
||||
// )
|
||||
// } else {
|
||||
val state = rememberSaveable { mutableStateOf(languagePref.get() ?: "system") }
|
||||
LangSelector(state) {
|
||||
state.value = it
|
||||
withApi {
|
||||
delay(200)
|
||||
val activity = context as? Activity
|
||||
if (activity != null) {
|
||||
if (it == "system") {
|
||||
activity.saveAppLocale(languagePref)
|
||||
} else {
|
||||
activity.saveAppLocale(languagePref, it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// }
|
||||
}
|
||||
SectionDividerSpaced()
|
||||
|
||||
SectionView(stringResource(MR.strings.settings_section_title_icon), padding = PaddingValues(horizontal = DEFAULT_PADDING_HALF)) {
|
||||
LazyRow {
|
||||
items(AppIcon.values().size, { index -> AppIcon.values()[index] }) { index ->
|
||||
val item = AppIcon.values()[index]
|
||||
val mipmap = ContextCompat.getDrawable(LocalContext.current, item.resId)!!
|
||||
Image(
|
||||
bitmap = mipmap.toBitmap().asImageBitmap(),
|
||||
contentDescription = "",
|
||||
contentScale = ContentScale.Fit,
|
||||
modifier = Modifier
|
||||
.shadow(if (item == icon.value) 1.dp else 0.dp, ambientColor = colors.secondaryVariant)
|
||||
.size(70.dp)
|
||||
.clickable { changeIcon(item) }
|
||||
.padding(10.dp)
|
||||
)
|
||||
|
||||
if (index + 1 != AppIcon.values().size) {
|
||||
Spacer(Modifier.padding(horizontal = 4.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SectionDividerSpaced(maxTopPadding = true)
|
||||
ThemesSection(systemDarkTheme, showSettingsModal, editColor)
|
||||
SectionBottomSpacer()
|
||||
}
|
||||
}
|
||||
|
||||
private fun findEnabledIcon(): AppIcon = AppIcon.values().first { icon ->
|
||||
androidAppContext.packageManager.getComponentEnabledSetting(
|
||||
ComponentName(APPLICATION_ID, "chat.simplex.app.MainActivity_${icon.name.lowercase()}")
|
||||
).let { it == COMPONENT_ENABLED_STATE_DEFAULT || it == COMPONENT_ENABLED_STATE_ENABLED }
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun PreviewAppearanceSettings() {
|
||||
SimpleXTheme {
|
||||
AppearanceScope.AppearanceLayout(
|
||||
icon = remember { mutableStateOf(AppIcon.DARK_BLUE) },
|
||||
languagePref = SharedPreference({ null }, {}),
|
||||
systemDarkTheme = SharedPreference({ null }, {}),
|
||||
changeIcon = {},
|
||||
showSettingsModal = { {} },
|
||||
editColor = { _, _ -> },
|
||||
)
|
||||
}
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
package chat.simplex.common.views.usersettings
|
||||
|
||||
import SectionView
|
||||
import android.view.WindowManager
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import chat.simplex.common.model.ChatModel
|
||||
import chat.simplex.res.MR
|
||||
import dev.icerock.moko.resources.compose.painterResource
|
||||
import dev.icerock.moko.resources.compose.stringResource
|
||||
|
||||
@Composable
|
||||
actual fun PrivacyDeviceSection(
|
||||
showSettingsModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit),
|
||||
setPerformLA: (Boolean) -> Unit,
|
||||
) {
|
||||
SectionView(stringResource(MR.strings.settings_section_title_device)) {
|
||||
ChatLockItem(showSettingsModal, setPerformLA)
|
||||
val context = LocalContext.current
|
||||
SettingsPreferenceItem(painterResource(MR.images.ic_visibility_off), stringResource(MR.strings.protect_app_screen), ChatModel.controller.appPrefs.privacyProtectScreen) { on ->
|
||||
if (on) {
|
||||
(context as? FragmentActivity)?.window?.setFlags(
|
||||
WindowManager.LayoutParams.FLAG_SECURE,
|
||||
WindowManager.LayoutParams.FLAG_SECURE
|
||||
)
|
||||
} else {
|
||||
(context as? FragmentActivity)?.window?.clearFlags(WindowManager.LayoutParams.FLAG_SECURE)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
package chat.simplex.common.views.usersettings
|
||||
|
||||
import android.Manifest
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import chat.simplex.common.model.ServerCfg
|
||||
import com.google.accompanist.permissions.rememberPermissionState
|
||||
|
||||
@Composable
|
||||
actual fun ScanProtocolServer(onNext: (ServerCfg) -> Unit) {
|
||||
val cameraPermissionState = rememberPermissionState(permission = Manifest.permission.CAMERA)
|
||||
LaunchedEffect(Unit) {
|
||||
cameraPermissionState.launchPermissionRequest()
|
||||
}
|
||||
ScanProtocolServerLayout(onNext)
|
||||
}
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
package chat.simplex.common.views.usersettings
|
||||
|
||||
import SectionView
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.work.WorkManager
|
||||
import chat.simplex.common.model.ChatModel
|
||||
import chat.simplex.common.platform.*
|
||||
import chat.simplex.common.views.helpers.AlertManager
|
||||
import chat.simplex.common.views.helpers.generalGetString
|
||||
import chat.simplex.res.MR
|
||||
import com.jakewharton.processphoenix.ProcessPhoenix
|
||||
import dev.icerock.moko.resources.compose.painterResource
|
||||
import dev.icerock.moko.resources.compose.stringResource
|
||||
|
||||
@Composable
|
||||
actual fun SettingsSectionApp(
|
||||
showSettingsModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit),
|
||||
showCustomModal: (@Composable (ChatModel, () -> Unit) -> Unit) -> (() -> Unit),
|
||||
showVersion: () -> Unit,
|
||||
withAuth: (title: String, desc: String, block: () -> Unit) -> Unit
|
||||
) {
|
||||
SectionView(stringResource(MR.strings.settings_section_title_app)) {
|
||||
SettingsActionItem(painterResource(MR.images.ic_restart_alt), stringResource(MR.strings.settings_restart_app), ::restartApp, extraPadding = true)
|
||||
SettingsActionItem(painterResource(MR.images.ic_power_settings_new), stringResource(MR.strings.settings_shutdown), { shutdownAppAlert(::shutdownApp) }, extraPadding = true)
|
||||
SettingsActionItem(painterResource(MR.images.ic_code), stringResource(MR.strings.settings_developer_tools), showSettingsModal { DeveloperView(it, showCustomModal, withAuth) }, extraPadding = true)
|
||||
AppVersionItem(showVersion)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private fun restartApp() {
|
||||
ProcessPhoenix.triggerRebirth(androidAppContext)
|
||||
shutdownApp()
|
||||
}
|
||||
|
||||
private fun shutdownApp() {
|
||||
WorkManager.getInstance(androidAppContext).cancelAllWork()
|
||||
platform.androidServiceSafeStop()
|
||||
Runtime.getRuntime().exit(0)
|
||||
}
|
||||
|
||||
private fun shutdownAppAlert(onConfirm: () -> Unit) {
|
||||
AlertManager.shared.showAlertDialog(
|
||||
title = generalGetString(MR.strings.shutdown_alert_question),
|
||||
text = generalGetString(MR.strings.shutdown_alert_desc),
|
||||
destructive = true,
|
||||
onConfirm = onConfirm
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,7 @@
|
||||
#include <jni.h>
|
||||
//#include <string.h>
|
||||
//#include <stdlib.h>
|
||||
//#include <android/log.h>
|
||||
|
||||
// from the RTS
|
||||
void hs_init(int * argc, char **argv[]);
|
||||
@@ -19,7 +22,7 @@ extern void __rel_iplt_start(void){};
|
||||
extern void reallocarray(void){};
|
||||
|
||||
JNIEXPORT jint JNICALL
|
||||
Java_chat_simplex_app_SimplexAppKt_pipeStdOutToSocket(JNIEnv *env, __unused jclass clazz, jstring socket_name) {
|
||||
Java_chat_simplex_common_platform_CoreKt_pipeStdOutToSocket(JNIEnv *env, __unused jclass clazz, jstring socket_name) {
|
||||
const char *name = (*env)->GetStringUTFChars(env, socket_name, JNI_FALSE);
|
||||
int ret = pipe_std_to_socket(name);
|
||||
(*env)->ReleaseStringUTFChars(env, socket_name, name);
|
||||
@@ -27,7 +30,7 @@ Java_chat_simplex_app_SimplexAppKt_pipeStdOutToSocket(JNIEnv *env, __unused jcla
|
||||
}
|
||||
|
||||
JNIEXPORT void JNICALL
|
||||
Java_chat_simplex_app_SimplexAppKt_initHS(__unused JNIEnv *env, __unused jclass clazz) {
|
||||
Java_chat_simplex_common_platform_CoreKt_initHS(__unused JNIEnv *env, __unused jclass clazz) {
|
||||
hs_init(NULL, NULL);
|
||||
setLineBuffering();
|
||||
}
|
||||
@@ -44,7 +47,7 @@ extern char *chat_parse_server(const char *str);
|
||||
extern char *chat_password_hash(const char *pwd, const char *salt);
|
||||
|
||||
JNIEXPORT jobjectArray JNICALL
|
||||
Java_chat_simplex_app_SimplexAppKt_chatMigrateInit(JNIEnv *env, __unused jclass clazz, jstring dbPath, jstring dbKey, jstring confirm) {
|
||||
Java_chat_simplex_common_platform_CoreKt_chatMigrateInit(JNIEnv *env, __unused jclass clazz, jstring dbPath, jstring dbKey, jstring confirm) {
|
||||
const char *_dbPath = (*env)->GetStringUTFChars(env, dbPath, JNI_FALSE);
|
||||
const char *_dbKey = (*env)->GetStringUTFChars(env, dbKey, JNI_FALSE);
|
||||
const char *_confirm = (*env)->GetStringUTFChars(env, confirm, JNI_FALSE);
|
||||
@@ -67,25 +70,28 @@ Java_chat_simplex_app_SimplexAppKt_chatMigrateInit(JNIEnv *env, __unused jclass
|
||||
}
|
||||
|
||||
JNIEXPORT jstring JNICALL
|
||||
Java_chat_simplex_app_SimplexAppKt_chatSendCmd(JNIEnv *env, __unused jclass clazz, jlong controller, jstring msg) {
|
||||
Java_chat_simplex_common_platform_CoreKt_chatSendCmd(JNIEnv *env, __unused jclass clazz, jlong controller, jstring msg) {
|
||||
const char *_msg = (*env)->GetStringUTFChars(env, msg, JNI_FALSE);
|
||||
//jint length = (jint) (*env)->GetStringUTFLength(env, msg);
|
||||
//for (int i = 0; i < length; ++i)
|
||||
// __android_log_print(ANDROID_LOG_ERROR, "simplex", "%d: %02x\n", i, _msg[i]);
|
||||
jstring res = (*env)->NewStringUTF(env, chat_send_cmd((void*)controller, _msg));
|
||||
(*env)->ReleaseStringUTFChars(env, msg, _msg);
|
||||
return res;
|
||||
}
|
||||
|
||||
JNIEXPORT jstring JNICALL
|
||||
Java_chat_simplex_app_SimplexAppKt_chatRecvMsg(JNIEnv *env, __unused jclass clazz, jlong controller) {
|
||||
Java_chat_simplex_common_platform_CoreKt_chatRecvMsg(JNIEnv *env, __unused jclass clazz, jlong controller) {
|
||||
return (*env)->NewStringUTF(env, chat_recv_msg((void*)controller));
|
||||
}
|
||||
|
||||
JNIEXPORT jstring JNICALL
|
||||
Java_chat_simplex_app_SimplexAppKt_chatRecvMsgWait(JNIEnv *env, __unused jclass clazz, jlong controller, jint wait) {
|
||||
Java_chat_simplex_common_platform_CoreKt_chatRecvMsgWait(JNIEnv *env, __unused jclass clazz, jlong controller, jint wait) {
|
||||
return (*env)->NewStringUTF(env, chat_recv_msg_wait((void*)controller, wait));
|
||||
}
|
||||
|
||||
JNIEXPORT jstring JNICALL
|
||||
Java_chat_simplex_app_SimplexAppKt_chatParseMarkdown(JNIEnv *env, __unused jclass clazz, jstring str) {
|
||||
Java_chat_simplex_common_platform_CoreKt_chatParseMarkdown(JNIEnv *env, __unused jclass clazz, jstring str) {
|
||||
const char *_str = (*env)->GetStringUTFChars(env, str, JNI_FALSE);
|
||||
jstring res = (*env)->NewStringUTF(env, chat_parse_markdown(_str));
|
||||
(*env)->ReleaseStringUTFChars(env, str, _str);
|
||||
@@ -93,7 +99,7 @@ Java_chat_simplex_app_SimplexAppKt_chatParseMarkdown(JNIEnv *env, __unused jclas
|
||||
}
|
||||
|
||||
JNIEXPORT jstring JNICALL
|
||||
Java_chat_simplex_app_SimplexAppKt_chatParseServer(JNIEnv *env, __unused jclass clazz, jstring str) {
|
||||
Java_chat_simplex_common_platform_CoreKt_chatParseServer(JNIEnv *env, __unused jclass clazz, jstring str) {
|
||||
const char *_str = (*env)->GetStringUTFChars(env, str, JNI_FALSE);
|
||||
jstring res = (*env)->NewStringUTF(env, chat_parse_server(_str));
|
||||
(*env)->ReleaseStringUTFChars(env, str, _str);
|
||||
@@ -101,7 +107,7 @@ Java_chat_simplex_app_SimplexAppKt_chatParseServer(JNIEnv *env, __unused jclass
|
||||
}
|
||||
|
||||
JNIEXPORT jstring JNICALL
|
||||
Java_chat_simplex_app_SimplexAppKt_chatPasswordHash(JNIEnv *env, __unused jclass clazz, jstring pwd, jstring salt) {
|
||||
Java_chat_simplex_common_platform_CoreKt_chatPasswordHash(JNIEnv *env, __unused jclass clazz, jstring pwd, jstring salt) {
|
||||
const char *_pwd = (*env)->GetStringUTFChars(env, pwd, JNI_FALSE);
|
||||
const char *_salt = (*env)->GetStringUTFChars(env, salt, JNI_FALSE);
|
||||
jstring res = (*env)->NewStringUTF(env, chat_password_hash(_pwd, _salt));
|
||||
|
||||
@@ -19,6 +19,8 @@ project("app")
|
||||
if(UNIX AND NOT APPLE)
|
||||
set(OS_LIB_PATH "linux")
|
||||
set(OS_LIB_EXT "so")
|
||||
# Makes ld search libs in the same dir as libapp-lib, not in system dirs
|
||||
set(CMAKE_BUILD_RPATH "$ORIGIN")
|
||||
elseif(WIN32)
|
||||
set(OS_LIB_PATH "windows")
|
||||
set(OS_LIB_EXT "dll")
|
||||
@@ -37,9 +39,6 @@ else()
|
||||
set(OS_LIB_ARCH "${CMAKE_SYSTEM_PROCESSOR}")
|
||||
endif()
|
||||
|
||||
# Makes ld search libs in the same dir as libapp-lib, not in system dirs
|
||||
#set(CMAKE_BUILD_RPATH "$ORIGIN")
|
||||
|
||||
# Creates and names a library, sets it as either STATIC
|
||||
# or SHARED, and provides the relative paths to its source code.
|
||||
# You can define multiple libraries, and CMake builds them for you.
|
||||
|
||||
@@ -1,21 +1,12 @@
|
||||
#include <jni.h>
|
||||
#include <string.h>
|
||||
#include <stdlib.h>
|
||||
|
||||
// from the RTS
|
||||
void hs_init(int * argc, char **argv[]);
|
||||
|
||||
//extern void __svfscanf(void){};
|
||||
//extern void __vfwscanf(void){};
|
||||
//extern void __memset_chk_fail(void){};
|
||||
//extern void __strcpy_chk_generic(void){};
|
||||
//extern void __strcat_chk_generic(void){};
|
||||
//extern void __libc_globals(void){};
|
||||
//extern void __rel_iplt_start(void){};
|
||||
|
||||
// Android 9 only, not 13
|
||||
//extern void reallocarray(void){};
|
||||
|
||||
JNIEXPORT void JNICALL
|
||||
Java_chat_simplex_common_platform_BackendKt_initHS(JNIEnv *env, jclass clazz) {
|
||||
Java_chat_simplex_common_platform_CoreKt_initHS(JNIEnv *env, jclass clazz) {
|
||||
hs_init(NULL, NULL);
|
||||
}
|
||||
|
||||
@@ -30,13 +21,54 @@ extern char *chat_parse_markdown(const char *str);
|
||||
extern char *chat_parse_server(const char *str);
|
||||
extern char *chat_password_hash(const char *pwd, const char *salt);
|
||||
|
||||
|
||||
// As a reference: https://stackoverflow.com/a/60002045
|
||||
jstring decode_to_utf8_string(JNIEnv *env, char *string) {
|
||||
jobject bb = (*env)->NewDirectByteBuffer(env, (void *)string, strlen(string));
|
||||
jclass cls_charset = (*env)->FindClass(env, "java/nio/charset/Charset");
|
||||
jmethodID mid_charset_forName = (*env)->GetStaticMethodID(env, cls_charset, "forName", "(Ljava/lang/String;)Ljava/nio/charset/Charset;");
|
||||
jobject charset = (*env)->CallStaticObjectMethod(env, cls_charset, mid_charset_forName, (*env)->NewStringUTF(env, "UTF-8"));
|
||||
|
||||
jmethodID mid_decode = (*env)->GetMethodID(env, cls_charset, "decode", "(Ljava/nio/ByteBuffer;)Ljava/nio/CharBuffer;");
|
||||
jobject cb = (*env)->CallObjectMethod(env, charset, mid_decode, bb);
|
||||
|
||||
jclass cls_char_buffer = (*env)->FindClass(env, "java/nio/CharBuffer");
|
||||
jmethodID mid_to_string = (*env)->GetMethodID(env, cls_char_buffer, "toString", "()Ljava/lang/String;");
|
||||
jstring res = (*env)->CallObjectMethod(env, cb, mid_to_string);
|
||||
|
||||
(*env)->DeleteLocalRef(env, bb);
|
||||
(*env)->DeleteLocalRef(env, charset);
|
||||
(*env)->DeleteLocalRef(env, cb);
|
||||
return res;
|
||||
}
|
||||
|
||||
char * encode_to_utf8_chars(JNIEnv *env, jstring string) {
|
||||
if (!string) return "";
|
||||
|
||||
const jclass cls_string = (*env)->FindClass(env, "java/lang/String");
|
||||
const jmethodID mid_getBytes = (*env)->GetMethodID(env, cls_string, "getBytes", "(Ljava/lang/String;)[B");
|
||||
const jbyteArray jbyte_array = (jbyteArray) (*env)->CallObjectMethod(env, string, mid_getBytes, (*env)->NewStringUTF(env, "UTF-8"));
|
||||
jint length = (jint) (*env)->GetArrayLength(env, jbyte_array);
|
||||
jbyte *jbytes = malloc(length + 1);
|
||||
(*env)->GetByteArrayRegion(env, jbyte_array, 0, length, jbytes);
|
||||
// char * should be null terminated but jbyte * isn't. Terminate it with \0. Otherwise, Haskell will not see the end of string
|
||||
jbytes[length] = '\0';
|
||||
|
||||
//for (int i = 0; i < length; ++i)
|
||||
// fprintf(stderr, "%d: %02x\n", i, jbytes[i]);
|
||||
|
||||
(*env)->DeleteLocalRef(env, jbyte_array);
|
||||
(*env)->DeleteLocalRef(env, cls_string);
|
||||
return (char *) jbytes;
|
||||
}
|
||||
|
||||
JNIEXPORT jobjectArray JNICALL
|
||||
Java_chat_simplex_common_platform_BackendKt_chatMigrateInit(JNIEnv *env, jclass clazz, jstring dbPath, jstring dbKey, jstring confirm) {
|
||||
const char *_dbPath = (*env)->GetStringUTFChars(env, dbPath, JNI_FALSE);
|
||||
const char *_dbKey = (*env)->GetStringUTFChars(env, dbKey, JNI_FALSE);
|
||||
const char *_confirm = (*env)->GetStringUTFChars(env, confirm, JNI_FALSE);
|
||||
jlong _ctrl = (jlong) 0;
|
||||
jstring res = (*env)->NewStringUTF(env, chat_migrate_init(_dbPath, _dbKey, _confirm, &_ctrl));
|
||||
Java_chat_simplex_common_platform_CoreKt_chatMigrateInit(JNIEnv *env, jclass clazz, jstring dbPath, jstring dbKey, jstring confirm) {
|
||||
const char *_dbPath = encode_to_utf8_chars(env, dbPath);
|
||||
const char *_dbKey = encode_to_utf8_chars(env, dbKey);
|
||||
const char *_confirm = encode_to_utf8_chars(env, confirm);
|
||||
long int *_ctrl = (long) 0;
|
||||
jstring res = decode_to_utf8_string(env, chat_migrate_init(_dbPath, _dbKey, _confirm, &_ctrl));
|
||||
(*env)->ReleaseStringUTFChars(env, dbPath, _dbPath);
|
||||
(*env)->ReleaseStringUTFChars(env, dbKey, _dbKey);
|
||||
(*env)->ReleaseStringUTFChars(env, dbKey, _confirm);
|
||||
@@ -54,44 +86,44 @@ Java_chat_simplex_common_platform_BackendKt_chatMigrateInit(JNIEnv *env, jclass
|
||||
}
|
||||
|
||||
JNIEXPORT jstring JNICALL
|
||||
Java_chat_simplex_common_platform_BackendKt_chatSendCmd(JNIEnv *env, jclass clazz, jlong controller, jstring msg) {
|
||||
const char *_msg = (*env)->GetStringUTFChars(env, msg, JNI_FALSE);
|
||||
jstring res = (*env)->NewStringUTF(env, chat_send_cmd((void*)controller, _msg));
|
||||
Java_chat_simplex_common_platform_CoreKt_chatSendCmd(JNIEnv *env, jclass clazz, jlong controller, jstring msg) {
|
||||
const char *_msg = encode_to_utf8_chars(env, msg);
|
||||
jstring res = decode_to_utf8_string(env, chat_send_cmd((void*)controller, _msg));
|
||||
(*env)->ReleaseStringUTFChars(env, msg, _msg);
|
||||
return res;
|
||||
}
|
||||
|
||||
JNIEXPORT jstring JNICALL
|
||||
Java_chat_simplex_common_platform_BackendKt_chatRecvMsg(JNIEnv *env, jclass clazz, jlong controller) {
|
||||
return (*env)->NewStringUTF(env, chat_recv_msg((void*)controller));
|
||||
Java_chat_simplex_common_platform_CoreKt_chatRecvMsg(JNIEnv *env, jclass clazz, jlong controller) {
|
||||
return decode_to_utf8_string(env, chat_recv_msg((void*)controller));
|
||||
}
|
||||
|
||||
JNIEXPORT jstring JNICALL
|
||||
Java_chat_simplex_common_platform_BackendKt_chatRecvMsgWait(JNIEnv *env, jclass clazz, jlong controller, jint wait) {
|
||||
return (*env)->NewStringUTF(env, chat_recv_msg_wait((void*)controller, wait));
|
||||
Java_chat_simplex_common_platform_CoreKt_chatRecvMsgWait(JNIEnv *env, jclass clazz, jlong controller, jint wait) {
|
||||
return decode_to_utf8_string(env, chat_recv_msg_wait((void*)controller, wait));
|
||||
}
|
||||
|
||||
JNIEXPORT jstring JNICALL
|
||||
Java_chat_simplex_common_platform_BackendKt_chatParseMarkdown(JNIEnv *env, jclass clazz, jstring str) {
|
||||
const char *_str = (*env)->GetStringUTFChars(env, str, JNI_FALSE);
|
||||
jstring res = (*env)->NewStringUTF(env, chat_parse_markdown(_str));
|
||||
Java_chat_simplex_common_platform_CoreKt_chatParseMarkdown(JNIEnv *env, jclass clazz, jstring str) {
|
||||
const char *_str = encode_to_utf8_chars(env, str);
|
||||
jstring res = decode_to_utf8_string(env, chat_parse_markdown(_str));
|
||||
(*env)->ReleaseStringUTFChars(env, str, _str);
|
||||
return res;
|
||||
}
|
||||
|
||||
JNIEXPORT jstring JNICALL
|
||||
Java_chat_simplex_common_platform_BackendKt_chatParseServer(JNIEnv *env, jclass clazz, jstring str) {
|
||||
const char *_str = (*env)->GetStringUTFChars(env, str, JNI_FALSE);
|
||||
jstring res = (*env)->NewStringUTF(env, chat_parse_server(_str));
|
||||
Java_chat_simplex_common_platform_CoreKt_chatParseServer(JNIEnv *env, jclass clazz, jstring str) {
|
||||
const char *_str = encode_to_utf8_chars(env, str);
|
||||
jstring res = decode_to_utf8_string(env, chat_parse_server(_str));
|
||||
(*env)->ReleaseStringUTFChars(env, str, _str);
|
||||
return res;
|
||||
}
|
||||
|
||||
JNIEXPORT jstring JNICALL
|
||||
Java_chat_simplex_common_platform_BackendKt_chatPasswordHash(JNIEnv *env, jclass clazz, jstring pwd, jstring salt) {
|
||||
const char *_pwd = (*env)->GetStringUTFChars(env, pwd, JNI_FALSE);
|
||||
const char *_salt = (*env)->GetStringUTFChars(env, salt, JNI_FALSE);
|
||||
jstring res = (*env)->NewStringUTF(env, chat_password_hash(_pwd, _salt));
|
||||
Java_chat_simplex_common_platform_CoreKt_chatPasswordHash(JNIEnv *env, jclass clazz, jstring pwd, jstring salt) {
|
||||
const char *_pwd = encode_to_utf8_chars(env, pwd);
|
||||
const char *_salt = encode_to_utf8_chars(env, salt);
|
||||
jstring res = decode_to_utf8_string(env, chat_password_hash(_pwd, _salt));
|
||||
(*env)->ReleaseStringUTFChars(env, pwd, _pwd);
|
||||
(*env)->ReleaseStringUTFChars(env, salt, _salt);
|
||||
return res;
|
||||
|
||||
@@ -0,0 +1,332 @@
|
||||
package chat.simplex.common
|
||||
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.unit.dp
|
||||
import chat.simplex.common.views.usersettings.SetDeliveryReceiptsView
|
||||
import chat.simplex.common.model.*
|
||||
import chat.simplex.common.platform.*
|
||||
import chat.simplex.common.ui.theme.*
|
||||
import chat.simplex.common.views.helpers.SimpleButton
|
||||
import chat.simplex.common.views.SplashView
|
||||
import chat.simplex.common.views.call.ActiveCallView
|
||||
import chat.simplex.common.views.call.IncomingCallAlertView
|
||||
import chat.simplex.common.views.chat.ChatView
|
||||
import chat.simplex.common.views.chatlist.*
|
||||
import chat.simplex.common.views.database.DatabaseErrorView
|
||||
import chat.simplex.common.views.helpers.*
|
||||
import chat.simplex.common.views.localauth.VerticalDivider
|
||||
import chat.simplex.common.views.onboarding.*
|
||||
import chat.simplex.common.views.usersettings.*
|
||||
import chat.simplex.res.MR
|
||||
import dev.icerock.moko.resources.compose.painterResource
|
||||
import dev.icerock.moko.resources.compose.stringResource
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
|
||||
data class SettingsViewState(
|
||||
val userPickerState: MutableStateFlow<AnimatedViewState>,
|
||||
val scaffoldState: ScaffoldState,
|
||||
val switchingUsers: MutableState<Boolean>
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun AppScreen() {
|
||||
ProvideWindowInsets(windowInsetsAnimationsEnabled = true) {
|
||||
Surface(color = MaterialTheme.colors.background) {
|
||||
MainScreen()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MainScreen() {
|
||||
val chatModel = ChatModel
|
||||
var showChatDatabaseError by rememberSaveable {
|
||||
mutableStateOf(chatModel.chatDbStatus.value != DBMigrationResult.OK && chatModel.chatDbStatus.value != null)
|
||||
}
|
||||
LaunchedEffect(chatModel.chatDbStatus.value) {
|
||||
showChatDatabaseError = chatModel.chatDbStatus.value != DBMigrationResult.OK && chatModel.chatDbStatus.value != null
|
||||
}
|
||||
var showAdvertiseLAAlert by remember { mutableStateOf(false) }
|
||||
LaunchedEffect(showAdvertiseLAAlert) {
|
||||
if (
|
||||
!chatModel.controller.appPrefs.laNoticeShown.get()
|
||||
&& showAdvertiseLAAlert
|
||||
&& chatModel.onboardingStage.value == OnboardingStage.OnboardingComplete
|
||||
&& chatModel.chats.isNotEmpty()
|
||||
&& chatModel.activeCallInvitation.value == null
|
||||
) {
|
||||
AppLock.showLANotice(ChatModel.controller.appPrefs.laNoticeShown) }
|
||||
}
|
||||
LaunchedEffect(chatModel.showAdvertiseLAUnavailableAlert.value) {
|
||||
if (chatModel.showAdvertiseLAUnavailableAlert.value) {
|
||||
laUnavailableInstructionAlert()
|
||||
}
|
||||
}
|
||||
LaunchedEffect(chatModel.clearOverlays.value) {
|
||||
if (chatModel.clearOverlays.value) {
|
||||
ModalManager.closeAllModalsEverywhere()
|
||||
chatModel.clearOverlays.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AuthView() {
|
||||
Surface(color = MaterialTheme.colors.background) {
|
||||
Box(
|
||||
Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
SimpleButton(
|
||||
stringResource(MR.strings.auth_unlock),
|
||||
icon = painterResource(MR.images.ic_lock),
|
||||
click = {
|
||||
AppLock.laFailed.value = false
|
||||
AppLock.runAuthenticate()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Box {
|
||||
val onboarding = chatModel.onboardingStage.value
|
||||
val userCreated = chatModel.userCreated.value
|
||||
var showInitializationView by remember { mutableStateOf(false) }
|
||||
when {
|
||||
chatModel.chatDbStatus.value == null && showInitializationView -> InitializationView()
|
||||
showChatDatabaseError -> {
|
||||
chatModel.chatDbStatus.value?.let {
|
||||
DatabaseErrorView(chatModel.chatDbStatus, chatModel.controller.appPrefs)
|
||||
}
|
||||
}
|
||||
onboarding == null || userCreated == null -> SplashView()
|
||||
onboarding == OnboardingStage.OnboardingComplete && userCreated -> {
|
||||
Box {
|
||||
showAdvertiseLAAlert = true
|
||||
val userPickerState by rememberSaveable(stateSaver = AnimatedViewState.saver()) { mutableStateOf(MutableStateFlow(AnimatedViewState.GONE)) }
|
||||
val scaffoldState = rememberScaffoldState()
|
||||
val switchingUsers = rememberSaveable { mutableStateOf(false) }
|
||||
val settingsState = remember { SettingsViewState(userPickerState, scaffoldState, switchingUsers) }
|
||||
if (appPlatform.isAndroid) {
|
||||
AndroidScreen(settingsState)
|
||||
} else {
|
||||
DesktopScreen(settingsState)
|
||||
}
|
||||
}
|
||||
}
|
||||
onboarding == OnboardingStage.Step1_SimpleXInfo -> {
|
||||
SimpleXInfo(chatModel, onboarding = true)
|
||||
if (appPlatform.isDesktop) {
|
||||
ModalManager.fullscreen.showInView()
|
||||
}
|
||||
}
|
||||
onboarding == OnboardingStage.Step2_CreateProfile -> CreateProfile(chatModel) {}
|
||||
onboarding == OnboardingStage.Step3_CreateSimpleXAddress -> CreateSimpleXAddress(chatModel)
|
||||
onboarding == OnboardingStage.Step4_SetNotificationsMode -> SetNotificationsMode(chatModel)
|
||||
}
|
||||
if (appPlatform.isAndroid) {
|
||||
ModalManager.fullscreen.showInView()
|
||||
}
|
||||
|
||||
val unauthorized = remember { derivedStateOf { AppLock.userAuthorized.value != true } }
|
||||
if (unauthorized.value && !(chatModel.activeCallViewIsVisible.value && chatModel.showCallView.value)) {
|
||||
LaunchedEffect(Unit) {
|
||||
// With these constrains when user presses back button while on ChatList, activity destroys and shows auth request
|
||||
// while the screen moves to a launcher. Detect it and prevent showing the auth
|
||||
if (!(AppLock.destroyedAfterBackPress.value && chatModel.controller.appPrefs.laMode.get() == LAMode.SYSTEM)) {
|
||||
AppLock.runAuthenticate()
|
||||
}
|
||||
}
|
||||
if (chatModel.controller.appPrefs.performLA.get() && AppLock.laFailed.value) {
|
||||
AuthView()
|
||||
} else {
|
||||
SplashView()
|
||||
}
|
||||
} else if (chatModel.showCallView.value) {
|
||||
ActiveCallView()
|
||||
}
|
||||
ModalManager.fullscreen.showPasscodeInView()
|
||||
val invitation = chatModel.activeCallInvitation.value
|
||||
if (invitation != null) IncomingCallAlertView(invitation, chatModel)
|
||||
AlertManager.shared.showInView()
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
delay(1000)
|
||||
if (chatModel.chatDbStatus.value == null) {
|
||||
showInitializationView = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DisposableEffectOnRotate {
|
||||
// When using lock delay = 0 and screen rotates, the app will be locked which is not useful.
|
||||
// Let's prolong the unlocked period to 3 sec for screen rotation to take place
|
||||
if (chatModel.controller.appPrefs.laLockDelay.get() == 0) {
|
||||
AppLock.enteredBackground.value = AppLock.elapsedRealtime() + 3000
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AndroidScreen(settingsState: SettingsViewState) {
|
||||
BoxWithConstraints {
|
||||
var currentChatId by rememberSaveable { mutableStateOf(chatModel.chatId.value) }
|
||||
val offset = remember { Animatable(if (chatModel.chatId.value == null) 0f else maxWidth.value) }
|
||||
Box(
|
||||
Modifier
|
||||
.graphicsLayer {
|
||||
translationX = -offset.value.dp.toPx()
|
||||
}
|
||||
) {
|
||||
StartPartOfScreen(settingsState)
|
||||
}
|
||||
val scope = rememberCoroutineScope()
|
||||
val onComposed: () -> Unit = {
|
||||
scope.launch {
|
||||
offset.animateTo(
|
||||
if (chatModel.chatId.value == null) 0f else maxWidth.value,
|
||||
chatListAnimationSpec()
|
||||
)
|
||||
if (offset.value == 0f) {
|
||||
currentChatId = null
|
||||
}
|
||||
}
|
||||
}
|
||||
LaunchedEffect(Unit) {
|
||||
launch {
|
||||
snapshotFlow { chatModel.chatId.value }
|
||||
.distinctUntilChanged()
|
||||
.collect {
|
||||
if (it != null) currentChatId = it
|
||||
else onComposed()
|
||||
}
|
||||
}
|
||||
}
|
||||
Box(Modifier.graphicsLayer { translationX = maxWidth.toPx() - offset.value.dp.toPx() }) Box2@{
|
||||
currentChatId?.let {
|
||||
ChatView(it, chatModel, onComposed)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun StartPartOfScreen(settingsState: SettingsViewState) {
|
||||
if (chatModel.setDeliveryReceipts.value) {
|
||||
SetDeliveryReceiptsView(chatModel)
|
||||
} else {
|
||||
val stopped = chatModel.chatRunning.value == false
|
||||
if (chatModel.sharedContent.value == null)
|
||||
ChatListView(chatModel, settingsState, AppLock::setPerformLA, stopped)
|
||||
else
|
||||
ShareListView(chatModel, settingsState, stopped)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CenterPartOfScreen() {
|
||||
val currentChatId by remember { ChatModel.chatId }
|
||||
LaunchedEffect(Unit) {
|
||||
snapshotFlow { currentChatId }
|
||||
.distinctUntilChanged()
|
||||
.collect {
|
||||
if (it != null) {
|
||||
ModalManager.center.closeModals()
|
||||
}
|
||||
}
|
||||
}
|
||||
when (val id = currentChatId) {
|
||||
null -> {
|
||||
if (!rememberUpdatedState(ModalManager.center.hasModalsOpen()).value) {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colors.background),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(stringResource(MR.strings.no_selected_chat))
|
||||
}
|
||||
} else {
|
||||
ModalManager.center.showInView()
|
||||
}
|
||||
}
|
||||
else -> ChatView(id, chatModel) {}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun EndPartOfScreen() {
|
||||
ModalManager.end.showInView()
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DesktopScreen(settingsState: SettingsViewState) {
|
||||
Box {
|
||||
// 56.dp is a size of unused space of settings drawer
|
||||
Box(Modifier.width(DEFAULT_START_MODAL_WIDTH + 56.dp)) {
|
||||
StartPartOfScreen(settingsState)
|
||||
}
|
||||
Box(Modifier.widthIn(max = DEFAULT_START_MODAL_WIDTH)) {
|
||||
ModalManager.start.showInView()
|
||||
}
|
||||
Row(Modifier.padding(start = DEFAULT_START_MODAL_WIDTH).clipToBounds()) {
|
||||
Box(Modifier.widthIn(min = DEFAULT_MIN_CENTER_MODAL_WIDTH).weight(1f)) {
|
||||
CenterPartOfScreen()
|
||||
}
|
||||
if (ModalManager.end.hasModalsOpen()) {
|
||||
VerticalDivider()
|
||||
}
|
||||
Box(Modifier.widthIn(max = DEFAULT_END_MODAL_WIDTH).clipToBounds()) {
|
||||
EndPartOfScreen()
|
||||
}
|
||||
}
|
||||
val (userPickerState, scaffoldState, switchingUsers ) = settingsState
|
||||
val scope = rememberCoroutineScope()
|
||||
if (scaffoldState.drawerState.isOpen) {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(start = DEFAULT_START_MODAL_WIDTH)
|
||||
.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = {
|
||||
ModalManager.start.closeModals()
|
||||
scope.launch { settingsState.scaffoldState.drawerState.close() }
|
||||
})
|
||||
)
|
||||
}
|
||||
VerticalDivider(Modifier.padding(start = DEFAULT_START_MODAL_WIDTH))
|
||||
UserPicker(chatModel, userPickerState, switchingUsers) {
|
||||
scope.launch { if (scaffoldState.drawerState.isOpen) scaffoldState.drawerState.close() else scaffoldState.drawerState.open() }
|
||||
}
|
||||
ModalManager.fullscreen.showInView()
|
||||
ModalManager.fullscreen.showPasscodeInView()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun InitializationView() {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
CircularProgressIndicator(
|
||||
Modifier
|
||||
.padding(bottom = DEFAULT_PADDING)
|
||||
.size(30.dp),
|
||||
color = MaterialTheme.colors.secondary,
|
||||
strokeWidth = 2.5.dp
|
||||
)
|
||||
Text(stringResource(MR.strings.opening_database))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
package chat.simplex.common
|
||||
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Surface
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.ui.Modifier
|
||||
import chat.simplex.common.model.*
|
||||
import chat.simplex.common.platform.*
|
||||
import chat.simplex.common.views.helpers.*
|
||||
import chat.simplex.common.views.localauth.SetAppPasscodeView
|
||||
import chat.simplex.common.views.usersettings.*
|
||||
import chat.simplex.res.MR
|
||||
import kotlinx.coroutines.*
|
||||
|
||||
object AppLock {
|
||||
/**
|
||||
* We don't want these values to be bound to Activity lifecycle since activities are changed often, for example, when a user
|
||||
* clicks on new message in notification. In this case savedInstanceState will be null (this prevents restoring the values)
|
||||
* See [SimplexService.onTaskRemoved] for another part of the logic which nullifies the values when app closed by the user
|
||||
* */
|
||||
val userAuthorized = mutableStateOf<Boolean?>(null)
|
||||
val enteredBackground = mutableStateOf<Long?>(null)
|
||||
|
||||
// Remember result and show it after orientation change
|
||||
val laFailed = mutableStateOf(false)
|
||||
val destroyedAfterBackPress = mutableStateOf(false)
|
||||
|
||||
fun clearAuthState() {
|
||||
userAuthorized.value = null
|
||||
enteredBackground.value = null
|
||||
}
|
||||
|
||||
fun showLANotice(laNoticeShown: SharedPreference<Boolean>) {
|
||||
Log.d(TAG, "showLANotice")
|
||||
if (!laNoticeShown.get()) {
|
||||
laNoticeShown.set(true)
|
||||
AlertManager.shared.showAlertDialog(
|
||||
title = generalGetString(MR.strings.la_notice_title_simplex_lock),
|
||||
text = generalGetString(MR.strings.la_notice_to_protect_your_information_turn_on_simplex_lock_you_will_be_prompted_to_complete_authentication_before_this_feature_is_enabled),
|
||||
confirmText = generalGetString(MR.strings.la_notice_turn_on),
|
||||
onConfirm = {
|
||||
laNoticeShown.set(true)
|
||||
withBGApi { // to remove this call, change ordering of onConfirm call in AlertManager
|
||||
if (appPlatform.isAndroid) {
|
||||
showChooseLAMode()
|
||||
} else {
|
||||
AlertManager.shared.hideAlert()
|
||||
setPasscode()
|
||||
}
|
||||
}
|
||||
},
|
||||
onDismiss = {
|
||||
AlertManager.shared.hideAlert()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun showChooseLAMode() {
|
||||
Log.d(TAG, "showLANotice")
|
||||
AlertManager.shared.showAlertDialogStacked(
|
||||
title = generalGetString(MR.strings.la_lock_mode),
|
||||
text = null,
|
||||
confirmText = generalGetString(MR.strings.la_lock_mode_passcode),
|
||||
dismissText = generalGetString(MR.strings.la_lock_mode_system),
|
||||
onConfirm = {
|
||||
AlertManager.shared.hideAlert()
|
||||
setPasscode()
|
||||
},
|
||||
onDismiss = {
|
||||
AlertManager.shared.hideAlert()
|
||||
initialEnableLA()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun initialEnableLA() {
|
||||
val m = ChatModel
|
||||
val appPrefs = ChatController.appPrefs
|
||||
appPrefs.laMode.set(LAMode.default)
|
||||
authenticate(
|
||||
generalGetString(MR.strings.auth_enable_simplex_lock),
|
||||
generalGetString(MR.strings.auth_confirm_credential),
|
||||
completed = { laResult ->
|
||||
when (laResult) {
|
||||
LAResult.Success -> {
|
||||
m.performLA.value = true
|
||||
appPrefs.performLA.set(true)
|
||||
laTurnedOnAlert()
|
||||
}
|
||||
is LAResult.Failed -> { /* Can be called multiple times on every failure */ }
|
||||
is LAResult.Error -> {
|
||||
m.performLA.value = false
|
||||
appPrefs.performLA.set(false)
|
||||
laFailedAlert()
|
||||
}
|
||||
is LAResult.Unavailable -> {
|
||||
m.performLA.value = false
|
||||
appPrefs.performLA.set(false)
|
||||
m.showAdvertiseLAUnavailableAlert.value = true
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun setPasscode() {
|
||||
val appPrefs = ChatController.appPrefs
|
||||
ModalManager.fullscreen.showCustomModal { close ->
|
||||
Surface(Modifier.fillMaxSize(), color = MaterialTheme.colors.background) {
|
||||
SetAppPasscodeView(
|
||||
submit = {
|
||||
ChatModel.performLA.value = true
|
||||
appPrefs.performLA.set(true)
|
||||
appPrefs.laMode.set(LAMode.PASSCODE)
|
||||
laTurnedOnAlert()
|
||||
},
|
||||
cancel = {
|
||||
ChatModel.performLA.value = false
|
||||
appPrefs.performLA.set(false)
|
||||
laPasscodeNotSetAlert()
|
||||
},
|
||||
close = close
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setAuthState() {
|
||||
userAuthorized.value = !ChatController.appPrefs.performLA.get()
|
||||
}
|
||||
|
||||
fun runAuthenticate() {
|
||||
val m = ChatModel
|
||||
setAuthState()
|
||||
if (userAuthorized.value == false) {
|
||||
// To make Main thread free in order to allow to Compose to show blank view that hiding content underneath of it faster on slow devices
|
||||
CoroutineScope(Dispatchers.Default).launch {
|
||||
delay(50)
|
||||
withContext(Dispatchers.Main) {
|
||||
authenticate(
|
||||
if (m.controller.appPrefs.laMode.get() == LAMode.SYSTEM)
|
||||
generalGetString(MR.strings.auth_unlock)
|
||||
else
|
||||
generalGetString(MR.strings.la_enter_app_passcode),
|
||||
if (m.controller.appPrefs.laMode.get() == LAMode.SYSTEM)
|
||||
generalGetString(MR.strings.auth_log_in_using_credential)
|
||||
else
|
||||
generalGetString(MR.strings.auth_unlock),
|
||||
selfDestruct = true,
|
||||
completed = { laResult ->
|
||||
when (laResult) {
|
||||
LAResult.Success ->
|
||||
userAuthorized.value = true
|
||||
is LAResult.Failed -> { /* Can be called multiple times on every failure */ }
|
||||
is LAResult.Error -> {
|
||||
laFailed.value = true
|
||||
if (m.controller.appPrefs.laMode.get() == LAMode.PASSCODE) {
|
||||
laFailedAlert()
|
||||
}
|
||||
}
|
||||
is LAResult.Unavailable -> {
|
||||
userAuthorized.value = true
|
||||
m.performLA.value = false
|
||||
m.controller.appPrefs.performLA.set(false)
|
||||
laUnavailableTurningOffAlert()
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setPerformLA(on: Boolean) {
|
||||
ChatController.appPrefs.laNoticeShown.set(true)
|
||||
if (on) {
|
||||
enableLA()
|
||||
} else {
|
||||
disableLA()
|
||||
}
|
||||
}
|
||||
|
||||
private fun enableLA() {
|
||||
val m = ChatModel
|
||||
authenticate(
|
||||
if (m.controller.appPrefs.laMode.get() == LAMode.SYSTEM)
|
||||
generalGetString(MR.strings.auth_enable_simplex_lock)
|
||||
else
|
||||
generalGetString(MR.strings.new_passcode),
|
||||
if (m.controller.appPrefs.laMode.get() == LAMode.SYSTEM)
|
||||
generalGetString(MR.strings.auth_confirm_credential)
|
||||
else
|
||||
"",
|
||||
completed = { laResult ->
|
||||
val prefPerformLA = m.controller.appPrefs.performLA
|
||||
when (laResult) {
|
||||
LAResult.Success -> {
|
||||
m.performLA.value = true
|
||||
prefPerformLA.set(true)
|
||||
laTurnedOnAlert()
|
||||
}
|
||||
is LAResult.Failed -> { /* Can be called multiple times on every failure */ }
|
||||
is LAResult.Error -> {
|
||||
m.performLA.value = false
|
||||
prefPerformLA.set(false)
|
||||
laFailedAlert()
|
||||
}
|
||||
is LAResult.Unavailable -> {
|
||||
m.performLA.value = false
|
||||
prefPerformLA.set(false)
|
||||
laUnavailableInstructionAlert()
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun disableLA() {
|
||||
val m = ChatModel
|
||||
authenticate(
|
||||
if (m.controller.appPrefs.laMode.get() == LAMode.SYSTEM)
|
||||
generalGetString(MR.strings.auth_disable_simplex_lock)
|
||||
else
|
||||
generalGetString(MR.strings.la_enter_app_passcode),
|
||||
if (m.controller.appPrefs.laMode.get() == LAMode.SYSTEM)
|
||||
generalGetString(MR.strings.auth_confirm_credential)
|
||||
else
|
||||
generalGetString(MR.strings.auth_disable_simplex_lock),
|
||||
completed = { laResult ->
|
||||
val prefPerformLA = m.controller.appPrefs.performLA
|
||||
val selfDestructPref = m.controller.appPrefs.selfDestruct
|
||||
when (laResult) {
|
||||
LAResult.Success -> {
|
||||
m.performLA.value = false
|
||||
prefPerformLA.set(false)
|
||||
DatabaseUtils.ksAppPassword.remove()
|
||||
selfDestructPref.set(false)
|
||||
DatabaseUtils.ksSelfDestructPassword.remove()
|
||||
}
|
||||
is LAResult.Failed -> { /* Can be called multiple times on every failure */ }
|
||||
is LAResult.Error -> {
|
||||
m.performLA.value = true
|
||||
prefPerformLA.set(true)
|
||||
laFailedAlert()
|
||||
}
|
||||
is LAResult.Unavailable -> {
|
||||
m.performLA.value = false
|
||||
prefPerformLA.set(false)
|
||||
laUnavailableTurningOffAlert()
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
fun elapsedRealtime(): Long = System.nanoTime() / 1_000_000
|
||||
|
||||
fun recheckAuthState() {
|
||||
val enteredBackgroundVal = enteredBackground.value
|
||||
val delay = ChatController.appPrefs.laLockDelay.get()
|
||||
if (enteredBackgroundVal == null || elapsedRealtime() - enteredBackgroundVal >= delay * 1000) {
|
||||
if (userAuthorized.value != false) {
|
||||
/** [runAuthenticate] will be called in [MainScreen] if needed. Making like this prevents double showing of passcode on start */
|
||||
setAuthState()
|
||||
} else if (!ChatModel.activeCallViewIsVisible.value) {
|
||||
runAuthenticate()
|
||||
}
|
||||
}
|
||||
}
|
||||
fun appWasHidden() {
|
||||
enteredBackground.value = elapsedRealtime()
|
||||
}
|
||||
}
|
||||
+104
-35
@@ -1,19 +1,18 @@
|
||||
package chat.simplex.app.model
|
||||
package chat.simplex.common.model
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.font.*
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import chat.simplex.app.ui.theme.*
|
||||
import chat.simplex.app.views.call.*
|
||||
import chat.simplex.app.views.chat.ComposeState
|
||||
import chat.simplex.app.views.helpers.*
|
||||
import chat.simplex.app.views.onboarding.OnboardingStage
|
||||
import chat.simplex.app.views.usersettings.NotificationPreviewMode
|
||||
import chat.simplex.app.views.usersettings.NotificationsMode
|
||||
import chat.simplex.common.model.*
|
||||
import chat.simplex.common.ui.theme.*
|
||||
import chat.simplex.common.views.call.*
|
||||
import chat.simplex.common.views.chat.ComposeState
|
||||
import chat.simplex.common.views.helpers.*
|
||||
import chat.simplex.common.views.onboarding.OnboardingStage
|
||||
import chat.simplex.common.platform.AudioPlayer
|
||||
import chat.simplex.res.MR
|
||||
import dev.icerock.moko.resources.ImageResource
|
||||
import dev.icerock.moko.resources.StringResource
|
||||
@@ -26,6 +25,7 @@ import kotlinx.serialization.encoding.Decoder
|
||||
import kotlinx.serialization.encoding.Encoder
|
||||
import kotlinx.serialization.json.*
|
||||
import java.io.File
|
||||
import java.net.URI
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.time.format.FormatStyle
|
||||
import java.util.*
|
||||
@@ -66,14 +66,21 @@ object ChatModel {
|
||||
val clearOverlays = mutableStateOf<Boolean>(false)
|
||||
|
||||
// set when app is opened via contact or invitation URI
|
||||
val appOpenUrl = mutableStateOf<Uri?>(null)
|
||||
val appOpenUrl = mutableStateOf<URI?>(null)
|
||||
|
||||
// preferences
|
||||
val notificationsMode by lazy { mutableStateOf(NotificationsMode.values().firstOrNull { it.name == controller.appPrefs.notificationsMode.get() } ?: NotificationsMode.default) }
|
||||
val notificationPreviewMode by lazy { mutableStateOf(NotificationPreviewMode.values().firstOrNull { it.name == controller.appPrefs.notificationPreviewMode.get() } ?: NotificationPreviewMode.default) }
|
||||
val performLA by lazy { mutableStateOf(controller.appPrefs.performLA.get()) }
|
||||
val notificationPreviewMode by lazy {
|
||||
mutableStateOf(
|
||||
try {
|
||||
NotificationPreviewMode.valueOf(controller.appPrefs.notificationPreviewMode.get()!!)
|
||||
} catch (e: Exception) {
|
||||
NotificationPreviewMode.default
|
||||
}
|
||||
)
|
||||
}
|
||||
val performLA by lazy { mutableStateOf(ChatController.appPrefs.performLA.get()) }
|
||||
val showAdvertiseLAUnavailableAlert = mutableStateOf(false)
|
||||
val incognito by lazy { mutableStateOf(controller.appPrefs.incognito.get()) }
|
||||
val incognito by lazy { mutableStateOf(ChatController.appPrefs.incognito.get()) }
|
||||
|
||||
// current WebRTC call
|
||||
val callManager = CallManager(this)
|
||||
@@ -95,7 +102,7 @@ object ChatModel {
|
||||
val sharedContent = mutableStateOf(null as SharedContent?)
|
||||
|
||||
val filesToDelete = mutableSetOf<File>()
|
||||
val simplexLinkMode by lazy { mutableStateOf(controller.appPrefs.simplexLinkMode.get()) }
|
||||
val simplexLinkMode by lazy { mutableStateOf(ChatController.appPrefs.simplexLinkMode.get()) }
|
||||
|
||||
fun getUser(userId: Long): User? = if (currentUser.value?.userId == userId) {
|
||||
currentUser.value
|
||||
@@ -123,10 +130,11 @@ object ChatModel {
|
||||
}
|
||||
}
|
||||
|
||||
fun hasChat(id: String): Boolean = chats.firstOrNull { it.id == id } != null
|
||||
fun getChat(id: String): Chat? = chats.firstOrNull { it.id == id }
|
||||
fun getContactChat(contactId: Long): Chat? = chats.firstOrNull { it.chatInfo is ChatInfo.Direct && it.chatInfo.apiId == contactId }
|
||||
private fun getChatIndex(id: String): Int = chats.indexOfFirst { it.id == id }
|
||||
// toList() here is to prevent ConcurrentModificationException that is rarely happens but happens
|
||||
fun hasChat(id: String): Boolean = chats.toList().firstOrNull { it.id == id } != null
|
||||
fun getChat(id: String): Chat? = chats.toList().firstOrNull { it.id == id }
|
||||
fun getContactChat(contactId: Long): Chat? = chats.toList().firstOrNull { it.chatInfo is ChatInfo.Direct && it.chatInfo.apiId == contactId }
|
||||
private fun getChatIndex(id: String): Int = chats.toList().indexOfFirst { it.id == id }
|
||||
fun addChat(chat: Chat) = chats.add(index = 0, chat)
|
||||
|
||||
fun updateChatInfo(cInfo: ChatInfo) {
|
||||
@@ -430,7 +438,7 @@ object ChatModel {
|
||||
val info = getChat(id)?.chatInfo as? ChatInfo.ContactConnection ?: return
|
||||
if (info.contactConnection.connReqInv == connReqInv.value) {
|
||||
connReqInv.value = null
|
||||
ModalManager.shared.closeModals()
|
||||
ModalManager.center.closeModals()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1616,19 +1624,12 @@ data class CIMeta (
|
||||
|
||||
val isRcvNew: Boolean get() = itemStatus is CIStatus.RcvNew
|
||||
|
||||
fun statusIcon(primaryColor: Color, metaColor: Color = CurrentColors.value.colors.secondary): Pair<ImageResource, Color>? =
|
||||
when (itemStatus) {
|
||||
is CIStatus.SndSent -> MR.images.ic_check_filled to metaColor
|
||||
is CIStatus.SndRcvd -> when(itemStatus.msgRcptStatus) {
|
||||
MsgReceiptStatus.Ok -> MR.images.ic_double_check to metaColor
|
||||
MsgReceiptStatus.BadMsgHash -> MR.images.ic_double_check to Color.Red
|
||||
}
|
||||
is CIStatus.SndErrorAuth -> MR.images.ic_close to Color.Red
|
||||
is CIStatus.SndError -> MR.images.ic_warning_filled to WarningYellow
|
||||
is CIStatus.RcvNew -> MR.images.ic_circle_filled to primaryColor
|
||||
is CIStatus.Invalid -> MR.images.ic_question_mark to metaColor
|
||||
else -> null
|
||||
}
|
||||
fun statusIcon(
|
||||
primaryColor: Color,
|
||||
metaColor: Color = CurrentColors.value.colors.secondary,
|
||||
paleMetaColor: Color = CurrentColors.value.colors.secondary
|
||||
): Pair<ImageResource, Color>? =
|
||||
itemStatus.statusIcon(primaryColor, metaColor, paleMetaColor)
|
||||
|
||||
companion object {
|
||||
fun getSample(
|
||||
@@ -1707,13 +1708,60 @@ fun localTimestamp(t: Instant): String {
|
||||
@Serializable
|
||||
sealed class CIStatus {
|
||||
@Serializable @SerialName("sndNew") class SndNew: CIStatus()
|
||||
@Serializable @SerialName("sndSent") class SndSent: CIStatus()
|
||||
@Serializable @SerialName("sndRcvd") class SndRcvd(val msgRcptStatus: MsgReceiptStatus): CIStatus()
|
||||
@Serializable @SerialName("sndSent") class SndSent(val sndProgress: SndCIStatusProgress): CIStatus()
|
||||
@Serializable @SerialName("sndRcvd") class SndRcvd(val msgRcptStatus: MsgReceiptStatus, val sndProgress: SndCIStatusProgress): CIStatus()
|
||||
@Serializable @SerialName("sndErrorAuth") class SndErrorAuth: CIStatus()
|
||||
@Serializable @SerialName("sndError") class SndError(val agentError: String): CIStatus()
|
||||
@Serializable @SerialName("rcvNew") class RcvNew: CIStatus()
|
||||
@Serializable @SerialName("rcvRead") class RcvRead: CIStatus()
|
||||
@Serializable @SerialName("invalid") class Invalid(val text: String): CIStatus()
|
||||
|
||||
fun statusIcon(
|
||||
primaryColor: Color,
|
||||
metaColor: Color = CurrentColors.value.colors.secondary,
|
||||
paleMetaColor: Color = CurrentColors.value.colors.secondary
|
||||
): Pair<ImageResource, Color>? =
|
||||
when (this) {
|
||||
is SndNew -> null
|
||||
is SndSent -> when (this.sndProgress) {
|
||||
SndCIStatusProgress.Complete -> MR.images.ic_check_filled to metaColor
|
||||
SndCIStatusProgress.Partial -> MR.images.ic_check_filled to paleMetaColor
|
||||
}
|
||||
is SndRcvd -> when(this.msgRcptStatus) {
|
||||
MsgReceiptStatus.Ok -> when (this.sndProgress) {
|
||||
SndCIStatusProgress.Complete -> MR.images.ic_double_check to metaColor
|
||||
SndCIStatusProgress.Partial -> MR.images.ic_double_check to paleMetaColor
|
||||
}
|
||||
MsgReceiptStatus.BadMsgHash -> MR.images.ic_double_check to Color.Red
|
||||
}
|
||||
is SndErrorAuth -> MR.images.ic_close to Color.Red
|
||||
is SndError -> MR.images.ic_warning_filled to WarningYellow
|
||||
is RcvNew -> MR.images.ic_circle_filled to primaryColor
|
||||
is RcvRead -> null
|
||||
is CIStatus.Invalid -> MR.images.ic_question_mark to metaColor
|
||||
}
|
||||
|
||||
val statusText: String get() = when (this) {
|
||||
is SndNew -> generalGetString(MR.strings.item_status_snd_new_text)
|
||||
is SndSent -> generalGetString(MR.strings.item_status_snd_sent_text)
|
||||
is SndRcvd -> generalGetString(MR.strings.item_status_snd_rcvd_text)
|
||||
is SndErrorAuth -> generalGetString(MR.strings.item_status_snd_error_text)
|
||||
is SndError -> generalGetString(MR.strings.item_status_snd_error_text)
|
||||
is RcvNew -> generalGetString(MR.strings.item_status_rcv_new_text)
|
||||
is RcvRead -> generalGetString(MR.strings.item_status_rcv_read_text)
|
||||
is Invalid -> "Invalid status"
|
||||
}
|
||||
|
||||
val statusDescription: String get() = when (this) {
|
||||
is SndNew -> generalGetString(MR.strings.item_status_snd_new_desc)
|
||||
is SndSent -> generalGetString(MR.strings.item_status_snd_sent_desc)
|
||||
is SndRcvd -> generalGetString(MR.strings.item_status_snd_rcvd_desc)
|
||||
is SndErrorAuth -> generalGetString(MR.strings.item_status_snd_error_auth_desc)
|
||||
is SndError -> String.format(generalGetString(MR.strings.item_status_snd_error_unexpected_desc), this.agentError)
|
||||
is RcvNew -> generalGetString(MR.strings.item_status_rcv_new_desc)
|
||||
is RcvRead -> generalGetString(MR.strings.item_status_rcv_read_desc)
|
||||
is Invalid -> this.text
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
@@ -1722,6 +1770,12 @@ enum class MsgReceiptStatus {
|
||||
@SerialName("badMsgHash") BadMsgHash;
|
||||
}
|
||||
|
||||
@Serializable
|
||||
enum class SndCIStatusProgress {
|
||||
@SerialName("partial") Partial,
|
||||
@SerialName("complete") Complete;
|
||||
}
|
||||
|
||||
@Serializable
|
||||
sealed class CIDeleted {
|
||||
@Serializable @SerialName("deleted") class Deleted(val deletedTs: Instant?): CIDeleted()
|
||||
@@ -2485,6 +2539,7 @@ sealed class ChatItemTTL: Comparable<ChatItemTTL?> {
|
||||
@Serializable
|
||||
class ChatItemInfo(
|
||||
val itemVersions: List<ChatItemVersion>,
|
||||
val memberDeliveryStatuses: List<MemberDeliveryStatus>?
|
||||
)
|
||||
|
||||
@Serializable
|
||||
@@ -2495,3 +2550,17 @@ data class ChatItemVersion(
|
||||
val itemVersionTs: Instant,
|
||||
val createdAt: Instant,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class MemberDeliveryStatus(
|
||||
val groupMemberId: Long,
|
||||
val memberDeliveryStatus: CIStatus
|
||||
)
|
||||
|
||||
enum class NotificationPreviewMode {
|
||||
MESSAGE, CONTACT, HIDDEN;
|
||||
|
||||
companion object {
|
||||
val default: NotificationPreviewMode = MESSAGE
|
||||
}
|
||||
}
|
||||
+396
-107
@@ -1,21 +1,21 @@
|
||||
package chat.simplex.app.model
|
||||
package chat.simplex.common.model
|
||||
|
||||
import android.content.*
|
||||
import android.util.Log
|
||||
import chat.simplex.app.views.helpers.*
|
||||
import chat.simplex.common.views.helpers.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.painter.Painter
|
||||
import dev.icerock.moko.resources.compose.painterResource
|
||||
import chat.simplex.app.*
|
||||
import chat.simplex.app.ui.theme.*
|
||||
import chat.simplex.app.views.call.*
|
||||
import chat.simplex.app.views.newchat.ConnectViaLinkTab
|
||||
import chat.simplex.app.views.onboarding.OnboardingStage
|
||||
import chat.simplex.app.views.usersettings.*
|
||||
import chat.simplex.common.*
|
||||
import chat.simplex.common.platform.*
|
||||
import chat.simplex.common.ui.theme.*
|
||||
import chat.simplex.common.views.call.*
|
||||
import chat.simplex.common.views.newchat.ConnectViaLinkTab
|
||||
import chat.simplex.common.views.onboarding.OnboardingStage
|
||||
import chat.simplex.common.views.usersettings.*
|
||||
import com.charleskorn.kaml.Yaml
|
||||
import com.charleskorn.kaml.YamlConfiguration
|
||||
import chat.simplex.res.MR
|
||||
import com.russhwolf.settings.Settings
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.datetime.Clock
|
||||
import kotlinx.datetime.Instant
|
||||
@@ -43,19 +43,17 @@ enum class SimplexLinkMode {
|
||||
BROWSER;
|
||||
|
||||
companion object {
|
||||
val default = SimplexLinkMode.DESCRIPTION
|
||||
val default = DESCRIPTION
|
||||
}
|
||||
}
|
||||
|
||||
class AppPreferences {
|
||||
private val sharedPreferences: SharedPreferences = SimplexApp.context.getSharedPreferences(SHARED_PREFS_ID, Context.MODE_PRIVATE)
|
||||
private val sharedPreferencesThemes: SharedPreferences = SimplexApp.context.getSharedPreferences(SHARED_PREFS_THEMES_ID, Context.MODE_PRIVATE)
|
||||
|
||||
// deprecated, remove in 2024
|
||||
private val runServiceInBackground = mkBoolPreference(SHARED_PREFS_RUN_SERVICE_IN_BACKGROUND, true)
|
||||
val notificationsMode = mkStrPreference(SHARED_PREFS_NOTIFICATIONS_MODE,
|
||||
if (!runServiceInBackground.get()) NotificationsMode.OFF.name else NotificationsMode.default.name
|
||||
)
|
||||
val notificationsMode = mkEnumPreference(
|
||||
SHARED_PREFS_NOTIFICATIONS_MODE,
|
||||
if (!runServiceInBackground.get()) NotificationsMode.OFF else NotificationsMode.default
|
||||
) { NotificationsMode.values().firstOrNull { it.name == this } }
|
||||
val notificationPreviewMode = mkStrPreference(SHARED_PREFS_NOTIFICATION_PREVIEW_MODE, NotificationPreviewMode.default.name)
|
||||
val backgroundServiceNoticeShown = mkBoolPreference(SHARED_PREFS_SERVICE_NOTICE_SHOWN, false)
|
||||
val backgroundServiceBatteryNoticeShown = mkBoolPreference(SHARED_PREFS_SERVICE_BATTERY_NOTICE_SHOWN, false)
|
||||
@@ -74,7 +72,7 @@ class AppPreferences {
|
||||
set = fun(action: CallOnLockScreen) { _callOnLockScreen.set(action.name) }
|
||||
)
|
||||
val performLA = mkBoolPreference(SHARED_PREFS_PERFORM_LA, false)
|
||||
val laMode = mkEnumPreference(SHARED_PREFS_LA_MODE, LAMode.SYSTEM) { LAMode.values().firstOrNull { it.name == this } }
|
||||
val laMode = mkEnumPreference(SHARED_PREFS_LA_MODE, LAMode.default) { LAMode.values().firstOrNull { it.name == this } }
|
||||
val laLockDelay = mkIntPreference(SHARED_PREFS_LA_LOCK_DELAY, 30)
|
||||
val laNoticeShown = mkBoolPreference(SHARED_PREFS_LA_NOTICE_SHOWN, false)
|
||||
val webrtcIceServers = mkStrPreference(SHARED_PREFS_WEBRTC_ICE_SERVERS, null)
|
||||
@@ -142,7 +140,7 @@ class AppPreferences {
|
||||
val initializationVectorAppPassphrase = mkStrPreference(SHARED_PREFS_INITIALIZATION_VECTOR_APP_PASSPHRASE, null)
|
||||
val encryptedSelfDestructPassphrase = mkStrPreference(SHARED_PREFS_ENCRYPTED_SELF_DESTRUCT_PASSPHRASE, null)
|
||||
val initializationVectorSelfDestructPassphrase = mkStrPreference(SHARED_PREFS_INITIALIZATION_VECTOR_SELF_DESTRUCT_PASSPHRASE, null)
|
||||
val encryptionStartedAt = mkDatePreference(SHARED_PREFS_ENCRYPTION_STARTED_AT, null, true)
|
||||
val encryptionStartedAt = mkDatePreference(SHARED_PREFS_ENCRYPTION_STARTED_AT, null)
|
||||
val confirmDBUpgrades = mkBoolPreference(SHARED_PREFS_CONFIRM_DB_UPGRADES, false)
|
||||
val selfDestruct = mkBoolPreference(SHARED_PREFS_SELF_DESTRUCT, false)
|
||||
val selfDestructDisplayName = mkStrPreference(SHARED_PREFS_SELF_DESTRUCT_DISPLAY_NAME, null)
|
||||
@@ -153,7 +151,7 @@ class AppPreferences {
|
||||
json.encodeToString(MapSerializer(String.serializer(), ThemeOverrides.serializer()), it)
|
||||
}, decode = {
|
||||
json.decodeFromString(MapSerializer(String.serializer(), ThemeOverrides.serializer()), it)
|
||||
}, sharedPreferencesThemes)
|
||||
}, settingsThemes)
|
||||
|
||||
val whatsNewVersion = mkStrPreference(SHARED_PREFS_WHATS_NEW_VERSION, null)
|
||||
val lastMigratedVersionCode = mkIntPreference(SHARED_PREFS_LAST_MIGRATED_VERSION_CODE, 0)
|
||||
@@ -161,65 +159,73 @@ class AppPreferences {
|
||||
|
||||
private fun mkIntPreference(prefName: String, default: Int) =
|
||||
SharedPreference(
|
||||
get = fun() = sharedPreferences.getInt(prefName, default),
|
||||
set = fun(value) = sharedPreferences.edit().putInt(prefName, value).apply()
|
||||
get = fun() = settings.getInt(prefName, default),
|
||||
set = fun(value) = settings.putInt(prefName, value)
|
||||
)
|
||||
|
||||
private fun mkLongPreference(prefName: String, default: Long) =
|
||||
SharedPreference(
|
||||
get = fun() = sharedPreferences.getLong(prefName, default),
|
||||
set = fun(value) = sharedPreferences.edit().putLong(prefName, value).apply()
|
||||
get = fun() = settings.getLong(prefName, default),
|
||||
set = fun(value) = settings.putLong(prefName, value)
|
||||
)
|
||||
|
||||
private fun mkTimeoutPreference(prefName: String, default: Long, proxyDefault: Long): SharedPreference<Long> {
|
||||
val d = if (networkUseSocksProxy.get()) proxyDefault else default
|
||||
return SharedPreference(
|
||||
get = fun() = sharedPreferences.getLong(prefName, d),
|
||||
set = fun(value) = sharedPreferences.edit().putLong(prefName, value).apply()
|
||||
get = fun() = settings.getLong(prefName, d),
|
||||
set = fun(value) = settings.putLong(prefName, value)
|
||||
)
|
||||
}
|
||||
|
||||
private fun mkBoolPreference(prefName: String, default: Boolean) =
|
||||
SharedPreference(
|
||||
get = fun() = sharedPreferences.getBoolean(prefName, default),
|
||||
set = fun(value) = sharedPreferences.edit().putBoolean(prefName, value).apply()
|
||||
get = fun() = settings.getBoolean(prefName, default),
|
||||
set = fun(value) = settings.putBoolean(prefName, value)
|
||||
)
|
||||
|
||||
private fun mkStrPreference(prefName: String, default: String?): SharedPreference<String?> =
|
||||
SharedPreference(
|
||||
get = fun() = sharedPreferences.getString(prefName, default),
|
||||
set = fun(value) = sharedPreferences.edit().putString(prefName, value).apply()
|
||||
get = {
|
||||
val nullValue = "----------------------"
|
||||
val pref = settings.getString(prefName, default ?: nullValue)
|
||||
if (pref != nullValue) {
|
||||
pref
|
||||
} else {
|
||||
null
|
||||
}
|
||||
},
|
||||
set = fun(value) = if (value != null) settings.putString(prefName, value) else settings.remove(prefName)
|
||||
)
|
||||
|
||||
private fun <T> mkEnumPreference(prefName: String, default: T, construct: String.() -> T?): SharedPreference<T> =
|
||||
SharedPreference(
|
||||
get = fun() = sharedPreferences.getString(prefName, default.toString())?.construct() ?: default,
|
||||
set = fun(value) = sharedPreferences.edit().putString(prefName, value.toString()).apply()
|
||||
get = fun() = settings.getString(prefName, default.toString()).construct() ?: default,
|
||||
set = fun(value) = settings.putString(prefName, value.toString())
|
||||
)
|
||||
|
||||
/**
|
||||
* Provide `[commit] = true` to save preferences right now, not after some unknown period of time.
|
||||
* So in case of a crash this value will be saved 100%
|
||||
* */
|
||||
private fun mkDatePreference(prefName: String, default: Instant?, commit: Boolean = false): SharedPreference<Instant?> =
|
||||
// LALAL
|
||||
private fun mkDatePreference(prefName: String, default: Instant?): SharedPreference<Instant?> =
|
||||
SharedPreference(
|
||||
get = {
|
||||
val pref = sharedPreferences.getString(prefName, default?.toEpochMilliseconds()?.toString())
|
||||
pref?.let { Instant.fromEpochMilliseconds(pref.toLong()) }
|
||||
val nullValue = "----------------------"
|
||||
val pref = settings.getString(prefName, default?.toEpochMilliseconds()?.toString() ?: nullValue)
|
||||
if (pref != nullValue) {
|
||||
Instant.fromEpochMilliseconds(pref.toLong())
|
||||
} else {
|
||||
null
|
||||
}
|
||||
},
|
||||
set = fun(value) = sharedPreferences.edit().putString(prefName, value?.toEpochMilliseconds()?.toString()).let {
|
||||
if (commit) it.commit() else it.apply()
|
||||
}
|
||||
set = fun(value) = if (value?.toEpochMilliseconds() != null) settings.putString(prefName, value.toEpochMilliseconds().toString()) else settings.remove(prefName)
|
||||
)
|
||||
|
||||
private fun <K, V> mkMapPreference(prefName: String, default: Map<K, V>, encode: (Map<K, V>) -> String, decode: (String) -> Map<K, V>, prefs: SharedPreferences = sharedPreferences): SharedPreference<Map<K,V>> =
|
||||
private fun <K, V> mkMapPreference(prefName: String, default: Map<K, V>, encode: (Map<K, V>) -> String, decode: (String) -> Map<K, V>, prefs: Settings = settings): SharedPreference<Map<K,V>> =
|
||||
SharedPreference(
|
||||
get = fun() = decode(prefs.getString(prefName, encode(default))!!),
|
||||
set = fun(value) = prefs.edit().putString(prefName, encode(value)).apply()
|
||||
get = fun() = decode(prefs.getString(prefName, encode(default))),
|
||||
set = fun(value) = prefs.putString(prefName, encode(value))
|
||||
)
|
||||
|
||||
companion object {
|
||||
internal const val SHARED_PREFS_ID = "chat.simplex.app.SIMPLEX_APP_PREFS"
|
||||
const val SHARED_PREFS_ID = "chat.simplex.app.SIMPLEX_APP_PREFS"
|
||||
internal const val SHARED_PREFS_THEMES_ID = "chat.simplex.app.THEMES"
|
||||
private const val SHARED_PREFS_AUTO_RESTART_WORKER_VERSION = "AutoRestartWorkerVersion"
|
||||
private const val SHARED_PREFS_RUN_SERVICE_IN_BACKGROUND = "RunServiceInBackground"
|
||||
@@ -240,7 +246,7 @@ class AppPreferences {
|
||||
private const val SHARED_PREFS_PRIVACY_LINK_PREVIEWS = "PrivacyLinkPreviews"
|
||||
private const val SHARED_PREFS_PRIVACY_SIMPLEX_LINK_MODE = "PrivacySimplexLinkMode"
|
||||
private const val SHARED_PREFS_PRIVACY_DELIVERY_RECEIPTS_SET = "PrivacyDeliveryReceiptsSet"
|
||||
internal const val SHARED_PREFS_PRIVACY_FULL_BACKUP = "FullBackup"
|
||||
const val SHARED_PREFS_PRIVACY_FULL_BACKUP = "FullBackup"
|
||||
private const val SHARED_PREFS_EXPERIMENTAL_CALLS = "ExperimentalCalls"
|
||||
private const val SHARED_PREFS_SHOW_UNREAD_AND_FAVORITES = "ShowUnreadAndFavorites"
|
||||
private const val SHARED_PREFS_CHAT_ARCHIVE_NAME = "ChatArchiveName"
|
||||
@@ -294,7 +300,6 @@ private const val MESSAGE_TIMEOUT: Int = 15_000_000
|
||||
object ChatController {
|
||||
var ctrl: ChatCtrl? = -1
|
||||
val appPrefs: AppPreferences by lazy { AppPreferences() }
|
||||
val ntfManager by lazy { NtfManager }
|
||||
|
||||
val chatModel = ChatModel
|
||||
private var receiverStarted = false
|
||||
@@ -316,8 +321,8 @@ object ChatController {
|
||||
try {
|
||||
if (chatModel.chatRunning.value == true) return
|
||||
apiSetNetworkConfig(getNetCfg())
|
||||
apiSetTempFolder(getTempFilesDirectory())
|
||||
apiSetFilesFolder(getAppFilesDirectory())
|
||||
apiSetTempFolder(coreTmpDir.absolutePath)
|
||||
apiSetFilesFolder(appFilesDir.absolutePath)
|
||||
apiSetXFTPConfig(getXFTPCfg())
|
||||
val justStarted = apiStartChat()
|
||||
val users = listUsers()
|
||||
@@ -328,7 +333,7 @@ object ChatController {
|
||||
chatModel.userCreated.value = true
|
||||
apiSetIncognito(chatModel.incognito.value)
|
||||
getUserChatData()
|
||||
chatModel.controller.appPrefs.chatLastStart.set(Clock.System.now())
|
||||
appPrefs.chatLastStart.set(Clock.System.now())
|
||||
chatModel.chatRunning.value = true
|
||||
startReceiver()
|
||||
Log.d(TAG, "startChat: started")
|
||||
@@ -466,13 +471,19 @@ object ChatController {
|
||||
suspend fun apiSetAllContactReceipts(enable: Boolean) {
|
||||
val r = sendCmd(CC.SetAllContactReceipts(enable))
|
||||
if (r is CR.CmdOk) return
|
||||
throw Exception("failed to enable receipts for all users ${r.responseType} ${r.details}")
|
||||
throw Exception("failed to set receipts for all users ${r.responseType} ${r.details}")
|
||||
}
|
||||
|
||||
suspend fun apiSetUserContactReceipts(userId: Long, userMsgReceiptSettings: UserMsgReceiptSettings) {
|
||||
val r = sendCmd(CC.ApiSetUserContactReceipts(userId, userMsgReceiptSettings))
|
||||
if (r is CR.CmdOk) return
|
||||
throw Exception("failed to enable receipts for user contacts ${r.responseType} ${r.details}")
|
||||
throw Exception("failed to set receipts for user contacts ${r.responseType} ${r.details}")
|
||||
}
|
||||
|
||||
suspend fun apiSetUserGroupReceipts(userId: Long, userMsgReceiptSettings: UserMsgReceiptSettings) {
|
||||
val r = sendCmd(CC.ApiSetUserGroupReceipts(userId, userMsgReceiptSettings))
|
||||
if (r is CR.CmdOk) return
|
||||
throw Exception("failed to set receipts for user groups ${r.responseType} ${r.details}")
|
||||
}
|
||||
|
||||
suspend fun apiHideUser(userId: Long, viewPwd: String): User =
|
||||
@@ -966,7 +977,8 @@ object ChatController {
|
||||
val r = sendCmd(CC.ApiShowMyAddress(userId))
|
||||
if (r is CR.UserContactLink) return r.contactLink
|
||||
if (r is CR.ChatCmdError && r.chatError is ChatError.ChatErrorStore
|
||||
&& r.chatError.storeError is StoreError.UserContactLinkNotFound) {
|
||||
&& r.chatError.storeError is StoreError.UserContactLinkNotFound
|
||||
) {
|
||||
return null
|
||||
}
|
||||
Log.e(TAG, "apiGetUserAddress bad response: ${r.responseType} ${r.details}")
|
||||
@@ -978,7 +990,8 @@ object ChatController {
|
||||
val r = sendCmd(CC.ApiAddressAutoAccept(userId, autoAccept))
|
||||
if (r is CR.UserContactLinkUpdated) return r.contactLink
|
||||
if (r is CR.ChatCmdError && r.chatError is ChatError.ChatErrorStore
|
||||
&& r.chatError.storeError is StoreError.UserContactLinkNotFound) {
|
||||
&& r.chatError.storeError is StoreError.UserContactLinkNotFound
|
||||
) {
|
||||
return null
|
||||
}
|
||||
Log.e(TAG, "userAddressAutoAccept bad response: ${r.responseType} ${r.details}")
|
||||
@@ -1067,23 +1080,26 @@ object ChatController {
|
||||
return false
|
||||
}
|
||||
|
||||
suspend fun apiReceiveFile(fileId: Long, inline: Boolean? = null): AChatItem? {
|
||||
suspend fun apiReceiveFile(fileId: Long, inline: Boolean? = null, auto: Boolean = false): AChatItem? {
|
||||
val r = sendCmd(CC.ReceiveFile(fileId, inline))
|
||||
return when (r) {
|
||||
is CR.RcvFileAccepted -> r.chatItem
|
||||
is CR.RcvFileAcceptedSndCancelled -> {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
generalGetString(MR.strings.cannot_receive_file),
|
||||
generalGetString(MR.strings.sender_cancelled_file_transfer)
|
||||
)
|
||||
Log.d(TAG, "apiReceiveFile error: sender cancelled file transfer")
|
||||
if (!auto) {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
generalGetString(MR.strings.cannot_receive_file),
|
||||
generalGetString(MR.strings.sender_cancelled_file_transfer)
|
||||
)
|
||||
}
|
||||
null
|
||||
}
|
||||
|
||||
else -> {
|
||||
if (!(networkErrorAlert(r))) {
|
||||
if (r is CR.ChatCmdError && r.chatError is ChatError.ChatErrorChat
|
||||
&& r.chatError.errorType is ChatErrorType.FileAlreadyReceiving
|
||||
) {
|
||||
Log.d(TAG, "apiReceiveFile ignoring FileAlreadyReceiving error")
|
||||
val maybeChatError = chatError(r)
|
||||
if (maybeChatError is ChatErrorType.FileCancelled || maybeChatError is ChatErrorType.FileAlreadyReceiving) {
|
||||
Log.d(TAG, "apiReceiveFile ignoring FileCancelled or FileAlreadyReceiving error")
|
||||
} else {
|
||||
apiErrorAlert("apiReceiveFile", generalGetString(MR.strings.error_receiving_file), r)
|
||||
}
|
||||
@@ -1405,9 +1421,9 @@ object ChatController {
|
||||
((mc is MsgContent.MCImage && file.fileSize <= MAX_IMAGE_SIZE_AUTO_RCV)
|
||||
|| (mc is MsgContent.MCVideo && file.fileSize <= MAX_VIDEO_SIZE_AUTO_RCV)
|
||||
|| (mc is MsgContent.MCVoice && file.fileSize <= MAX_VOICE_SIZE_AUTO_RCV && file.fileStatus !is CIFileStatus.RcvAccepted))) {
|
||||
withApi { receiveFile(r.user, file.fileId) }
|
||||
withApi { receiveFile(r.user, file.fileId, auto = true) }
|
||||
}
|
||||
if (cItem.showNotification && (!SimplexApp.context.isAppOnForeground || chatModel.chatId.value != cInfo.id)) {
|
||||
if (cItem.showNotification && (allowedToShowNotification() || chatModel.chatId.value != cInfo.id)) {
|
||||
ntfManager.notifyMessageReceived(r.user, cInfo, cItem)
|
||||
}
|
||||
}
|
||||
@@ -1560,7 +1576,7 @@ object ChatController {
|
||||
// TODO check encryption is compatible
|
||||
withCall(r, r.contact) { call ->
|
||||
chatModel.activeCall.value = call.copy(callState = CallState.OfferReceived, peerMedia = r.callType.media, sharedKey = r.sharedKey)
|
||||
val useRelay = chatModel.controller.appPrefs.webrtcPolicyRelay.get()
|
||||
val useRelay = appPrefs.webrtcPolicyRelay.get()
|
||||
val iceServers = getIceServers()
|
||||
Log.d(TAG, ".callOffer iceServers $iceServers")
|
||||
chatModel.callCommand.value = WCallCommand.Offer(
|
||||
@@ -1639,8 +1655,8 @@ object ChatController {
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun receiveFile(user: User, fileId: Long) {
|
||||
val chatItem = apiReceiveFile(fileId)
|
||||
suspend fun receiveFile(user: User, fileId: Long, auto: Boolean = false) {
|
||||
val chatItem = apiReceiveFile(fileId, auto = auto)
|
||||
if (chatItem != null) {
|
||||
chatItemSimpleUpdate(user, chatItem)
|
||||
}
|
||||
@@ -1778,6 +1794,7 @@ sealed class CC {
|
||||
class ApiSetActiveUser(val userId: Long, val viewPwd: String?): CC()
|
||||
class SetAllContactReceipts(val enable: Boolean): CC()
|
||||
class ApiSetUserContactReceipts(val userId: Long, val userMsgReceiptSettings: UserMsgReceiptSettings): CC()
|
||||
class ApiSetUserGroupReceipts(val userId: Long, val userMsgReceiptSettings: UserMsgReceiptSettings): CC()
|
||||
class ApiHideUser(val userId: Long, val viewPwd: String): CC()
|
||||
class ApiUnhideUser(val userId: Long, val viewPwd: String): CC()
|
||||
class ApiMuteUser(val userId: Long): CC()
|
||||
@@ -1875,7 +1892,11 @@ sealed class CC {
|
||||
is SetAllContactReceipts -> "/set receipts all ${onOff(enable)}"
|
||||
is ApiSetUserContactReceipts -> {
|
||||
val mrs = userMsgReceiptSettings
|
||||
"/_set receipts $userId ${onOff(mrs.enable)} clear_overrides=${onOff(mrs.clearOverrides)}"
|
||||
"/_set receipts contacts $userId ${onOff(mrs.enable)} clear_overrides=${onOff(mrs.clearOverrides)}"
|
||||
}
|
||||
is ApiSetUserGroupReceipts -> {
|
||||
val mrs = userMsgReceiptSettings
|
||||
"/_set receipts groups $userId ${onOff(mrs.enable)} clear_overrides=${onOff(mrs.clearOverrides)}"
|
||||
}
|
||||
is ApiHideUser -> "/_hide user $userId ${json.encodeToString(viewPwd)}"
|
||||
is ApiUnhideUser -> "/_unhide user $userId ${json.encodeToString(viewPwd)}"
|
||||
@@ -1974,6 +1995,7 @@ sealed class CC {
|
||||
is ApiSetActiveUser -> "apiSetActiveUser"
|
||||
is SetAllContactReceipts -> "setAllContactReceipts"
|
||||
is ApiSetUserContactReceipts -> "apiSetUserContactReceipts"
|
||||
is ApiSetUserGroupReceipts -> "apiSetUserGroupReceipts"
|
||||
is ApiHideUser -> "apiHideUser"
|
||||
is ApiUnhideUser -> "apiUnhideUser"
|
||||
is ApiMuteUser -> "apiMuteUser"
|
||||
@@ -3231,6 +3253,7 @@ sealed class CR {
|
||||
@Serializable @SerialName("sentConfirmation") class SentConfirmation(val user: User): CR()
|
||||
@Serializable @SerialName("sentInvitation") class SentInvitation(val user: User): CR()
|
||||
@Serializable @SerialName("contactAlreadyExists") class ContactAlreadyExists(val user: User, val contact: Contact): CR()
|
||||
@Serializable @SerialName("contactRequestAlreadyAccepted") class ContactRequestAlreadyAccepted(val user: User, val contact: Contact): CR()
|
||||
@Serializable @SerialName("contactDeleted") class ContactDeleted(val user: User, val contact: Contact): CR()
|
||||
@Serializable @SerialName("chatCleared") class ChatCleared(val user: User, val chatInfo: ChatInfo): CR()
|
||||
@Serializable @SerialName("userProfileNoChange") class UserProfileNoChange(val user: User): CR()
|
||||
@@ -3260,6 +3283,7 @@ sealed class CR {
|
||||
@Serializable @SerialName("newChatItem") class NewChatItem(val user: User, val chatItem: AChatItem): CR()
|
||||
@Serializable @SerialName("chatItemStatusUpdated") class ChatItemStatusUpdated(val user: User, val chatItem: AChatItem): CR()
|
||||
@Serializable @SerialName("chatItemUpdated") class ChatItemUpdated(val user: User, val chatItem: AChatItem): CR()
|
||||
@Serializable @SerialName("chatItemNotChanged") class ChatItemNotChanged(val user: User, val chatItem: AChatItem): CR()
|
||||
@Serializable @SerialName("chatItemReaction") class ChatItemReaction(val user: User, val added: Boolean, val reaction: ACIReaction): CR()
|
||||
@Serializable @SerialName("chatItemDeleted") class ChatItemDeleted(val user: User, val deletedChatItem: AChatItem, val toChatItem: AChatItem? = null, val byUser: Boolean): CR()
|
||||
@Serializable @SerialName("contactsList") class ContactsList(val user: User, val contacts: List<Contact>): CR()
|
||||
@@ -3357,6 +3381,7 @@ sealed class CR {
|
||||
is SentConfirmation -> "sentConfirmation"
|
||||
is SentInvitation -> "sentInvitation"
|
||||
is ContactAlreadyExists -> "contactAlreadyExists"
|
||||
is ContactRequestAlreadyAccepted -> "contactRequestAlreadyAccepted"
|
||||
is ContactDeleted -> "contactDeleted"
|
||||
is ChatCleared -> "chatCleared"
|
||||
is UserProfileNoChange -> "userProfileNoChange"
|
||||
@@ -3386,6 +3411,7 @@ sealed class CR {
|
||||
is NewChatItem -> "newChatItem"
|
||||
is ChatItemStatusUpdated -> "chatItemStatusUpdated"
|
||||
is ChatItemUpdated -> "chatItemUpdated"
|
||||
is ChatItemNotChanged -> "chatItemNotChanged"
|
||||
is ChatItemReaction -> "chatItemReaction"
|
||||
is ChatItemDeleted -> "chatItemDeleted"
|
||||
is ContactsList -> "contactsList"
|
||||
@@ -3480,6 +3506,7 @@ sealed class CR {
|
||||
is SentConfirmation -> withUser(user, noDetails())
|
||||
is SentInvitation -> withUser(user, noDetails())
|
||||
is ContactAlreadyExists -> withUser(user, json.encodeToString(contact))
|
||||
is ContactRequestAlreadyAccepted -> withUser(user, json.encodeToString(contact))
|
||||
is ContactDeleted -> withUser(user, json.encodeToString(contact))
|
||||
is ChatCleared -> withUser(user, json.encodeToString(chatInfo))
|
||||
is UserProfileNoChange -> withUser(user, noDetails())
|
||||
@@ -3510,6 +3537,7 @@ sealed class CR {
|
||||
is NewChatItem -> withUser(user, json.encodeToString(chatItem))
|
||||
is ChatItemStatusUpdated -> withUser(user, json.encodeToString(chatItem))
|
||||
is ChatItemUpdated -> withUser(user, json.encodeToString(chatItem))
|
||||
is ChatItemNotChanged -> withUser(user, json.encodeToString(chatItem))
|
||||
is ChatItemReaction -> withUser(user, "added: $added\n${json.encodeToString(reaction)}")
|
||||
is ChatItemDeleted -> withUser(user, "deletedChatItem:\n${json.encodeToString(deletedChatItem)}\ntoChatItem:\n${json.encodeToString(toChatItem)}\nbyUser: $byUser")
|
||||
is ContactsList -> withUser(user, json.encodeToString(contacts))
|
||||
@@ -3576,6 +3604,14 @@ sealed class CR {
|
||||
private fun withUser(u: User?, s: String): String = if (u != null) "userId: ${u.userId}\n$s" else s
|
||||
}
|
||||
|
||||
fun chatError(r: CR): ChatErrorType? {
|
||||
return (
|
||||
if (r is CR.ChatCmdError && r.chatError is ChatError.ChatErrorChat) r.chatError.errorType
|
||||
else if (r is CR.ChatRespError && r.chatError is ChatError.ChatErrorChat) r.chatError.errorType
|
||||
else null
|
||||
)
|
||||
}
|
||||
|
||||
abstract class TerminalItem {
|
||||
abstract val id: Long
|
||||
val date: Instant = Clock.System.now()
|
||||
@@ -3715,34 +3751,266 @@ sealed class ChatError {
|
||||
|
||||
@Serializable
|
||||
sealed class ChatErrorType {
|
||||
val string: String get() = when (this) {
|
||||
is NoActiveUser -> "noActiveUser"
|
||||
is DifferentActiveUser -> "differentActiveUser"
|
||||
is UserExists -> "userExists"
|
||||
is InvalidConnReq -> "invalidConnReq"
|
||||
is FileAlreadyReceiving -> "fileAlreadyReceiving"
|
||||
is СommandError -> "commandError $message"
|
||||
is CEException -> "exception $message"
|
||||
}
|
||||
@Serializable @SerialName("noActiveUser") class NoActiveUser: ChatErrorType()
|
||||
@Serializable @SerialName("differentActiveUser") class DifferentActiveUser: ChatErrorType()
|
||||
val string: String
|
||||
get() = when (this) {
|
||||
is NoActiveUser -> "noActiveUser"
|
||||
is NoConnectionUser -> "noConnectionUser"
|
||||
is NoSndFileUser -> "noSndFileUser"
|
||||
is NoRcvFileUser -> "noRcvFileUser"
|
||||
is UserUnknown -> "userUnknown"
|
||||
is ActiveUserExists -> "activeUserExists"
|
||||
is UserExists -> "userExists"
|
||||
is DifferentActiveUser -> "differentActiveUser"
|
||||
is CantDeleteActiveUser -> "cantDeleteActiveUser"
|
||||
is CantDeleteLastUser -> "cantDeleteLastUser"
|
||||
is CantHideLastUser -> "cantHideLastUser"
|
||||
is HiddenUserAlwaysMuted -> "hiddenUserAlwaysMuted"
|
||||
is EmptyUserPassword -> "emptyUserPassword"
|
||||
is UserAlreadyHidden -> "userAlreadyHidden"
|
||||
is UserNotHidden -> "userNotHidden"
|
||||
is ChatNotStarted -> "chatNotStarted"
|
||||
is ChatNotStopped -> "chatNotStopped"
|
||||
is ChatStoreChanged -> "chatStoreChanged"
|
||||
is InvalidConnReq -> "invalidConnReq"
|
||||
is InvalidChatMessage -> "invalidChatMessage"
|
||||
is ContactNotReady -> "contactNotReady"
|
||||
is ContactDisabled -> "contactDisabled"
|
||||
is ConnectionDisabled -> "connectionDisabled"
|
||||
is GroupUserRole -> "groupUserRole"
|
||||
is GroupMemberInitialRole -> "groupMemberInitialRole"
|
||||
is ContactIncognitoCantInvite -> "contactIncognitoCantInvite"
|
||||
is GroupIncognitoCantInvite -> "groupIncognitoCantInvite"
|
||||
is GroupContactRole -> "groupContactRole"
|
||||
is GroupDuplicateMember -> "groupDuplicateMember"
|
||||
is GroupDuplicateMemberId -> "groupDuplicateMemberId"
|
||||
is GroupNotJoined -> "groupNotJoined"
|
||||
is GroupMemberNotActive -> "groupMemberNotActive"
|
||||
is GroupMemberUserRemoved -> "groupMemberUserRemoved"
|
||||
is GroupMemberNotFound -> "groupMemberNotFound"
|
||||
is GroupMemberIntroNotFound -> "groupMemberIntroNotFound"
|
||||
is GroupCantResendInvitation -> "groupCantResendInvitation"
|
||||
is GroupInternal -> "groupInternal"
|
||||
is FileNotFound -> "fileNotFound"
|
||||
is FileSize -> "fileSize"
|
||||
is FileAlreadyReceiving -> "fileAlreadyReceiving"
|
||||
is FileCancelled -> "fileCancelled"
|
||||
is FileCancel -> "fileCancel"
|
||||
is FileAlreadyExists -> "fileAlreadyExists"
|
||||
is FileRead -> "fileRead"
|
||||
is FileWrite -> "fileWrite"
|
||||
is FileSend -> "fileSend"
|
||||
is FileRcvChunk -> "fileRcvChunk"
|
||||
is FileInternal -> "fileInternal"
|
||||
is FileImageType -> "fileImageType"
|
||||
is FileImageSize -> "fileImageSize"
|
||||
is FileNotReceived -> "fileNotReceived"
|
||||
// is XFTPRcvFile -> "xftpRcvFile"
|
||||
// is XFTPSndFile -> "xftpSndFile"
|
||||
is FallbackToSMPProhibited -> "fallbackToSMPProhibited"
|
||||
is InlineFileProhibited -> "inlineFileProhibited"
|
||||
is InvalidQuote -> "invalidQuote"
|
||||
is InvalidChatItemUpdate -> "invalidChatItemUpdate"
|
||||
is InvalidChatItemDelete -> "invalidChatItemDelete"
|
||||
is HasCurrentCall -> "hasCurrentCall"
|
||||
is NoCurrentCall -> "noCurrentCall"
|
||||
is CallContact -> "callContact"
|
||||
is CallState -> "callState"
|
||||
is DirectMessagesProhibited -> "directMessagesProhibited"
|
||||
is AgentVersion -> "agentVersion"
|
||||
is AgentNoSubResult -> "agentNoSubResult"
|
||||
is CommandError -> "commandError $message"
|
||||
is ServerProtocol -> "serverProtocol"
|
||||
is AgentCommandError -> "agentCommandError"
|
||||
is InvalidFileDescription -> "invalidFileDescription"
|
||||
is InternalError -> "internalError"
|
||||
is CEException -> "exception $message"
|
||||
}
|
||||
|
||||
@Serializable @SerialName("noActiveUser") object NoActiveUser: ChatErrorType()
|
||||
@Serializable @SerialName("noConnectionUser") class NoConnectionUser(val agentConnId: String): ChatErrorType()
|
||||
@Serializable @SerialName("noSndFileUser") class NoSndFileUser(val agentSndFileId: String): ChatErrorType()
|
||||
@Serializable @SerialName("noRcvFileUser") class NoRcvFileUser(val agentRcvFileId: String): ChatErrorType()
|
||||
@Serializable @SerialName("userUnknown") object UserUnknown: ChatErrorType()
|
||||
@Serializable @SerialName("activeUserExists") object ActiveUserExists: ChatErrorType()
|
||||
@Serializable @SerialName("userExists") class UserExists(val contactName: String): ChatErrorType()
|
||||
@Serializable @SerialName("invalidConnReq") class InvalidConnReq: ChatErrorType()
|
||||
@Serializable @SerialName("fileAlreadyReceiving") class FileAlreadyReceiving: ChatErrorType()
|
||||
@Serializable @SerialName("commandError") class СommandError(val message: String): ChatErrorType()
|
||||
@Serializable @SerialName("differentActiveUser") class DifferentActiveUser(val commandUserId: Long, val activeUserId: Long): ChatErrorType()
|
||||
@Serializable @SerialName("cantDeleteActiveUser") class CantDeleteActiveUser(val userId: Long): ChatErrorType()
|
||||
@Serializable @SerialName("cantDeleteLastUser") class CantDeleteLastUser(val userId: Long): ChatErrorType()
|
||||
@Serializable @SerialName("cantHideLastUser") class CantHideLastUser(val userId: Long): ChatErrorType()
|
||||
@Serializable @SerialName("hiddenUserAlwaysMuted") class HiddenUserAlwaysMuted(val userId: Long): ChatErrorType()
|
||||
@Serializable @SerialName("emptyUserPassword") class EmptyUserPassword(val userId: Long): ChatErrorType()
|
||||
@Serializable @SerialName("userAlreadyHidden") class UserAlreadyHidden(val userId: Long): ChatErrorType()
|
||||
@Serializable @SerialName("userNotHidden") class UserNotHidden(val userId: Long): ChatErrorType()
|
||||
@Serializable @SerialName("chatNotStarted") object ChatNotStarted: ChatErrorType()
|
||||
@Serializable @SerialName("chatNotStopped") object ChatNotStopped: ChatErrorType()
|
||||
@Serializable @SerialName("chatStoreChanged") object ChatStoreChanged: ChatErrorType()
|
||||
@Serializable @SerialName("invalidConnReq") object InvalidConnReq: ChatErrorType()
|
||||
@Serializable @SerialName("invalidChatMessage") class InvalidChatMessage(val connection: Connection, val message: String): ChatErrorType()
|
||||
@Serializable @SerialName("contactNotReady") class ContactNotReady(val contact: Contact): ChatErrorType()
|
||||
@Serializable @SerialName("contactDisabled") class ContactDisabled(val contact: Contact): ChatErrorType()
|
||||
@Serializable @SerialName("connectionDisabled") class ConnectionDisabled(val connection: Connection): ChatErrorType()
|
||||
@Serializable @SerialName("groupUserRole") class GroupUserRole(val groupInfo: GroupInfo, val requiredRole: GroupMemberRole): ChatErrorType()
|
||||
@Serializable @SerialName("groupMemberInitialRole") class GroupMemberInitialRole(val groupInfo: GroupInfo, val initialRole: GroupMemberRole): ChatErrorType()
|
||||
@Serializable @SerialName("contactIncognitoCantInvite") object ContactIncognitoCantInvite: ChatErrorType()
|
||||
@Serializable @SerialName("groupIncognitoCantInvite") object GroupIncognitoCantInvite: ChatErrorType()
|
||||
@Serializable @SerialName("groupContactRole") class GroupContactRole(val contactName: String): ChatErrorType()
|
||||
@Serializable @SerialName("groupDuplicateMember") class GroupDuplicateMember(val contactName: String): ChatErrorType()
|
||||
@Serializable @SerialName("groupDuplicateMemberId") object GroupDuplicateMemberId: ChatErrorType()
|
||||
@Serializable @SerialName("groupNotJoined") class GroupNotJoined(val groupInfo: GroupInfo): ChatErrorType()
|
||||
@Serializable @SerialName("groupMemberNotActive") object GroupMemberNotActive: ChatErrorType()
|
||||
@Serializable @SerialName("groupMemberUserRemoved") object GroupMemberUserRemoved: ChatErrorType()
|
||||
@Serializable @SerialName("groupMemberNotFound") object GroupMemberNotFound: ChatErrorType()
|
||||
@Serializable @SerialName("groupMemberIntroNotFound") class GroupMemberIntroNotFound(val contactName: String): ChatErrorType()
|
||||
@Serializable @SerialName("groupCantResendInvitation") class GroupCantResendInvitation(val groupInfo: GroupInfo, val contactName: String): ChatErrorType()
|
||||
@Serializable @SerialName("groupInternal") class GroupInternal(val message: String): ChatErrorType()
|
||||
@Serializable @SerialName("fileNotFound") class FileNotFound(val message: String): ChatErrorType()
|
||||
@Serializable @SerialName("fileSize") class FileSize(val filePath: String): ChatErrorType()
|
||||
@Serializable @SerialName("fileAlreadyReceiving") class FileAlreadyReceiving(val message: String): ChatErrorType()
|
||||
@Serializable @SerialName("fileCancelled") class FileCancelled(val message: String): ChatErrorType()
|
||||
@Serializable @SerialName("fileCancel") class FileCancel(val fileId: Long, val message: String): ChatErrorType()
|
||||
@Serializable @SerialName("fileAlreadyExists") class FileAlreadyExists(val filePath: String): ChatErrorType()
|
||||
@Serializable @SerialName("fileRead") class FileRead(val filePath: String, val message: String): ChatErrorType()
|
||||
@Serializable @SerialName("fileWrite") class FileWrite(val filePath: String, val message: String): ChatErrorType()
|
||||
@Serializable @SerialName("fileSend") class FileSend(val fileId: Long, val agentError: String): ChatErrorType()
|
||||
@Serializable @SerialName("fileRcvChunk") class FileRcvChunk(val message: String): ChatErrorType()
|
||||
@Serializable @SerialName("fileInternal") class FileInternal(val message: String): ChatErrorType()
|
||||
@Serializable @SerialName("fileImageType") class FileImageType(val filePath: String): ChatErrorType()
|
||||
@Serializable @SerialName("fileImageSize") class FileImageSize(val filePath: String): ChatErrorType()
|
||||
@Serializable @SerialName("fileNotReceived") class FileNotReceived(val fileId: Long): ChatErrorType()
|
||||
// @Serializable @SerialName("xFTPRcvFile") object XFTPRcvFile: ChatErrorType()
|
||||
// @Serializable @SerialName("xFTPSndFile") object XFTPSndFile: ChatErrorType()
|
||||
@Serializable @SerialName("fallbackToSMPProhibited") class FallbackToSMPProhibited(val fileId: Long): ChatErrorType()
|
||||
@Serializable @SerialName("inlineFileProhibited") class InlineFileProhibited(val fileId: Long): ChatErrorType()
|
||||
@Serializable @SerialName("invalidQuote") object InvalidQuote: ChatErrorType()
|
||||
@Serializable @SerialName("invalidChatItemUpdate") object InvalidChatItemUpdate: ChatErrorType()
|
||||
@Serializable @SerialName("invalidChatItemDelete") object InvalidChatItemDelete: ChatErrorType()
|
||||
@Serializable @SerialName("hasCurrentCall") object HasCurrentCall: ChatErrorType()
|
||||
@Serializable @SerialName("noCurrentCall") object NoCurrentCall: ChatErrorType()
|
||||
@Serializable @SerialName("callContact") class CallContact(val contactId: Long): ChatErrorType()
|
||||
@Serializable @SerialName("callState") object CallState: ChatErrorType()
|
||||
@Serializable @SerialName("directMessagesProhibited") class DirectMessagesProhibited(val contact: Contact): ChatErrorType()
|
||||
@Serializable @SerialName("agentVersion") object AgentVersion: ChatErrorType()
|
||||
@Serializable @SerialName("agentNoSubResult") class AgentNoSubResult(val agentConnId: String): ChatErrorType()
|
||||
@Serializable @SerialName("commandError") class CommandError(val message: String): ChatErrorType()
|
||||
@Serializable @SerialName("serverProtocol") object ServerProtocol: ChatErrorType()
|
||||
@Serializable @SerialName("agentCommandError") class AgentCommandError(val message: String): ChatErrorType()
|
||||
@Serializable @SerialName("invalidFileDescription") class InvalidFileDescription(val message: String): ChatErrorType()
|
||||
@Serializable @SerialName("internalError") class InternalError(val message: String): ChatErrorType()
|
||||
@Serializable @SerialName("exception") class CEException(val message: String): ChatErrorType()
|
||||
}
|
||||
|
||||
@Serializable
|
||||
sealed class StoreError {
|
||||
val string: String get() = when (this) {
|
||||
is UserContactLinkNotFound -> "userContactLinkNotFound"
|
||||
is GroupNotFound -> "groupNotFound"
|
||||
is DuplicateName -> "duplicateName"
|
||||
}
|
||||
@Serializable @SerialName("userContactLinkNotFound") class UserContactLinkNotFound: StoreError()
|
||||
@Serializable @SerialName("groupNotFound") class GroupNotFound: StoreError()
|
||||
@Serializable @SerialName("duplicateName") class DuplicateName: StoreError()
|
||||
val string: String
|
||||
get() = when (this) {
|
||||
is DuplicateName -> "duplicateName"
|
||||
is UserNotFound -> "userNotFound"
|
||||
is UserNotFoundByName -> "userNotFoundByName"
|
||||
is UserNotFoundByContactId -> "userNotFoundByContactId"
|
||||
is UserNotFoundByGroupId -> "userNotFoundByGroupId"
|
||||
is UserNotFoundByFileId -> "userNotFoundByFileId"
|
||||
is UserNotFoundByContactRequestId -> "userNotFoundByContactRequestId"
|
||||
is ContactNotFound -> "contactNotFound"
|
||||
is ContactNotFoundByName -> "contactNotFoundByName"
|
||||
is ContactNotReady -> "contactNotReady"
|
||||
is DuplicateContactLink -> "duplicateContactLink"
|
||||
is UserContactLinkNotFound -> "userContactLinkNotFound"
|
||||
is ContactRequestNotFound -> "contactRequestNotFound"
|
||||
is ContactRequestNotFoundByName -> "contactRequestNotFoundByName"
|
||||
is GroupNotFound -> "groupNotFound"
|
||||
is GroupNotFoundByName -> "groupNotFoundByName"
|
||||
is GroupMemberNameNotFound -> "groupMemberNameNotFound"
|
||||
is GroupMemberNotFound -> "groupMemberNotFound"
|
||||
is GroupMemberNotFoundByMemberId -> "groupMemberNotFoundByMemberId"
|
||||
is GroupWithoutUser -> "groupWithoutUser"
|
||||
is DuplicateGroupMember -> "duplicateGroupMember"
|
||||
is GroupAlreadyJoined -> "groupAlreadyJoined"
|
||||
is GroupInvitationNotFound -> "groupInvitationNotFound"
|
||||
is SndFileNotFound -> "sndFileNotFound"
|
||||
is SndFileInvalid -> "sndFileInvalid"
|
||||
is RcvFileNotFound -> "rcvFileNotFound"
|
||||
is RcvFileDescrNotFound -> "rcvFileDescrNotFound"
|
||||
is FileNotFound -> "fileNotFound"
|
||||
is RcvFileInvalid -> "rcvFileInvalid"
|
||||
is RcvFileInvalidDescrPart -> "rcvFileInvalidDescrPart"
|
||||
is SharedMsgIdNotFoundByFileId -> "sharedMsgIdNotFoundByFileId"
|
||||
is FileIdNotFoundBySharedMsgId -> "fileIdNotFoundBySharedMsgId"
|
||||
is SndFileNotFoundXFTP -> "sndFileNotFoundXFTP"
|
||||
is RcvFileNotFoundXFTP -> "rcvFileNotFoundXFTP"
|
||||
is ConnectionNotFound -> "connectionNotFound"
|
||||
is ConnectionNotFoundById -> "connectionNotFoundById"
|
||||
is PendingConnectionNotFound -> "pendingConnectionNotFound"
|
||||
is IntroNotFound -> "introNotFound"
|
||||
is UniqueID -> "uniqueID"
|
||||
is InternalError -> "internalError"
|
||||
is NoMsgDelivery -> "noMsgDelivery"
|
||||
is BadChatItem -> "badChatItem"
|
||||
is ChatItemNotFound -> "chatItemNotFound"
|
||||
is ChatItemNotFoundByText -> "chatItemNotFoundByText"
|
||||
is ChatItemSharedMsgIdNotFound -> "chatItemSharedMsgIdNotFound"
|
||||
is ChatItemNotFoundByFileId -> "chatItemNotFoundByFileId"
|
||||
is ChatItemNotFoundByGroupId -> "chatItemNotFoundByGroupId"
|
||||
is ProfileNotFound -> "profileNotFound"
|
||||
is DuplicateGroupLink -> "duplicateGroupLink"
|
||||
is GroupLinkNotFound -> "groupLinkNotFound"
|
||||
is HostMemberIdNotFound -> "hostMemberIdNotFound"
|
||||
is ContactNotFoundByFileId -> "contactNotFoundByFileId"
|
||||
is NoGroupSndStatus -> "noGroupSndStatus"
|
||||
}
|
||||
|
||||
@Serializable @SerialName("duplicateName") object DuplicateName: StoreError()
|
||||
@Serializable @SerialName("userNotFound") class UserNotFound(val userId: Long): StoreError()
|
||||
@Serializable @SerialName("userNotFoundByName") class UserNotFoundByName(val contactName: String): StoreError()
|
||||
@Serializable @SerialName("userNotFoundByContactId") class UserNotFoundByContactId(val contactId: Long): StoreError()
|
||||
@Serializable @SerialName("userNotFoundByGroupId") class UserNotFoundByGroupId(val groupId: Long): StoreError()
|
||||
@Serializable @SerialName("userNotFoundByFileId") class UserNotFoundByFileId(val fileId: Long): StoreError()
|
||||
@Serializable @SerialName("userNotFoundByContactRequestId") class UserNotFoundByContactRequestId(val contactRequestId: Long): StoreError()
|
||||
@Serializable @SerialName("contactNotFound") class ContactNotFound(val contactId: Long): StoreError()
|
||||
@Serializable @SerialName("contactNotFoundByName") class ContactNotFoundByName(val contactName: String): StoreError()
|
||||
@Serializable @SerialName("contactNotReady") class ContactNotReady(val contactName: String): StoreError()
|
||||
@Serializable @SerialName("duplicateContactLink") object DuplicateContactLink: StoreError()
|
||||
@Serializable @SerialName("userContactLinkNotFound") object UserContactLinkNotFound: StoreError()
|
||||
@Serializable @SerialName("contactRequestNotFound") class ContactRequestNotFound(val contactRequestId: Long): StoreError()
|
||||
@Serializable @SerialName("contactRequestNotFoundByName") class ContactRequestNotFoundByName(val contactName: String): StoreError()
|
||||
@Serializable @SerialName("groupNotFound") class GroupNotFound(val groupId: Long): StoreError()
|
||||
@Serializable @SerialName("groupNotFoundByName") class GroupNotFoundByName(val groupName: String): StoreError()
|
||||
@Serializable @SerialName("groupMemberNameNotFound") class GroupMemberNameNotFound(val groupId: Long, val groupMemberName: String): StoreError()
|
||||
@Serializable @SerialName("groupMemberNotFound") class GroupMemberNotFound(val groupMemberId: Long): StoreError()
|
||||
@Serializable @SerialName("groupMemberNotFoundByMemberId") class GroupMemberNotFoundByMemberId(val memberId: String): StoreError()
|
||||
@Serializable @SerialName("groupWithoutUser") object GroupWithoutUser: StoreError()
|
||||
@Serializable @SerialName("duplicateGroupMember") object DuplicateGroupMember: StoreError()
|
||||
@Serializable @SerialName("groupAlreadyJoined") object GroupAlreadyJoined: StoreError()
|
||||
@Serializable @SerialName("groupInvitationNotFound") object GroupInvitationNotFound: StoreError()
|
||||
@Serializable @SerialName("sndFileNotFound") class SndFileNotFound(val fileId: Long): StoreError()
|
||||
@Serializable @SerialName("sndFileInvalid") class SndFileInvalid(val fileId: Long): StoreError()
|
||||
@Serializable @SerialName("rcvFileNotFound") class RcvFileNotFound(val fileId: Long): StoreError()
|
||||
@Serializable @SerialName("rcvFileDescrNotFound") class RcvFileDescrNotFound(val fileId: Long): StoreError()
|
||||
@Serializable @SerialName("fileNotFound") class FileNotFound(val fileId: Long): StoreError()
|
||||
@Serializable @SerialName("rcvFileInvalid") class RcvFileInvalid(val fileId: Long): StoreError()
|
||||
@Serializable @SerialName("rcvFileInvalidDescrPart") object RcvFileInvalidDescrPart: StoreError()
|
||||
@Serializable @SerialName("sharedMsgIdNotFoundByFileId") class SharedMsgIdNotFoundByFileId(val fileId: Long): StoreError()
|
||||
@Serializable @SerialName("fileIdNotFoundBySharedMsgId") class FileIdNotFoundBySharedMsgId(val sharedMsgId: String): StoreError()
|
||||
@Serializable @SerialName("sndFileNotFoundXFTP") class SndFileNotFoundXFTP(val agentSndFileId: String): StoreError()
|
||||
@Serializable @SerialName("rcvFileNotFoundXFTP") class RcvFileNotFoundXFTP(val agentRcvFileId: String): StoreError()
|
||||
@Serializable @SerialName("connectionNotFound") class ConnectionNotFound(val agentConnId: String): StoreError()
|
||||
@Serializable @SerialName("connectionNotFoundById") class ConnectionNotFoundById(val connId: Long): StoreError()
|
||||
@Serializable @SerialName("pendingConnectionNotFound") class PendingConnectionNotFound(val connId: Long): StoreError()
|
||||
@Serializable @SerialName("introNotFound") object IntroNotFound: StoreError()
|
||||
@Serializable @SerialName("uniqueID") object UniqueID: StoreError()
|
||||
@Serializable @SerialName("internalError") class InternalError(val message: String): StoreError()
|
||||
@Serializable @SerialName("noMsgDelivery") class NoMsgDelivery(val connId: Long, val agentMsgId: String): StoreError()
|
||||
@Serializable @SerialName("badChatItem") class BadChatItem(val itemId: Long): StoreError()
|
||||
@Serializable @SerialName("chatItemNotFound") class ChatItemNotFound(val itemId: Long): StoreError()
|
||||
@Serializable @SerialName("chatItemNotFoundByText") class ChatItemNotFoundByText(val text: String): StoreError()
|
||||
@Serializable @SerialName("chatItemSharedMsgIdNotFound") class ChatItemSharedMsgIdNotFound(val sharedMsgId: String): StoreError()
|
||||
@Serializable @SerialName("chatItemNotFoundByFileId") class ChatItemNotFoundByFileId(val fileId: Long): StoreError()
|
||||
@Serializable @SerialName("chatItemNotFoundByGroupId") class ChatItemNotFoundByGroupId(val groupId: Long): StoreError()
|
||||
@Serializable @SerialName("profileNotFound") class ProfileNotFound(val profileId: Long): StoreError()
|
||||
@Serializable @SerialName("duplicateGroupLink") class DuplicateGroupLink(val groupInfo: GroupInfo): StoreError()
|
||||
@Serializable @SerialName("groupLinkNotFound") class GroupLinkNotFound(val groupInfo: GroupInfo): StoreError()
|
||||
@Serializable @SerialName("hostMemberIdNotFound") class HostMemberIdNotFound(val groupId: Long): StoreError()
|
||||
@Serializable @SerialName("contactNotFoundByFileId") class ContactNotFoundByFileId(val fileId: Long): StoreError()
|
||||
@Serializable @SerialName("noGroupSndStatus") class NoGroupSndStatus(val itemId: Long, val groupMemberId: Long): StoreError()
|
||||
}
|
||||
|
||||
@Serializable
|
||||
@@ -3773,18 +4041,22 @@ sealed class AgentErrorType {
|
||||
is CMD -> "CMD ${cmdErr.string}"
|
||||
is CONN -> "CONN ${connErr.string}"
|
||||
is SMP -> "SMP ${smpErr.string}"
|
||||
// is NTF -> "NTF ${ntfErr.string}"
|
||||
is XFTP -> "XFTP ${xftpErr.string}"
|
||||
is BROKER -> "BROKER ${brokerErr.string}"
|
||||
is AGENT -> "AGENT ${agentErr.string}"
|
||||
is INTERNAL -> "INTERNAL $internalErr"
|
||||
is INACTIVE -> "INACTIVE"
|
||||
}
|
||||
@Serializable @SerialName("CMD") class CMD(val cmdErr: CommandErrorType): AgentErrorType()
|
||||
@Serializable @SerialName("CONN") class CONN(val connErr: ConnectionErrorType): AgentErrorType()
|
||||
@Serializable @SerialName("SMP") class SMP(val smpErr: SMPErrorType): AgentErrorType()
|
||||
// @Serializable @SerialName("NTF") class NTF(val ntfErr: SMPErrorType): AgentErrorType()
|
||||
@Serializable @SerialName("XFTP") class XFTP(val xftpErr: XFTPErrorType): AgentErrorType()
|
||||
@Serializable @SerialName("BROKER") class BROKER(val brokerAddress: String, val brokerErr: BrokerErrorType): AgentErrorType()
|
||||
@Serializable @SerialName("AGENT") class AGENT(val agentErr: SMPAgentError): AgentErrorType()
|
||||
@Serializable @SerialName("INTERNAL") class INTERNAL(val internalErr: String): AgentErrorType()
|
||||
@Serializable @SerialName("INACTIVE") object INACTIVE: AgentErrorType()
|
||||
}
|
||||
|
||||
@Serializable
|
||||
@@ -3822,17 +4094,19 @@ sealed class ConnectionErrorType {
|
||||
@Serializable
|
||||
sealed class BrokerErrorType {
|
||||
val string: String get() = when (this) {
|
||||
is RESPONSE -> "RESPONSE ${smpErr.string}"
|
||||
is RESPONSE -> "RESPONSE ${smpErr}"
|
||||
is UNEXPECTED -> "UNEXPECTED"
|
||||
is NETWORK -> "NETWORK"
|
||||
is HOST -> "HOST"
|
||||
is TRANSPORT -> "TRANSPORT ${transportErr.string}"
|
||||
is TIMEOUT -> "TIMEOUT"
|
||||
}
|
||||
@Serializable @SerialName("RESPONSE") class RESPONSE(val smpErr: SMPErrorType): BrokerErrorType()
|
||||
@Serializable @SerialName("UNEXPECTED") class UNEXPECTED: BrokerErrorType()
|
||||
@Serializable @SerialName("NETWORK") class NETWORK: BrokerErrorType()
|
||||
@Serializable @SerialName("RESPONSE") class RESPONSE(val smpErr: String): BrokerErrorType()
|
||||
@Serializable @SerialName("UNEXPECTED") object UNEXPECTED: BrokerErrorType()
|
||||
@Serializable @SerialName("NETWORK") object NETWORK: BrokerErrorType()
|
||||
@Serializable @SerialName("HOST") object HOST: BrokerErrorType()
|
||||
@Serializable @SerialName("TRANSPORT") class TRANSPORT(val transportErr: SMPTransportError): BrokerErrorType()
|
||||
@Serializable @SerialName("TIMEOUT") class TIMEOUT: BrokerErrorType()
|
||||
@Serializable @SerialName("TIMEOUT") object TIMEOUT: BrokerErrorType()
|
||||
}
|
||||
|
||||
@Serializable
|
||||
@@ -3862,15 +4136,17 @@ sealed class ProtocolCommandError {
|
||||
val string: String get() = when (this) {
|
||||
is UNKNOWN -> "UNKNOWN"
|
||||
is SYNTAX -> "SYNTAX"
|
||||
is PROHIBITED -> "PROHIBITED"
|
||||
is NO_AUTH -> "NO_AUTH"
|
||||
is HAS_AUTH -> "HAS_AUTH"
|
||||
is NO_QUEUE -> "NO_QUEUE"
|
||||
}
|
||||
@Serializable @SerialName("UNKNOWN") class UNKNOWN: ProtocolCommandError()
|
||||
@Serializable @SerialName("SYNTAX") class SYNTAX: ProtocolCommandError()
|
||||
@Serializable @SerialName("NO_AUTH") class NO_AUTH: ProtocolCommandError()
|
||||
@Serializable @SerialName("HAS_AUTH") class HAS_AUTH: ProtocolCommandError()
|
||||
@Serializable @SerialName("NO_QUEUE") class NO_QUEUE: ProtocolCommandError()
|
||||
@Serializable @SerialName("UNKNOWN") object UNKNOWN: ProtocolCommandError()
|
||||
@Serializable @SerialName("SYNTAX") object SYNTAX: ProtocolCommandError()
|
||||
@Serializable @SerialName("PROHIBITED") object PROHIBITED: ProtocolCommandError()
|
||||
@Serializable @SerialName("NO_AUTH") object NO_AUTH: ProtocolCommandError()
|
||||
@Serializable @SerialName("HAS_AUTH") object HAS_AUTH: ProtocolCommandError()
|
||||
@Serializable @SerialName("NO_QUEUE") object NO_QUEUE: ProtocolCommandError()
|
||||
}
|
||||
|
||||
@Serializable
|
||||
@@ -3905,12 +4181,16 @@ sealed class SMPAgentError {
|
||||
is A_MESSAGE -> "A_MESSAGE"
|
||||
is A_PROHIBITED -> "A_PROHIBITED"
|
||||
is A_VERSION -> "A_VERSION"
|
||||
is A_ENCRYPTION -> "A_ENCRYPTION"
|
||||
is A_CRYPTO -> "A_CRYPTO"
|
||||
is A_DUPLICATE -> "A_DUPLICATE"
|
||||
is A_QUEUE -> "A_QUEUE"
|
||||
}
|
||||
@Serializable @SerialName("A_MESSAGE") class A_MESSAGE: SMPAgentError()
|
||||
@Serializable @SerialName("A_PROHIBITED") class A_PROHIBITED: SMPAgentError()
|
||||
@Serializable @SerialName("A_VERSION") class A_VERSION: SMPAgentError()
|
||||
@Serializable @SerialName("A_ENCRYPTION") class A_ENCRYPTION: SMPAgentError()
|
||||
@Serializable @SerialName("A_MESSAGE") object A_MESSAGE: SMPAgentError()
|
||||
@Serializable @SerialName("A_PROHIBITED") object A_PROHIBITED: SMPAgentError()
|
||||
@Serializable @SerialName("A_VERSION") object A_VERSION: SMPAgentError()
|
||||
@Serializable @SerialName("A_CRYPTO") object A_CRYPTO: SMPAgentError()
|
||||
@Serializable @SerialName("A_DUPLICATE") object A_DUPLICATE: SMPAgentError()
|
||||
@Serializable @SerialName("A_QUEUE") class A_QUEUE(val queueErr: String): SMPAgentError()
|
||||
}
|
||||
|
||||
@Serializable
|
||||
@@ -3952,3 +4232,12 @@ sealed class ArchiveError {
|
||||
@Serializable @SerialName("import") class ArchiveErrorImport(val chatError: ChatError): ArchiveError()
|
||||
@Serializable @SerialName("importFile") class ArchiveErrorImportFile(val file: String, val chatError: ChatError): ArchiveError()
|
||||
}
|
||||
|
||||
|
||||
enum class NotificationsMode() {
|
||||
OFF, PERIODIC, SERVICE, /*INSTANT - for Firebase notifications */;
|
||||
|
||||
companion object {
|
||||
val default: NotificationsMode = SERVICE
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -2,7 +2,7 @@ package com.sd.lib.compose.wheel_picker
|
||||
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import chat.simplex.common.ui.theme.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
package chat.simplex.common.platform
|
||||
|
||||
import chat.simplex.common.BuildConfigCommon
|
||||
import chat.simplex.common.model.ChatController
|
||||
import chat.simplex.common.ui.theme.DefaultTheme
|
||||
import java.util.*
|
||||
|
||||
enum class AppPlatform {
|
||||
ANDROID, DESKTOP;
|
||||
|
||||
val isAndroid: Boolean
|
||||
get() = this == ANDROID
|
||||
|
||||
val isDesktop: Boolean
|
||||
get() = this == DESKTOP
|
||||
}
|
||||
|
||||
expect val appPlatform: AppPlatform
|
||||
|
||||
val appVersionInfo: Pair<String, Int?> = if (appPlatform == AppPlatform.ANDROID)
|
||||
BuildConfigCommon.ANDROID_VERSION_NAME to BuildConfigCommon.ANDROID_VERSION_CODE
|
||||
else
|
||||
BuildConfigCommon.DESKTOP_VERSION_NAME to null
|
||||
|
||||
class FifoQueue<E>(private var capacity: Int) : LinkedList<E>() {
|
||||
override fun add(element: E): Boolean {
|
||||
if(size > capacity) removeFirst()
|
||||
return super.add(element)
|
||||
}
|
||||
}
|
||||
|
||||
// LALAL VERSION CODE
|
||||
fun runMigrations() {
|
||||
val lastMigration = ChatController.appPrefs.lastMigratedVersionCode
|
||||
if (lastMigration.get() < BuildConfigCommon.ANDROID_VERSION_CODE) {
|
||||
while (true) {
|
||||
if (lastMigration.get() < 117) {
|
||||
if (ChatController.appPrefs.currentTheme.get() == DefaultTheme.DARK.name) {
|
||||
ChatController.appPrefs.currentTheme.set(DefaultTheme.SIMPLEX.name)
|
||||
}
|
||||
lastMigration.set(117)
|
||||
} else {
|
||||
lastMigration.set(BuildConfigCommon.ANDROID_VERSION_CODE)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package chat.simplex.common.platform
|
||||
|
||||
import androidx.compose.runtime.*
|
||||
|
||||
@SuppressWarnings("MissingJvmstatic")
|
||||
@Composable
|
||||
expect fun BackHandler(enabled: Boolean = true, onBack: () -> Unit)
|
||||
@@ -0,0 +1,70 @@
|
||||
package chat.simplex.common.platform
|
||||
|
||||
import chat.simplex.common.model.*
|
||||
import chat.simplex.common.views.helpers.*
|
||||
import chat.simplex.common.views.onboarding.OnboardingStage
|
||||
import kotlinx.serialization.decodeFromString
|
||||
|
||||
// ghc's rts
|
||||
external fun initHS()
|
||||
// android-support
|
||||
external fun pipeStdOutToSocket(socketName: String) : Int
|
||||
|
||||
// SimpleX API
|
||||
typealias ChatCtrl = Long
|
||||
external fun chatMigrateInit(dbPath: String, dbKey: String, confirm: String): Array<Any>
|
||||
external fun chatSendCmd(ctrl: ChatCtrl, msg: String): String
|
||||
external fun chatRecvMsg(ctrl: ChatCtrl): String
|
||||
external fun chatRecvMsgWait(ctrl: ChatCtrl, timeout: Int): String
|
||||
external fun chatParseMarkdown(str: String): String
|
||||
external fun chatParseServer(str: String): String
|
||||
external fun chatPasswordHash(pwd: String, salt: String): String
|
||||
|
||||
val chatModel: ChatModel
|
||||
get() = chatController.chatModel
|
||||
|
||||
val appPreferences: AppPreferences
|
||||
get() = chatController.appPrefs
|
||||
|
||||
val chatController: ChatController = ChatController
|
||||
|
||||
suspend fun initChatController(useKey: String? = null, confirmMigrations: MigrationConfirmation? = null, startChat: Boolean = true) {
|
||||
val dbKey = useKey ?: DatabaseUtils.useDatabaseKey()
|
||||
val confirm = confirmMigrations ?: if (appPreferences.confirmDBUpgrades.get()) MigrationConfirmation.Error else MigrationConfirmation.YesUp
|
||||
val migrated: Array<Any> = chatMigrateInit(dbAbsolutePrefixPath, dbKey, confirm.value)
|
||||
val res: DBMigrationResult = kotlin.runCatching {
|
||||
json.decodeFromString<DBMigrationResult>(migrated[0] as String)
|
||||
}.getOrElse { DBMigrationResult.Unknown(migrated[0] as String) }
|
||||
val ctrl = if (res is DBMigrationResult.OK) {
|
||||
migrated[1] as Long
|
||||
} else null
|
||||
chatController.ctrl = ctrl
|
||||
chatModel.chatDbEncrypted.value = dbKey != ""
|
||||
chatModel.chatDbStatus.value = res
|
||||
if (res != DBMigrationResult.OK) {
|
||||
Log.d(TAG, "Unable to migrate successfully: $res")
|
||||
} else if (startChat) {
|
||||
// If we migrated successfully means previous re-encryption process on database level finished successfully too
|
||||
if (appPreferences.encryptionStartedAt.get() != null) appPreferences.encryptionStartedAt.set(null)
|
||||
val user = chatController.apiGetActiveUser()
|
||||
if (user == null) {
|
||||
chatModel.controller.appPrefs.onboardingStage.set(OnboardingStage.Step1_SimpleXInfo)
|
||||
chatModel.controller.appPrefs.privacyDeliveryReceiptsSet.set(true)
|
||||
chatModel.onboardingStage.value = OnboardingStage.Step1_SimpleXInfo
|
||||
chatModel.currentUser.value = null
|
||||
chatModel.users.clear()
|
||||
} else {
|
||||
val savedOnboardingStage = appPreferences.onboardingStage.get()
|
||||
chatModel.onboardingStage.value = if (listOf(OnboardingStage.Step1_SimpleXInfo, OnboardingStage.Step2_CreateProfile).contains(savedOnboardingStage) && chatModel.users.size == 1) {
|
||||
OnboardingStage.Step3_CreateSimpleXAddress
|
||||
} else {
|
||||
savedOnboardingStage
|
||||
}
|
||||
if (chatModel.onboardingStage.value == OnboardingStage.OnboardingComplete && !chatModel.controller.appPrefs.privacyDeliveryReceiptsSet.get()) {
|
||||
chatModel.setDeliveryReceipts.value = true
|
||||
}
|
||||
chatController.startChat(user)
|
||||
platform.androidChatInitializedAndStarted()
|
||||
}
|
||||
}
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
package chat.simplex.common.platform
|
||||
|
||||
interface CryptorInterface {
|
||||
fun decryptData(data: ByteArray, iv: ByteArray, alias: String): String?
|
||||
fun encryptText(text: String, alias: String): Pair<ByteArray, ByteArray>
|
||||
fun deleteKey(alias: String)
|
||||
}
|
||||
|
||||
expect val cryptor: CryptorInterface
|
||||
@@ -0,0 +1,88 @@
|
||||
package chat.simplex.common.platform
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import chat.simplex.common.model.CIFile
|
||||
import chat.simplex.common.views.helpers.generalGetString
|
||||
import chat.simplex.res.MR
|
||||
import java.io.*
|
||||
import java.net.URI
|
||||
|
||||
expect val dataDir: File
|
||||
expect val tmpDir: File
|
||||
expect val filesDir: File
|
||||
expect val appFilesDir: File
|
||||
expect val coreTmpDir: File
|
||||
expect val dbAbsolutePrefixPath: String
|
||||
|
||||
expect val chatDatabaseFileName: String
|
||||
expect val agentDatabaseFileName: String
|
||||
|
||||
/**
|
||||
* This is used only for temporary storing db archive for export.
|
||||
* Providing [tmpDir] instead crashes the app. Check db export before moving from this path to something else
|
||||
* */
|
||||
expect val databaseExportDir: File
|
||||
|
||||
fun copyFileToFile(from: File, to: URI, finally: () -> Unit) {
|
||||
try {
|
||||
to.outputStream().use { stream ->
|
||||
BufferedOutputStream(stream).use { outputStream ->
|
||||
from.inputStream().use { it.copyTo(outputStream) }
|
||||
}
|
||||
}
|
||||
showToast(generalGetString(MR.strings.file_saved))
|
||||
} catch (e: Error) {
|
||||
showToast(generalGetString(MR.strings.error_saving_file))
|
||||
Log.e(TAG, "copyFileToFile error saving file $e")
|
||||
} finally {
|
||||
finally()
|
||||
}
|
||||
}
|
||||
|
||||
fun copyBytesToFile(bytes: ByteArrayInputStream, to: URI, finally: () -> Unit) {
|
||||
try {
|
||||
to.outputStream().use { stream ->
|
||||
BufferedOutputStream(stream).use { outputStream ->
|
||||
bytes.use { it.copyTo(outputStream) }
|
||||
}
|
||||
}
|
||||
showToast(generalGetString(MR.strings.file_saved))
|
||||
} catch (e: Error) {
|
||||
showToast(generalGetString(MR.strings.error_saving_file))
|
||||
Log.e(TAG, "copyBytesToFile error saving file $e")
|
||||
} finally {
|
||||
finally()
|
||||
}
|
||||
}
|
||||
|
||||
fun getAppFilePath(fileName: String): String {
|
||||
return appFilesDir.absolutePath + File.separator + fileName
|
||||
}
|
||||
|
||||
fun getLoadedFilePath(file: CIFile?): String? {
|
||||
return if (file?.filePath != null && file.loaded) {
|
||||
val filePath = getAppFilePath(file.filePath)
|
||||
if (File(filePath).exists()) filePath else null
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* [rememberedValue] is used in `remember(rememberedValue)`. So when the value changes, file saver will update a callback function
|
||||
* */
|
||||
@Composable
|
||||
expect fun rememberFileChooserLauncher(getContent: Boolean, rememberedValue: Any? = null, onResult: (URI?) -> Unit): FileChooserLauncher
|
||||
|
||||
expect fun rememberFileChooserMultipleLauncher(onResult: (List<URI>) -> Unit): FileChooserMultipleLauncher
|
||||
|
||||
expect class FileChooserLauncher() {
|
||||
suspend fun launch(input: String)
|
||||
}
|
||||
|
||||
expect class FileChooserMultipleLauncher() {
|
||||
suspend fun launch(input: String)
|
||||
}
|
||||
|
||||
expect fun URI.inputStream(): InputStream?
|
||||
expect fun URI.outputStream(): OutputStream
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
package chat.simplex.common.platform
|
||||
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import boofcv.struct.image.GrayU8
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.InputStream
|
||||
import java.net.URI
|
||||
|
||||
expect fun base64ToBitmap(base64ImageString: String): ImageBitmap
|
||||
expect fun resizeImageToStrSize(image: ImageBitmap, maxDataSize: Long): String
|
||||
expect fun resizeImageToDataSize(image: ImageBitmap, usePng: Boolean, maxDataSize: Long): ByteArrayOutputStream
|
||||
expect fun cropToSquare(image: ImageBitmap): ImageBitmap
|
||||
expect fun compressImageStr(bitmap: ImageBitmap): String
|
||||
expect fun compressImageData(bitmap: ImageBitmap, usePng: Boolean): ByteArrayOutputStream
|
||||
|
||||
expect fun GrayU8.toImageBitmap(): ImageBitmap
|
||||
|
||||
expect fun ImageBitmap.addLogo(): ImageBitmap
|
||||
expect fun ImageBitmap.scale(width: Int, height: Int): ImageBitmap
|
||||
|
||||
expect fun isImage(uri: URI): Boolean
|
||||
expect fun isAnimImage(uri: URI, drawable: Any?): Boolean
|
||||
|
||||
expect fun loadImageBitmap(inputStream: InputStream): ImageBitmap
|
||||
@@ -0,0 +1,10 @@
|
||||
package chat.simplex.common.platform
|
||||
|
||||
const val TAG = "SIMPLEX"
|
||||
|
||||
expect object Log {
|
||||
fun d(tag: String, text: String)
|
||||
fun e(tag: String, text: String)
|
||||
fun i(tag: String, text: String)
|
||||
fun w(tag: String, text: String)
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
package chat.simplex.common.platform
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.painter.Painter
|
||||
|
||||
expect fun Modifier.navigationBarsWithImePadding(): Modifier
|
||||
|
||||
@Composable
|
||||
expect fun ProvideWindowInsets(
|
||||
consumeWindowInsets: Boolean = true,
|
||||
windowInsetsAnimationsEnabled: Boolean = true,
|
||||
content: @Composable () -> Unit
|
||||
)
|
||||
|
||||
@Composable
|
||||
expect fun Modifier.desktopOnExternalDrag(
|
||||
enabled: Boolean = true,
|
||||
onFiles: (List<String>) -> Unit = {},
|
||||
onImage: (Painter) -> Unit = {},
|
||||
onText: (String) -> Unit = {}
|
||||
): Modifier
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
package chat.simplex.common.platform
|
||||
|
||||
expect fun allowedToShowNotification(): Boolean
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user