Compare commits

..

16 Commits

Author SHA1 Message Date
Evgeny Poberezkin
fde3c4f4e0 ios: 5.2.2, build 165 2023-08-07 20:42:09 +01:00
Stanislav Dmitrenko
f17889b3e3 android: 5.2.2, build 142 (revert to API 32) 2023-08-07 16:57:19 +01:00
Evgeny Poberezkin
5a5876c258 core: 5.2.2.0 2023-08-06 22:14:11 +01:00
Evgeny Poberezkin
e30f7695ab core, ios: fix markdown (#2837) 2023-08-02 16:22:20 +01:00
spaced4ndy
920b56e3d8 android: 5.2.1 (139) 2023-07-31 18:01:25 +04:00
spaced4ndy
dd51f032d2 ios: 5.2.1 (162) 2023-07-31 17:44:47 +04:00
spaced4ndy
1bdbea4f6d ios: update library (5.2.1.1) 2023-07-31 17:23:16 +04:00
spaced4ndy
90be54ff82 core: 5.2.1.1 2023-07-31 17:00:28 +04:00
Evgeny Poberezkin
bd4b445cbf mobile: increase default network timeouts (#2801)
* ios: increase default network timeouts

* android: increase default timeouts

---------

Co-authored-by: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com>
2023-07-31 16:56:23 +04:00
spaced4ndy
af98e703ec ios: update library (status parsing) (#2811) 2023-07-31 16:16:29 +04:00
spaced4ndy
fff8935b94 core: fix status parsing 2 (#2809) 2023-07-31 15:26:29 +04:00
spaced4ndy
9e7a45c734 core: fix status parsing (#2807) 2023-07-31 14:20:24 +04:00
spaced4ndy
af33f4e2d9 core: 5.2.1.0 2023-07-31 12:18:38 +04:00
spaced4ndy
98e53fb35b core: read unparsable item status as unknown (#2805) 2023-07-31 11:54:39 +04:00
Evgeny Poberezkin
cb4aa29549 core: update preset smp relays (#2804) 2023-07-30 15:15:41 +01:00
Michael Sjöberg
8fc3f5a0f7 Fixed typo in en.json (#2773)
mesages -> messages
2023-07-27 20:26:10 +01:00
316 changed files with 5309 additions and 9631 deletions

View File

@@ -171,12 +171,6 @@ 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))
}

View File

@@ -62,6 +62,7 @@ struct CIFileView: View {
case .rcvComplete: return true
case .rcvCancelled: return false
case .rcvError: return false
case .invalid: return false
}
}
return false
@@ -149,6 +150,7 @@ struct CIFileView: View {
case .rcvComplete: fileIcon("doc.fill")
case .rcvCancelled: fileIcon("doc.fill", innerIcon: "xmark", innerIconSize: 10)
case .rcvError: fileIcon("doc.fill", innerIcon: "xmark", innerIconSize: 10)
case .invalid: fileIcon("doc.fill", innerIcon: "questionmark", innerIconSize: 10)
}
} else {
fileIcon("doc.fill")
@@ -195,7 +197,7 @@ struct CIFileView_Previews: PreviewProvider {
static var previews: some View {
let sentFile: ChatItem = ChatItem(
chatDir: .directSnd,
meta: CIMeta.getSample(1, .now, "", .sndSent(sndProgress: .complete), itemEdited: true),
meta: CIMeta.getSample(1, .now, "", .sndSent, itemEdited: true),
content: .sndMsgContent(msgContent: .file("")),
quotedItem: nil,
file: CIFile.getSample(fileStatus: .sndComplete)

View File

@@ -99,6 +99,7 @@ struct CIImageView: View {
case .rcvTransfer: progressView()
case .rcvCancelled: fileIcon("xmark", 10, 13)
case .rcvError: fileIcon("xmark", 10, 13)
case .invalid: fileIcon("questionmark", 10, 13)
default: EmptyView()
}
}

View File

@@ -13,7 +13,6 @@ 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 {
@@ -22,23 +21,12 @@ struct CIMetaView: View {
let meta = chatItem.meta
let ttl = chat.chatInfo.timedMessagesTTL
switch meta.itemStatus {
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)
}
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)
}
default:
ciMetaText(meta, chatTTL: ttl, color: metaColor)
@@ -73,7 +61,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 : statusColor.opacity(0.67)) + gap
case .rcvd1: r = r + t.foregroundColor(transparent ? .clear : color.opacity(0.67)) + gap
case .rcvd2: r = r + gap + t1
}
r = r + Text(" ")
@@ -90,12 +78,8 @@ 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(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.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent))
CIMetaView(chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent, itemEdited: true))
CIMetaView(chatItem: ChatItem.getDeletedContentSample())
}
.previewLayout(.fixed(width: 360, height: 100))

View File

@@ -212,6 +212,7 @@ struct CIVideoView: View {
}
case .rcvCancelled: fileIcon("xmark", 10, 13)
case .rcvError: fileIcon("xmark", 10, 13)
case .invalid: fileIcon("questionmark", 10, 13)
default: EmptyView()
}
}

View File

@@ -144,6 +144,7 @@ struct VoiceMessagePlayer: View {
case .rcvComplete: playbackButton()
case .rcvCancelled: playPauseIcon("play.fill", Color(uiColor: .tertiaryLabel))
case .rcvError: playPauseIcon("play.fill", Color(uiColor: .tertiaryLabel))
case .invalid: playPauseIcon("play.fill", Color(uiColor: .tertiaryLabel))
}
} else {
playPauseIcon("play.fill", Color(uiColor: .tertiaryLabel))
@@ -268,7 +269,7 @@ struct CIVoiceView_Previews: PreviewProvider {
static var previews: some View {
let sentVoiceMessage: ChatItem = ChatItem(
chatDir: .directSnd,
meta: CIMeta.getSample(1, .now, "", .sndSent(sndProgress: .complete), itemEdited: true),
meta: CIMeta.getSample(1, .now, "", .sndSent, itemEdited: true),
content: .sndMsgContent(msgContent: .voice(text: "", duration: 30)),
quotedItem: nil,
file: CIFile.getSample(fileStatus: .sndComplete)

View File

@@ -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(sndProgress: .complete)))
EmojiItemView(chatItem: ChatItem.getSample(1, .directSnd, .now, "🙂", .sndSent))
EmojiItemView(chatItem: ChatItem.getSample(2, .directRcv, .now, "👍"))
}
.previewLayout(.fixed(width: 360, height: 70))

View File

@@ -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(sndProgress: .complete), itemEdited: true),
meta: CIMeta.getSample(1, .now, "", .sndSent, 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(sndProgress: .complete), itemEdited: true),
meta: CIMeta.getSample(1, .now, "", .sndSent, itemEdited: true),
content: .sndMsgContent(msgContent: .voice(text: "", duration: 30)),
quotedItem: CIQuote.getSample(1, .now, "Hi", chatDir: .directRcv),
file: CIFile.getSample(fileStatus: .sndComplete)

View File

@@ -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(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, .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, .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(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, .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, .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(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, .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, .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(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, .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, .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(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, .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, .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))

View File

@@ -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(sndProgress: .complete), itemDeleted: .deleted(deletedTs: .now)))
MarkedDeletedItemView(chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent, itemDeleted: .deleted(deletedTs: .now)))
}
.previewLayout(.fixed(width: 360, height: 200))
}

View File

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

View File

@@ -14,23 +14,11 @@ 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 {
@@ -43,11 +31,6 @@ struct ChatItemInfoView: View {
}
}
}
.alert(item: $alert) { alertItem in
switch(alertItem) {
case let .deliveryStatusAlert(status): return deliveryStatusAlert(status)
}
}
}
}
@@ -57,44 +40,19 @@ 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 numTabs > 1 {
if let qi = ci.quotedItem {
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)
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
}
quoteTab(qi)
.tabItem {
Label("In reply to", systemImage: "arrowshape.turn.up.left")
}
.tag(CIInfoTab.quote)
}
} else {
historyTab()
@@ -259,89 +217,9 @@ 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] = [String.localizedStringWithFormat(NSLocalizedString("# %@", comment: "copied message info title, # <title>"), title), ""]
var shareText: [String] = [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))]
@@ -367,7 +245,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) {
@@ -384,23 +262,9 @@ 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 += [

View File

@@ -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(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))
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))
}
.previewLayout(.fixed(width: 360, height: 70))
.environmentObject(Chat.sampleData)

View File

@@ -770,12 +770,6 @@ 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))")
}

View File

@@ -9,8 +9,6 @@
import SwiftUI
import SimpleXChat
let SMALL_GROUPS_RCPS_MEM_LIMIT: Int = 20
struct GroupChatInfoView: View {
@EnvironmentObject var chatModel: ChatModel
@Environment(\.dismiss) var dismiss: DismissAction
@@ -23,8 +21,6 @@ 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
@@ -34,7 +30,6 @@ struct GroupChatInfoView: View {
case clearChatAlert
case leaveGroupAlert
case cantInviteIncognitoAlert
case largeGroupReceiptsDisabled
var id: GroupChatInfoViewAlert { get { self } }
}
@@ -57,11 +52,6 @@ struct GroupChatInfoView: View {
addOrEditWelcomeMessage()
}
groupPreferencesButton($groupInfo)
if members.filter { $0.memberCurrent }.count <= SMALL_GROUPS_RCPS_MEM_LIMIT {
sendReceiptsOption()
} else {
sendReceiptsOptionDisabled()
}
} header: {
Text("")
} footer: {
@@ -125,14 +115,9 @@ 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
@@ -343,38 +328,6 @@ 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 {
@@ -403,13 +356,6 @@ 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)

View File

@@ -258,20 +258,20 @@ struct ChatPreviewView_Previews: PreviewProvider {
))
ChatPreviewView(chat: Chat(
chatInfo: ChatInfo.sampleData.direct,
chatItems: [ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete))]
chatItems: [ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent)]
))
ChatPreviewView(chat: Chat(
chatInfo: ChatInfo.sampleData.direct,
chatItems: [ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete))],
chatItems: [ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent)],
chatStats: ChatStats(unreadCount: 11, minUnreadItemId: 0)
))
ChatPreviewView(chat: Chat(
chatInfo: ChatInfo.sampleData.direct,
chatItems: [ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemDeleted: .deleted(deletedTs: .now))]
chatItems: [ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent, itemDeleted: .deleted(deletedTs: .now))]
))
ChatPreviewView(chat: Chat(
chatInfo: ChatInfo.sampleData.direct,
chatItems: [ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete))],
chatItems: [ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent)],
chatStats: ChatStats(unreadCount: 3, minUnreadItemId: 0)
))
ChatPreviewView(chat: Chat(

View File

@@ -51,9 +51,9 @@ struct AdvancedNetworkSettings: View {
}
.disabled(currentNetCfg == NetCfg.proxyDefaults)
timeoutSettingPicker("TCP connection timeout", selection: $netCfg.tcpConnectTimeout, values: [2_500000, 5_000000, 7_500000, 10_000000, 15_000000, 20_000000], label: secondsLabel)
timeoutSettingPicker("Protocol timeout", selection: $netCfg.tcpTimeout, values: [1_500000, 3_000000, 5_000000, 7_000000, 10_000000, 15_000000], label: secondsLabel)
timeoutSettingPicker("Protocol timeout per KB", selection: $netCfg.tcpTimeoutPerKb, values: [5_000, 10_000, 20_000, 40_000], label: secondsLabel)
timeoutSettingPicker("TCP connection timeout", selection: $netCfg.tcpConnectTimeout, values: [5_000000, 7_500000, 10_000000, 15_000000, 20_000000, 30_000000, 45_000000], label: secondsLabel)
timeoutSettingPicker("Protocol timeout", selection: $netCfg.tcpTimeout, values: [3_000000, 5_000000, 7_000000, 10_000000, 15_000000, 20_000000, 30_000000], label: secondsLabel)
timeoutSettingPicker("Protocol timeout per KB", selection: $netCfg.tcpTimeoutPerKb, values: [10_000, 20_000, 40_000, 75_000, 100_000], label: secondsLabel)
timeoutSettingPicker("PING interval", selection: $netCfg.smpPingInterval, values: [120_000000, 300_000000, 600_000000, 1200_000000, 2400_000000, 3600_000000], label: secondsLabel)
intSettingPicker("PING count", selection: $netCfg.smpPingCount, values: [1, 2, 3, 5, 8], label: "")
Toggle("Enable TCP keep-alive", isOn: $enableKeepAlive)
@@ -153,7 +153,9 @@ struct AdvancedNetworkSettings: View {
private func timeoutSettingPicker(_ title: LocalizedStringKey, selection: Binding<Int>, values: [Int], label: String) -> some View {
Picker(title, selection: selection) {
ForEach(values, id: \.self) { value in
let v = selection.wrappedValue
let vs = values.contains(v) ? values : values + [v]
ForEach(vs, id: \.self) { value in
Text("\(String(format: "%g", (Double(value) / 1000000))) \(secondsLabel)")
}
}

View File

@@ -21,10 +21,6 @@ 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 {
@@ -93,15 +89,15 @@ struct PrivacySettings: View {
settingsRow("person") {
Toggle("Contacts", isOn: $contactReceipts)
}
settingsRow("person.2") {
Toggle("Small groups (max 20)", isOn: $groupReceipts)
}
// settingsRow("person.2") {
// Toggle("Small groups (max 20)", isOn: Binding.constant(false))
// }
} 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 and group settings.")
Text("They can be overridden in contact settings")
}
.frame(maxWidth: .infinity, alignment: .leading)
}
@@ -117,44 +113,19 @@ 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
.onChange(of: contactReceipts) { _ in // sometimes there is race with onAppear
if contactReceiptsReset {
contactReceiptsReset = false
} else {
setOrAskSendReceiptsContacts(contactReceipts)
}
}
.onChange(of: groupReceipts) { _ in
if groupReceiptsReset {
groupReceiptsReset = false
} else {
setOrAskSendReceiptsGroups(groupReceipts)
}
}
.onAppear {
if let u = m.currentUser {
if contactReceipts != u.sendRcptsContacts {
contactReceiptsReset = true
contactReceipts = u.sendRcptsContacts
}
if groupReceipts != u.sendRcptsSmallGroups {
groupReceiptsReset = true
groupReceipts = u.sendRcptsSmallGroups
}
if let u = m.currentUser, contactReceipts != u.sendRcptsContacts {
contactReceiptsReset = true
contactReceipts = u.sendRcptsContacts
}
}
.alert(item: $alert) { alert in
@@ -208,55 +179,7 @@ struct PrivacySettings: View {
}
}
} catch let 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))")
alert = .error(title: "Error setting delivery receipts!", error: "Error: \(responseError(error))")
}
}
}

View File

@@ -43,6 +43,11 @@
5C3F1D562842B68D00EC8A82 /* IntegrityErrorItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C3F1D552842B68D00EC8A82 /* IntegrityErrorItemView.swift */; };
5C3F1D58284363C400EC8A82 /* PrivacySettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C3F1D57284363C400EC8A82 /* PrivacySettings.swift */; };
5C4B3B0A285FB130003915F2 /* DatabaseView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C4B3B09285FB130003915F2 /* DatabaseView.swift */; };
5C4E794D2A8175E0006253CA /* libHSsimplex-chat-5.2.2.0-JPeoRyrCW7JAvXL7HFn3iU-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C4E79482A8175DF006253CA /* libHSsimplex-chat-5.2.2.0-JPeoRyrCW7JAvXL7HFn3iU-ghc8.10.7.a */; };
5C4E794E2A8175E0006253CA /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C4E79492A8175E0006253CA /* libgmp.a */; };
5C4E794F2A8175E0006253CA /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C4E794A2A8175E0006253CA /* libgmpxx.a */; };
5C4E79502A8175E0006253CA /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C4E794B2A8175E0006253CA /* libffi.a */; };
5C4E79512A8175E0006253CA /* libHSsimplex-chat-5.2.2.0-JPeoRyrCW7JAvXL7HFn3iU.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C4E794C2A8175E0006253CA /* libHSsimplex-chat-5.2.2.0-JPeoRyrCW7JAvXL7HFn3iU.a */; };
5C5346A827B59A6A004DF848 /* ChatHelp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C5346A727B59A6A004DF848 /* ChatHelp.swift */; };
5C55A91F283AD0E400C4E99E /* CallManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C55A91E283AD0E400C4E99E /* CallManager.swift */; };
5C55A921283CCCB700C4E99E /* IncomingCallView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C55A920283CCCB700C4E99E /* IncomingCallView.swift */; };
@@ -171,11 +176,6 @@
64AA1C6C27F3537400AC7277 /* DeletedItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64AA1C6B27F3537400AC7277 /* DeletedItemView.swift */; };
64C06EB52A0A4A7C00792D4D /* ChatItemInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C06EB42A0A4A7C00792D4D /* ChatItemInfoView.swift */; };
64C3B0212A0D359700E19930 /* CustomTimePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C3B0202A0D359700E19930 /* CustomTimePicker.swift */; };
64C9F3CF2A73C538002C80AF /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C9F3CA2A73C538002C80AF /* libgmpxx.a */; };
64C9F3D02A73C538002C80AF /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C9F3CB2A73C538002C80AF /* libgmp.a */; };
64C9F3D12A73C538002C80AF /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C9F3CC2A73C538002C80AF /* libffi.a */; };
64C9F3D22A73C538002C80AF /* libHSsimplex-chat-5.3.0.0-FkRHBzksWjH5JbOMv5lWX0-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C9F3CD2A73C538002C80AF /* libHSsimplex-chat-5.3.0.0-FkRHBzksWjH5JbOMv5lWX0-ghc8.10.7.a */; };
64C9F3D32A73C538002C80AF /* libHSsimplex-chat-5.3.0.0-FkRHBzksWjH5JbOMv5lWX0.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C9F3CE2A73C538002C80AF /* libHSsimplex-chat-5.3.0.0-FkRHBzksWjH5JbOMv5lWX0.a */; };
64D0C2C029F9688300B38D5F /* UserAddressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64D0C2BF29F9688300B38D5F /* UserAddressView.swift */; };
64D0C2C229FA57AB00B38D5F /* UserAddressLearnMore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64D0C2C129FA57AB00B38D5F /* UserAddressLearnMore.swift */; };
64D0C2C629FAC1EC00B38D5F /* AddContactLearnMore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64D0C2C529FAC1EC00B38D5F /* AddContactLearnMore.swift */; };
@@ -284,6 +284,11 @@
5C3F1D57284363C400EC8A82 /* PrivacySettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrivacySettings.swift; sourceTree = "<group>"; };
5C422A7C27A9A6FA0097A1E1 /* SimpleX (iOS).entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "SimpleX (iOS).entitlements"; sourceTree = "<group>"; };
5C4B3B09285FB130003915F2 /* DatabaseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DatabaseView.swift; sourceTree = "<group>"; };
5C4E79482A8175DF006253CA /* libHSsimplex-chat-5.2.2.0-JPeoRyrCW7JAvXL7HFn3iU-ghc8.10.7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.2.2.0-JPeoRyrCW7JAvXL7HFn3iU-ghc8.10.7.a"; sourceTree = "<group>"; };
5C4E79492A8175E0006253CA /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = "<group>"; };
5C4E794A2A8175E0006253CA /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = "<group>"; };
5C4E794B2A8175E0006253CA /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = "<group>"; };
5C4E794C2A8175E0006253CA /* libHSsimplex-chat-5.2.2.0-JPeoRyrCW7JAvXL7HFn3iU.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.2.2.0-JPeoRyrCW7JAvXL7HFn3iU.a"; sourceTree = "<group>"; };
5C5346A727B59A6A004DF848 /* ChatHelp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatHelp.swift; sourceTree = "<group>"; };
5C55A91E283AD0E400C4E99E /* CallManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallManager.swift; sourceTree = "<group>"; };
5C55A920283CCCB700C4E99E /* IncomingCallView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IncomingCallView.swift; sourceTree = "<group>"; };
@@ -449,11 +454,6 @@
64AA1C6B27F3537400AC7277 /* DeletedItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeletedItemView.swift; sourceTree = "<group>"; };
64C06EB42A0A4A7C00792D4D /* ChatItemInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatItemInfoView.swift; sourceTree = "<group>"; };
64C3B0202A0D359700E19930 /* CustomTimePicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomTimePicker.swift; sourceTree = "<group>"; };
64C9F3CA2A73C538002C80AF /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = "<group>"; };
64C9F3CB2A73C538002C80AF /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = "<group>"; };
64C9F3CC2A73C538002C80AF /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = "<group>"; };
64C9F3CD2A73C538002C80AF /* libHSsimplex-chat-5.3.0.0-FkRHBzksWjH5JbOMv5lWX0-ghc8.10.7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.3.0.0-FkRHBzksWjH5JbOMv5lWX0-ghc8.10.7.a"; sourceTree = "<group>"; };
64C9F3CE2A73C538002C80AF /* libHSsimplex-chat-5.3.0.0-FkRHBzksWjH5JbOMv5lWX0.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.3.0.0-FkRHBzksWjH5JbOMv5lWX0.a"; sourceTree = "<group>"; };
64D0C2BF29F9688300B38D5F /* UserAddressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserAddressView.swift; sourceTree = "<group>"; };
64D0C2C129FA57AB00B38D5F /* UserAddressLearnMore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserAddressLearnMore.swift; sourceTree = "<group>"; };
64D0C2C529FAC1EC00B38D5F /* AddContactLearnMore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddContactLearnMore.swift; sourceTree = "<group>"; };
@@ -501,13 +501,13 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
64C9F3CF2A73C538002C80AF /* libgmpxx.a in Frameworks */,
5CE2BA93284534B000EC33A6 /* libiconv.tbd in Frameworks */,
64C9F3D22A73C538002C80AF /* libHSsimplex-chat-5.3.0.0-FkRHBzksWjH5JbOMv5lWX0-ghc8.10.7.a in Frameworks */,
64C9F3D02A73C538002C80AF /* libgmp.a in Frameworks */,
64C9F3D12A73C538002C80AF /* libffi.a in Frameworks */,
5C4E79502A8175E0006253CA /* libffi.a in Frameworks */,
5C4E794F2A8175E0006253CA /* libgmpxx.a in Frameworks */,
5C4E79512A8175E0006253CA /* libHSsimplex-chat-5.2.2.0-JPeoRyrCW7JAvXL7HFn3iU.a in Frameworks */,
5CE2BA94284534BB00EC33A6 /* libz.tbd in Frameworks */,
64C9F3D32A73C538002C80AF /* libHSsimplex-chat-5.3.0.0-FkRHBzksWjH5JbOMv5lWX0.a in Frameworks */,
5C4E794E2A8175E0006253CA /* libgmp.a in Frameworks */,
5C4E794D2A8175E0006253CA /* libHSsimplex-chat-5.2.2.0-JPeoRyrCW7JAvXL7HFn3iU-ghc8.10.7.a in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -568,11 +568,11 @@
5C764E5C279C70B7000C6508 /* Libraries */ = {
isa = PBXGroup;
children = (
64C9F3CC2A73C538002C80AF /* libffi.a */,
64C9F3CB2A73C538002C80AF /* libgmp.a */,
64C9F3CA2A73C538002C80AF /* libgmpxx.a */,
64C9F3CD2A73C538002C80AF /* libHSsimplex-chat-5.3.0.0-FkRHBzksWjH5JbOMv5lWX0-ghc8.10.7.a */,
64C9F3CE2A73C538002C80AF /* libHSsimplex-chat-5.3.0.0-FkRHBzksWjH5JbOMv5lWX0.a */,
5C4E794B2A8175E0006253CA /* libffi.a */,
5C4E79492A8175E0006253CA /* libgmp.a */,
5C4E794A2A8175E0006253CA /* libgmpxx.a */,
5C4E79482A8175DF006253CA /* libHSsimplex-chat-5.2.2.0-JPeoRyrCW7JAvXL7HFn3iU-ghc8.10.7.a */,
5C4E794C2A8175E0006253CA /* libHSsimplex-chat-5.2.2.0-JPeoRyrCW7JAvXL7HFn3iU.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 = 161;
CURRENT_PROJECT_VERSION = 165;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_BITCODE = NO;
ENABLE_PREVIEWS = YES;
@@ -1499,7 +1499,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 5.3;
MARKETING_VERSION = 5.2.2;
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 = 161;
CURRENT_PROJECT_VERSION = 165;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_BITCODE = NO;
ENABLE_PREVIEWS = YES;
@@ -1541,7 +1541,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 5.3;
MARKETING_VERSION = 5.2.2;
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 = 161;
CURRENT_PROJECT_VERSION = 165;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_BITCODE = NO;
GENERATE_INFOPLIST_FILE = YES;
@@ -1613,7 +1613,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 5.3;
MARKETING_VERSION = 5.2.2;
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 = 161;
CURRENT_PROJECT_VERSION = 165;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_BITCODE = NO;
GENERATE_INFOPLIST_FILE = YES;
@@ -1645,7 +1645,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 5.3;
MARKETING_VERSION = 5.2.2;
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 = 165;
DEFINES_MODULE = YES;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
DYLIB_COMPATIBILITY_VERSION = 1;
@@ -1688,7 +1688,7 @@
"$(inherited)",
"$(PROJECT_DIR)/Libraries/sim",
);
MARKETING_VERSION = 5.2;
MARKETING_VERSION = 5.2.2;
PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.SimpleXChat;
PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)";
SDKROOT = iphoneos;
@@ -1710,7 +1710,7 @@
APPLICATION_EXTENSION_API_ONLY = YES;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 160;
CURRENT_PROJECT_VERSION = 165;
DEFINES_MODULE = YES;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
DYLIB_COMPATIBILITY_VERSION = 1;
@@ -1734,7 +1734,7 @@
"$(inherited)",
"$(PROJECT_DIR)/Libraries/sim",
);
MARKETING_VERSION = 5.2;
MARKETING_VERSION = 5.2.2;
PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.SimpleXChat;
PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)";
SDKROOT = iphoneos;

View File

@@ -19,7 +19,6 @@ 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)
@@ -128,10 +127,7 @@ public enum ChatCommand {
case let .setAllContactReceipts(enable): return "/set receipts all \(onOff(enable))"
case let .apiSetUserContactReceipts(userId, userMsgReceiptSettings):
let umrs = userMsgReceiptSettings
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))"
return "/_set receipts \(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)"
@@ -261,7 +257,6 @@ 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"
@@ -1057,9 +1052,9 @@ public struct NetCfg: Codable, Equatable {
public static let defaults: NetCfg = NetCfg(
socksProxy: nil,
sessionMode: TransportSessionMode.user,
tcpConnectTimeout: 10_000_000,
tcpTimeout: 7_000_000,
tcpTimeoutPerKb: 10_000,
tcpConnectTimeout: 15_000_000,
tcpTimeout: 10_000_000,
tcpTimeoutPerKb: 20_000,
tcpKeepAlive: KeepAliveOpts.defaults,
smpPingInterval: 1200_000_000,
smpPingCount: 3,
@@ -1069,9 +1064,9 @@ public struct NetCfg: Codable, Equatable {
public static let proxyDefaults: NetCfg = NetCfg(
socksProxy: nil,
sessionMode: TransportSessionMode.user,
tcpConnectTimeout: 20_000_000,
tcpTimeout: 15_000_000,
tcpTimeoutPerKb: 20_000,
tcpConnectTimeout: 30_000_000,
tcpTimeout: 20_000_000,
tcpTimeoutPerKb: 40_000,
tcpKeepAlive: KeepAliveOpts.defaults,
smpPingInterval: 1200_000_000,
smpPingCount: 3,

View File

@@ -1200,13 +1200,6 @@ 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 {
@@ -2270,7 +2263,19 @@ public struct CIMeta: Decodable {
}
public func statusIcon(_ metaColor: Color = .secondary) -> (String, Color)? {
itemStatus.statusIcon(metaColor)
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
}
}
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 {
@@ -2333,12 +2338,13 @@ private func recent(_ date: Date) -> Bool {
public enum CIStatus: Decodable {
case sndNew
case sndSent(sndProgress: SndCIStatusProgress)
case sndRcvd(msgRcptStatus: MsgReceiptStatus, sndProgress: SndCIStatusProgress)
case sndSent
case sndRcvd(msgRcptStatus: MsgReceiptStatus)
case sndErrorAuth
case sndError(agentError: String)
case rcvNew
case rcvRead
case invalid(text: String)
var id: String {
switch self {
@@ -2349,46 +2355,7 @@ public enum CIStatus: Decodable {
case .sndError: return "sndError"
case .rcvNew: return "rcvNew"
case .rcvRead: return "rcvRead"
}
}
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
}
}
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")
}
}
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 .invalid: return "invalid"
}
}
}
@@ -2398,11 +2365,6 @@ 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)
@@ -2656,6 +2618,7 @@ public struct CIFile: Decodable {
case .rcvCancelled: return false
case .rcvComplete: return true
case .rcvError: return false
case .invalid: return false
}
}
}
@@ -2679,6 +2642,7 @@ public struct CIFile: Decodable {
case .rcvCancelled: return nil
case .rcvComplete: return nil
case .rcvError: return nil
case .invalid: return nil
}
}
}
@@ -2739,6 +2703,7 @@ public enum CIFileStatus: Decodable, Equatable {
case rcvComplete
case rcvCancelled
case rcvError
case invalid(text: String)
var id: String {
switch self {
@@ -2753,6 +2718,7 @@ public enum CIFileStatus: Decodable, Equatable {
case .rcvComplete: return "rcvComplete"
case .rcvCancelled: return "rcvCancelled"
case .rcvError: return "rcvError"
case .invalid: return "invalid"
}
}
}
@@ -3246,7 +3212,6 @@ public enum ChatItemTTL: Hashable, Identifiable, Comparable {
public struct ChatItemInfo: Decodable {
public var itemVersions: [ChatItemVersion]
public var memberDeliveryStatuses: [MemberDeliveryStatus]?
}
public struct ChatItemVersion: Decodable {
@@ -3256,8 +3221,3 @@ public struct ChatItemVersion: Decodable {
public var itemVersionTs: Date
public var createdAt: Date
}
public struct MemberDeliveryStatus: Decodable {
public var groupMemberId: Int64
public var memberDeliveryStatus: CIStatus
}

View File

@@ -7,13 +7,17 @@ plugins {
id("org.jetbrains.kotlin.plugin.serialization")
}
repositories {
maven("https://jitpack.io")
}
android {
compileSdkVersion(33)
defaultConfig {
applicationId = "chat.simplex.app"
minSdkVersion(26)
targetSdkVersion(33)
targetSdkVersion(32)
// !!!
// skip version code after release to F-Droid, as it uses two version codes
versionCode = (extra["android.version_code"] as String).toInt()
@@ -120,16 +124,9 @@ 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")

View File

@@ -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.common.model.AppPreferences
import chat.simplex.common.model.AppPreferences.Companion.SHARED_PREFS_PRIVACY_FULL_BACKUP
import chat.simplex.app.model.AppPreferences
import chat.simplex.app.model.AppPreferences.Companion.SHARED_PREFS_PRIVACY_FULL_BACKUP
class BackupAgent: BackupAgentHelper() {
override fun onFullBackup(data: FullBackupDataOutput?) {

View File

@@ -1,40 +1,84 @@
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 chat.simplex.app.model.NtfManager
import androidx.lifecycle.*
import chat.simplex.app.MainActivity.Companion.enteredBackground
import chat.simplex.app.model.*
import chat.simplex.app.model.NtfManager.getUserIdFromIntent
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.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.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()
mainActivity = WeakReference(this)
val m = vm.chatModel
// 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)
processIntent(intent)
processExternalIntent(intent)
processNotificationIntent(intent, m)
processIntent(intent, m)
processExternalIntent(intent, m)
}
if (ChatController.appPrefs.privacyProtectScreen.get()) {
if (m.controller.appPrefs.privacyProtectScreen.get()) {
Log.d(TAG, "onCreate: set FLAG_SECURE")
window.setFlags(
WindowManager.LayoutParams.FLAG_SECURE,
@@ -43,7 +87,17 @@ class MainActivity: FragmentActivity() {
}
setContent {
SimpleXTheme {
AppScreen()
Surface(color = MaterialTheme.colors.background) {
MainPage(
m,
userAuthorized,
laFailed,
destroyedAfterBackPress,
::runAuthenticate,
::setPerformLA,
showLANotice = { showLANotice(m.controller.appPrefs.laNoticeShown) }
)
}
}
}
SimplexApp.context.schedulePeriodicServiceRestartWorker()
@@ -52,29 +106,38 @@ class MainActivity: FragmentActivity() {
override fun onNewIntent(intent: Intent?) {
super.onNewIntent(intent)
processIntent(intent)
processExternalIntent(intent)
processIntent(intent, vm.chatModel)
processExternalIntent(intent, vm.chatModel)
}
override fun onResume() {
super.onResume()
AppLock.recheckAuthState()
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()
}
}
}
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]
* */
AppLock.appWasHidden()
* 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()
}
override fun onStop() {
super.onStop()
VideoPlayer.stopAll()
AppLock.appWasHidden()
enteredBackground.value = elapsedRealtime()
}
override fun onBackPressed() {
@@ -87,52 +150,481 @@ class MainActivity: FragmentActivity() {
super.onBackPressed()
}
if (!onBackPressedDispatcher.hasEnabledCallbacks() && ChatController.appPrefs.performLA.get()) {
if (!onBackPressedDispatcher.hasEnabledCallbacks() && vm.chatModel.controller.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
AppLock.clearAuthState()
AppLock.laFailed.value = true
AppLock.destroyedAfterBackPress.value = true
clearAuthState()
laFailed.value = true
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()
}
}
}
)
}
}
fun processNotificationIntent(intent: Intent?) {
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) {
val userId = getUserIdFromIntent(intent)
when (intent?.action) {
NtfManager.OpenChatAction -> {
val chatId = intent.getStringExtra("chatId")
Log.d(TAG, "processNotificationIntent: OpenChatAction $chatId")
if (chatId != null) {
ntfManager.openChatAction(userId, chatId)
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.ShowChatsAction -> {
Log.d(TAG, "processNotificationIntent: ShowChatsAction")
ntfManager.showChatsAction(userId)
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.AcceptCallAction -> {
val chatId = intent.getStringExtra("chatId")
if (chatId == null || chatId == "") return
Log.d(TAG, "processNotificationIntent: AcceptCallAction $chatId")
ntfManager.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)
}
}
}
}
fun processIntent(intent: Intent?) {
fun processIntent(intent: Intent?, chatModel: ChatModel) {
when (intent?.action) {
"android.intent.action.VIEW" -> {
val uri = intent.data
if (uri != null) connectIfOpenedViaUri(uri.toURI(), ChatModel)
if (uri != null) connectIfOpenedViaUri(uri, chatModel)
}
}
}
fun processExternalIntent(intent: Intent?) {
fun processExternalIntent(intent: Intent?, chatModel: ChatModel) {
when (intent?.action) {
Intent.ACTION_SEND -> {
// Close active chat and show a list of chats
@@ -148,13 +640,13 @@ fun processExternalIntent(intent: Intent?) {
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.toURI()))
chatModel.sharedContent.value = SharedContent.Media(intent.getStringExtra(Intent.EXTRA_TEXT) ?: "", listOf(uri))
} // 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.toURI())
chatModel.sharedContent.value = SharedContent.File(intent.getStringExtra(Intent.EXTRA_TEXT) ?: "", uri)
}
}
}
@@ -168,7 +660,7 @@ fun processExternalIntent(intent: Intent?) {
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.map { it.toURI() })
chatModel.sharedContent.value = SharedContent.Media(intent.getStringExtra(Intent.EXTRA_TEXT) ?: "", uris)
} // All other mime types
}
else -> {}
@@ -180,6 +672,48 @@ fun processExternalIntent(intent: Intent?) {
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()

View File

@@ -1,31 +1,101 @@
package chat.simplex.app
import android.app.Application
import chat.simplex.common.platform.Log
import android.net.LocalServerSocket
import android.util.Log
import androidx.lifecycle.*
import androidx.work.*
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 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 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()
@@ -33,10 +103,7 @@ class SimplexApp: Application(), LifecycleEventObserver {
return;
}
context = this
initHaskell()
initMultiplatform()
tmpDir.deleteRecursively()
context.getDir("temp", MODE_PRIVATE).deleteRecursively()
withBGApi {
initChatController()
runMigrations()
@@ -80,7 +147,7 @@ class SimplexApp: Application(), LifecycleEventObserver {
* */
if (chatModel.chatRunning.value != false &&
chatModel.onboardingStage.value == OnboardingStage.OnboardingComplete &&
appPrefs.notificationsMode.get() == NotificationsMode.SERVICE
appPreferences.notificationsMode.get() == NotificationsMode.SERVICE.name
) {
SimplexService.start()
}
@@ -91,12 +158,12 @@ class SimplexApp: Application(), LifecycleEventObserver {
}
fun allowToStartServiceAfterAppExit() = with(chatModel.controller) {
appPrefs.notificationsMode.get() == NotificationsMode.SERVICE &&
appPrefs.notificationsMode.get() == NotificationsMode.SERVICE.name &&
(!NotificationsMode.SERVICE.requiresIgnoringBattery || SimplexService.isIgnoringBatteryOptimizations())
}
private fun allowToStartPeriodically() = with(chatModel.controller) {
appPrefs.notificationsMode.get() == NotificationsMode.PERIODIC &&
appPrefs.notificationsMode.get() == NotificationsMode.PERIODIC.name &&
(!NotificationsMode.PERIODIC.requiresIgnoringBattery || SimplexService.isIgnoringBatteryOptimizations())
}
@@ -131,73 +198,75 @@ class SimplexApp: Application(), LifecycleEventObserver {
MessagesFetcherWorker.scheduleWork()
}
companion object {
lateinit var context: SimplexApp private set
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
}
}
}
}
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()
}
companion object {
lateinit var context: SimplexApp private set
override fun androidServiceSafeStop() {
SimplexService.safeStopService()
}
override fun androidNotificationsModeChanged(mode: NotificationsMode) {
if (mode.requiresIgnoringBattery && !SimplexService.isIgnoringBatteryOptimizations()) {
appPrefs.backgroundServiceNoticeShown.set(false)
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())
}
}
SimplexService.StartReceiver.toggleReceiver(mode == NotificationsMode.SERVICE)
CoroutineScope(Dispatchers.Default).launch {
if (mode == NotificationsMode.SERVICE)
SimplexService.start()
else
SimplexService.safeStopService()
if (server == null) {
throw Error("Unable to setup local server socket. Contact developers")
}
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 -> {}
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")
}
}
override fun androidChatStopped() {
SimplexService.safeStopService()
MessagesFetcherWorker.cancelAll()
}
System.loadLibrary("app-lib")
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()
}
}
}
s.acquire()
pipeStdOutToSocket(socketName)
initHS()
}
}
}
class FifoQueue<E>(private var capacity: Int) : LinkedList<E>() {
override fun add(element: E): Boolean {
if(size > capacity) removeFirst()
return super.add(element)
}
}

View File

@@ -7,26 +7,23 @@ 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.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.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.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/
@@ -100,7 +97,7 @@ class SimplexService: Service() {
val self = this
isStartingService = true
withApi {
val chatController = ChatController
val chatController = (application as SimplexApp).chatController
waitDbMigrationEnds(chatController)
try {
Log.w(TAG, "Starting foreground service")
@@ -108,7 +105,7 @@ class SimplexService: Service() {
if (chatDbStatus != DBMigrationResult.OK) {
Log.w(chat.simplex.app.TAG, "SimplexService: problem with the database: $chatDbStatus")
showPassphraseNotification(chatDbStatus)
safeStopService()
safeStopService(self)
return@withApi
}
saveServiceState(self, ServiceState.STARTED)
@@ -170,7 +167,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
AppLock.clearAuthState()
MainActivity.clearAuthState()
// If notification service isn't enabled or battery optimization isn't disabled, we shouldn't restart the service
if (!SimplexApp.context.allowToStartServiceAfterAppExit()) {
@@ -268,9 +265,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() {
fun safeStopService(context: Context) {
if (isServiceStarted) {
androidAppContext.stopService(Intent(androidAppContext, SimplexService::class.java))
context.stopService(Intent(context, SimplexService::class.java))
} else {
stopAfterStart = true
}
@@ -279,9 +276,9 @@ class SimplexService: Service() {
private suspend fun serviceAction(action: Action) {
Log.d(TAG, "SimplexService serviceAction: ${action.name}")
withContext(Dispatchers.IO) {
Intent(androidAppContext, SimplexService::class.java).also {
Intent(SimplexApp.context, SimplexService::class.java).also {
it.action = action.name
ContextCompat.startForegroundService(androidAppContext, it)
ContextCompat.startForegroundService(SimplexApp.context, it)
}
}
}
@@ -355,7 +352,7 @@ class SimplexService: Service() {
fun showBackgroundServiceNoticeIfNeeded() {
val appPrefs = ChatController.appPrefs
val mode = appPrefs.notificationsMode.get()
val mode = NotificationsMode.valueOf(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
@@ -376,10 +373,11 @@ 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)
StartReceiver.toggleReceiver(false)
appPrefs.notificationsMode.set(NotificationsMode.OFF.name)
ChatModel.notificationsMode.value = NotificationsMode.OFF
SimplexService.StartReceiver.toggleReceiver(false)
MessagesFetcherWorker.cancelAll()
safeStopService()
SimplexService.safeStopService(SimplexApp.context)
} else {
// show battery optimization notice
showBGServiceNoticeIgnoreOptimization(mode)
@@ -491,18 +489,18 @@ class SimplexService: Service() {
}
fun isIgnoringBatteryOptimizations(): Boolean {
val powerManager = androidAppContext.getSystemService(Application.POWER_SERVICE) as PowerManager
return powerManager.isIgnoringBatteryOptimizations(androidAppContext.packageName)
val powerManager = SimplexApp.context.getSystemService(Application.POWER_SERVICE) as PowerManager
return powerManager.isIgnoringBatteryOptimizations(SimplexApp.context.packageName)
}
private fun askAboutIgnoringBatteryOptimization() {
Intent().apply {
@SuppressLint("BatteryLife")
action = Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS
data = Uri.parse("package:${androidAppContext.packageName}")
data = Uri.parse("package:${SimplexApp.context.packageName}")
// This flag is needed when you start a new activity from non-Activity context
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
androidAppContext.startActivity(this)
SimplexApp.context.startActivity(this)
}
}
}

View File

@@ -1,18 +1,19 @@
package chat.simplex.common.model
package chat.simplex.app.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.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.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.res.MR
import dev.icerock.moko.resources.ImageResource
import dev.icerock.moko.resources.StringResource
@@ -25,7 +26,6 @@ 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.*
@@ -56,7 +56,7 @@ object ChatModel {
val chatItems = mutableStateListOf<ChatItem>()
val groupMembers = mutableStateListOf<GroupMember>()
val terminalItems = mutableStateOf(emptyList<TerminalItem>())
val terminalItems = mutableStateListOf<TerminalItem>()
val userAddress = mutableStateOf<UserContactLinkRec?>(null)
// Allows to temporary save servers that are being edited on multiple screens
val userSMPServersUnsaved = mutableStateOf<(List<ServerCfg>)?>(null)
@@ -66,21 +66,14 @@ 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 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 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 showAdvertiseLAUnavailableAlert = mutableStateOf(false)
val incognito by lazy { mutableStateOf(ChatController.appPrefs.incognito.get()) }
val incognito by lazy { mutableStateOf(controller.appPrefs.incognito.get()) }
// current WebRTC call
val callManager = CallManager(this)
@@ -102,7 +95,7 @@ object ChatModel {
val sharedContent = mutableStateOf(null as SharedContent?)
val filesToDelete = mutableSetOf<File>()
val simplexLinkMode by lazy { mutableStateOf(ChatController.appPrefs.simplexLinkMode.get()) }
val simplexLinkMode by lazy { mutableStateOf(controller.appPrefs.simplexLinkMode.get()) }
fun getUser(userId: Long): User? = if (currentUser.value?.userId == userId) {
currentUser.value
@@ -130,11 +123,10 @@ object ChatModel {
}
}
// 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 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 }
fun addChat(chat: Chat) = chats.add(index = 0, chat)
fun updateChatInfo(cInfo: ChatInfo) {
@@ -438,7 +430,7 @@ object ChatModel {
val info = getChat(id)?.chatInfo as? ChatInfo.ContactConnection ?: return
if (info.contactConnection.connReqInv == connReqInv.value) {
connReqInv.value = null
ModalManager.center.closeModals()
ModalManager.shared.closeModals()
}
}
@@ -484,11 +476,10 @@ object ChatModel {
networkStatuses[contact.activeConn.agentConnId] ?: NetworkStatus.Unknown()
fun addTerminalItem(item: TerminalItem) {
if (terminalItems.value.size >= 500) {
terminalItems.value = terminalItems.value.takeLast(499) + item
} else {
terminalItems.value += item
if (terminalItems.size >= 500) {
terminalItems.removeAt(0)
}
terminalItems.add(item)
}
}
@@ -1625,12 +1616,19 @@ data class CIMeta (
val isRcvNew: Boolean get() = itemStatus is CIStatus.RcvNew
fun statusIcon(
primaryColor: Color,
metaColor: Color = CurrentColors.value.colors.secondary,
paleMetaColor: Color = CurrentColors.value.colors.secondary
): Pair<ImageResource, Color>? =
itemStatus.statusIcon(primaryColor, metaColor, paleMetaColor)
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
}
companion object {
fun getSample(
@@ -1709,56 +1707,13 @@ fun localTimestamp(t: Instant): String {
@Serializable
sealed class CIStatus {
@Serializable @SerialName("sndNew") class SndNew: CIStatus()
@Serializable @SerialName("sndSent") class SndSent(val sndProgress: SndCIStatusProgress): CIStatus()
@Serializable @SerialName("sndRcvd") class SndRcvd(val msgRcptStatus: MsgReceiptStatus, val sndProgress: SndCIStatusProgress): CIStatus()
@Serializable @SerialName("sndSent") class SndSent: CIStatus()
@Serializable @SerialName("sndRcvd") class SndRcvd(val msgRcptStatus: MsgReceiptStatus): 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()
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
}
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)
}
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)
}
@Serializable @SerialName("invalid") class Invalid(val text: String): CIStatus()
}
@Serializable
@@ -1767,12 +1722,6 @@ 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()
@@ -2002,6 +1951,7 @@ class CIFile(
is CIFileStatus.RcvCancelled -> false
is CIFileStatus.RcvComplete -> true
is CIFileStatus.RcvError -> false
is CIFileStatus.Invalid -> false
}
@Transient
@@ -2022,6 +1972,7 @@ class CIFile(
is CIFileStatus.RcvCancelled -> null
is CIFileStatus.RcvComplete -> null
is CIFileStatus.RcvError -> null
is CIFileStatus.Invalid -> null
}
companion object {
@@ -2091,6 +2042,7 @@ sealed class CIFileStatus {
@Serializable @SerialName("rcvComplete") object RcvComplete: CIFileStatus()
@Serializable @SerialName("rcvCancelled") object RcvCancelled: CIFileStatus()
@Serializable @SerialName("rcvError") object RcvError: CIFileStatus()
@Serializable @SerialName("invalid") class Invalid(val text: String): CIFileStatus()
}
@Suppress("SERIALIZER_TYPE_INCOMPATIBLE")
@@ -2533,7 +2485,6 @@ sealed class ChatItemTTL: Comparable<ChatItemTTL?> {
@Serializable
class ChatItemInfo(
val itemVersions: List<ChatItemVersion>,
val memberDeliveryStatuses: List<MemberDeliveryStatus>?
)
@Serializable
@@ -2544,17 +2495,3 @@ 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
}
}

View File

@@ -1,5 +1,6 @@
package chat.simplex.app.model
import android.Manifest
import android.app.*
import android.app.TaskStackBuilder
import android.content.*
@@ -8,20 +9,16 @@ 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.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.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 kotlinx.datetime.Clock
object NtfManager {
const val MessageChannel: String = "chat.simplex.app.MESSAGE_NOTIFICATION"
@@ -36,7 +33,7 @@ object NtfManager {
const val CallNotificationId: Int = -1
private const val UserIdKey: String = "userId"
private const val ChatIdKey: String = "chatId"
private val appPreferences: AppPreferences = ChatController.appPrefs
private val appPreferences: AppPreferences by lazy { ChatController.appPrefs }
private val context: Context
get() = SimplexApp.context
@@ -45,7 +42,7 @@ object NtfManager {
return if (userId == -1L || userId == null) null else userId
}
private val manager: NotificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
private val manager: NotificationManager = SimplexApp.context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
private var prevNtfTime = mutableMapOf<String, Long>()
private val msgNtfTimeoutMs = 30000L
@@ -53,6 +50,10 @@ 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()
@@ -81,6 +82,31 @@ 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")
@@ -93,7 +119,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).asAndroidBitmap()
else -> base64ToBitmap(image)
}
val builder = NotificationCompat.Builder(context, MessageChannel)
.setContentTitle(title)
@@ -132,7 +158,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, android.Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED) {
if (ActivityCompat.checkSelfPermission(SimplexApp.context, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED) {
notify(chatId.hashCode(), builder.build())
notify(0, summary)
}
@@ -146,9 +172,9 @@ object NtfManager {
"notifyCallInvitation pre-requests: " +
"keyguard locked ${keyguardManager.isKeyguardLocked}, " +
"callOnLockScreen ${appPreferences.callOnLockScreen.get()}, " +
"onForeground ${isAppOnForeground}"
"onForeground ${SimplexApp.context.isAppOnForeground}"
)
if (isAppOnForeground) return
if (SimplexApp.context.isAppOnForeground) return
val contactId = invitation.contact.id
Log.d(TAG, "notifyCallInvitation $contactId")
val image = invitation.contact.image
@@ -186,7 +212,7 @@ object NtfManager {
val largeIcon = if (image == null || previewMode == NotificationPreviewMode.HIDDEN.name)
BitmapFactory.decodeResource(context.resources, R.drawable.icon)
else
base64ToBitmap(image).asAndroidBitmap()
base64ToBitmap(image)
ntfBuilder = ntfBuilder
.setContentTitle(title)
@@ -201,7 +227,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, android.Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED) {
if (ActivityCompat.checkSelfPermission(SimplexApp.context, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED) {
notify(CallNotificationId, notification)
}
}
@@ -217,6 +243,19 @@ 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()
@@ -260,7 +299,18 @@ object NtfManager {
val chatId = intent?.getStringExtra(ChatIdKey) ?: return
val m = SimplexApp.context.chatModel
when (intent.action) {
NotificationAction.ACCEPT_CONTACT_REQUEST.name -> ntfManager.acceptContactRequestAction(userId, chatId)
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)
}
RejectCallAction -> {
val invitation = m.callInvitations[chatId]
if (invitation != null) {

View File

@@ -1,21 +1,21 @@
package chat.simplex.common.model
package chat.simplex.app.model
import chat.simplex.common.views.helpers.*
import android.content.*
import android.util.Log
import chat.simplex.app.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.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 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 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,17 +43,19 @@ enum class SimplexLinkMode {
BROWSER;
companion object {
val default = DESCRIPTION
val default = SimplexLinkMode.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 = mkEnumPreference(
SHARED_PREFS_NOTIFICATIONS_MODE,
if (!runServiceInBackground.get()) NotificationsMode.OFF else NotificationsMode.default
) { NotificationsMode.values().firstOrNull { it.name == this } }
val notificationsMode = mkStrPreference(SHARED_PREFS_NOTIFICATIONS_MODE,
if (!runServiceInBackground.get()) NotificationsMode.OFF.name else NotificationsMode.default.name
)
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)
@@ -72,7 +74,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.default) { LAMode.values().firstOrNull { it.name == this } }
val laMode = mkEnumPreference(SHARED_PREFS_LA_MODE, LAMode.SYSTEM) { 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)
@@ -140,7 +142,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)
val encryptionStartedAt = mkDatePreference(SHARED_PREFS_ENCRYPTION_STARTED_AT, null, true)
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)
@@ -151,7 +153,7 @@ class AppPreferences {
json.encodeToString(MapSerializer(String.serializer(), ThemeOverrides.serializer()), it)
}, decode = {
json.decodeFromString(MapSerializer(String.serializer(), ThemeOverrides.serializer()), it)
}, settingsThemes)
}, sharedPreferencesThemes)
val whatsNewVersion = mkStrPreference(SHARED_PREFS_WHATS_NEW_VERSION, null)
val lastMigratedVersionCode = mkIntPreference(SHARED_PREFS_LAST_MIGRATED_VERSION_CODE, 0)
@@ -159,73 +161,65 @@ class AppPreferences {
private fun mkIntPreference(prefName: String, default: Int) =
SharedPreference(
get = fun() = settings.getInt(prefName, default),
set = fun(value) = settings.putInt(prefName, value)
get = fun() = sharedPreferences.getInt(prefName, default),
set = fun(value) = sharedPreferences.edit().putInt(prefName, value).apply()
)
private fun mkLongPreference(prefName: String, default: Long) =
SharedPreference(
get = fun() = settings.getLong(prefName, default),
set = fun(value) = settings.putLong(prefName, value)
get = fun() = sharedPreferences.getLong(prefName, default),
set = fun(value) = sharedPreferences.edit().putLong(prefName, value).apply()
)
private fun mkTimeoutPreference(prefName: String, default: Long, proxyDefault: Long): SharedPreference<Long> {
val d = if (networkUseSocksProxy.get()) proxyDefault else default
return SharedPreference(
get = fun() = settings.getLong(prefName, d),
set = fun(value) = settings.putLong(prefName, value)
get = fun() = sharedPreferences.getLong(prefName, d),
set = fun(value) = sharedPreferences.edit().putLong(prefName, value).apply()
)
}
private fun mkBoolPreference(prefName: String, default: Boolean) =
SharedPreference(
get = fun() = settings.getBoolean(prefName, default),
set = fun(value) = settings.putBoolean(prefName, value)
get = fun() = sharedPreferences.getBoolean(prefName, default),
set = fun(value) = sharedPreferences.edit().putBoolean(prefName, value).apply()
)
private fun mkStrPreference(prefName: String, default: String?): SharedPreference<String?> =
SharedPreference(
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)
get = fun() = sharedPreferences.getString(prefName, default),
set = fun(value) = sharedPreferences.edit().putString(prefName, value).apply()
)
private fun <T> mkEnumPreference(prefName: String, default: T, construct: String.() -> T?): SharedPreference<T> =
SharedPreference(
get = fun() = settings.getString(prefName, default.toString()).construct() ?: default,
set = fun(value) = settings.putString(prefName, value.toString())
get = fun() = sharedPreferences.getString(prefName, default.toString())?.construct() ?: default,
set = fun(value) = sharedPreferences.edit().putString(prefName, value.toString()).apply()
)
// LALAL
private fun mkDatePreference(prefName: String, default: Instant?): SharedPreference<Instant?> =
/**
* 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?> =
SharedPreference(
get = {
val nullValue = "----------------------"
val pref = settings.getString(prefName, default?.toEpochMilliseconds()?.toString() ?: nullValue)
if (pref != nullValue) {
Instant.fromEpochMilliseconds(pref.toLong())
} else {
null
}
val pref = sharedPreferences.getString(prefName, default?.toEpochMilliseconds()?.toString())
pref?.let { Instant.fromEpochMilliseconds(pref.toLong()) }
},
set = fun(value) = if (value?.toEpochMilliseconds() != null) settings.putString(prefName, value.toEpochMilliseconds().toString()) else settings.remove(prefName)
set = fun(value) = sharedPreferences.edit().putString(prefName, value?.toEpochMilliseconds()?.toString()).let {
if (commit) it.commit() else it.apply()
}
)
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>> =
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>> =
SharedPreference(
get = fun() = decode(prefs.getString(prefName, encode(default))),
set = fun(value) = prefs.putString(prefName, encode(value))
get = fun() = decode(prefs.getString(prefName, encode(default))!!),
set = fun(value) = prefs.edit().putString(prefName, encode(value)).apply()
)
companion object {
const val SHARED_PREFS_ID = "chat.simplex.app.SIMPLEX_APP_PREFS"
internal 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"
@@ -246,7 +240,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"
const val SHARED_PREFS_PRIVACY_FULL_BACKUP = "FullBackup"
internal 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"
@@ -300,6 +294,7 @@ 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
@@ -321,8 +316,8 @@ object ChatController {
try {
if (chatModel.chatRunning.value == true) return
apiSetNetworkConfig(getNetCfg())
apiSetTempFolder(coreTmpDir.absolutePath)
apiSetFilesFolder(appFilesDir.absolutePath)
apiSetTempFolder(getTempFilesDirectory())
apiSetFilesFolder(getAppFilesDirectory())
apiSetXFTPConfig(getXFTPCfg())
val justStarted = apiStartChat()
val users = listUsers()
@@ -333,7 +328,7 @@ object ChatController {
chatModel.userCreated.value = true
apiSetIncognito(chatModel.incognito.value)
getUserChatData()
appPrefs.chatLastStart.set(Clock.System.now())
chatModel.controller.appPrefs.chatLastStart.set(Clock.System.now())
chatModel.chatRunning.value = true
startReceiver()
Log.d(TAG, "startChat: started")
@@ -471,19 +466,13 @@ object ChatController {
suspend fun apiSetAllContactReceipts(enable: Boolean) {
val r = sendCmd(CC.SetAllContactReceipts(enable))
if (r is CR.CmdOk) return
throw Exception("failed to set receipts for all users ${r.responseType} ${r.details}")
throw Exception("failed to enable 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 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}")
throw Exception("failed to enable receipts for user contacts ${r.responseType} ${r.details}")
}
suspend fun apiHideUser(userId: Long, viewPwd: String): User =
@@ -977,8 +966,7 @@ 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}")
@@ -990,8 +978,7 @@ 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}")
@@ -1420,7 +1407,7 @@ object ChatController {
|| (mc is MsgContent.MCVoice && file.fileSize <= MAX_VOICE_SIZE_AUTO_RCV && file.fileStatus !is CIFileStatus.RcvAccepted))) {
withApi { receiveFile(r.user, file.fileId) }
}
if (cItem.showNotification && (allowedToShowNotification() || chatModel.chatId.value != cInfo.id)) {
if (cItem.showNotification && (!SimplexApp.context.isAppOnForeground || chatModel.chatId.value != cInfo.id)) {
ntfManager.notifyMessageReceived(r.user, cInfo, cItem)
}
}
@@ -1573,7 +1560,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 = appPrefs.webrtcPolicyRelay.get()
val useRelay = chatModel.controller.appPrefs.webrtcPolicyRelay.get()
val iceServers = getIceServers()
Log.d(TAG, ".callOffer iceServers $iceServers")
chatModel.callCommand.value = WCallCommand.Offer(
@@ -1791,7 +1778,6 @@ 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()
@@ -1889,11 +1875,7 @@ sealed class CC {
is SetAllContactReceipts -> "/set receipts all ${onOff(enable)}"
is ApiSetUserContactReceipts -> {
val mrs = userMsgReceiptSettings
"/_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)}"
"/_set receipts $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)}"
@@ -1992,7 +1974,6 @@ sealed class CC {
is ApiSetActiveUser -> "apiSetActiveUser"
is SetAllContactReceipts -> "setAllContactReceipts"
is ApiSetUserContactReceipts -> "apiSetUserContactReceipts"
is ApiSetUserGroupReceipts -> "apiSetUserGroupReceipts"
is ApiHideUser -> "apiHideUser"
is ApiUnhideUser -> "apiUnhideUser"
is ApiMuteUser -> "apiMuteUser"
@@ -2355,9 +2336,9 @@ data class NetCfg(
hostMode = HostMode.OnionViaSocks,
requiredHostMode = false,
sessionMode = TransportSessionMode.User,
tcpConnectTimeout = 10_000_000,
tcpTimeout = 7_000_000,
tcpTimeoutPerKb = 10_000,
tcpConnectTimeout = 15_000_000,
tcpTimeout = 10_000_000,
tcpTimeoutPerKb = 20_000,
tcpKeepAlive = KeepAliveOpts.defaults,
smpPingInterval = 1200_000_000,
smpPingCount = 3
@@ -2369,9 +2350,9 @@ data class NetCfg(
hostMode = HostMode.OnionViaSocks,
requiredHostMode = false,
sessionMode = TransportSessionMode.User,
tcpConnectTimeout = 20_000_000,
tcpTimeout = 15_000_000,
tcpTimeoutPerKb = 20_000,
tcpConnectTimeout = 30_000_000,
tcpTimeout = 20_000_000,
tcpTimeoutPerKb = 40_000,
tcpKeepAlive = KeepAliveOpts.defaults,
smpPingInterval = 1200_000_000,
smpPingCount = 3
@@ -3971,12 +3952,3 @@ 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
}
}

View File

@@ -1,4 +1,4 @@
package chat.simplex.common.ui.theme
package chat.simplex.app.ui.theme
import androidx.compose.ui.graphics.Color

View File

@@ -1,4 +1,4 @@
package chat.simplex.common.ui.theme
package chat.simplex.app.ui.theme
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.Shapes

View File

@@ -1,19 +1,22 @@
package chat.simplex.common.ui.theme
package chat.simplex.app.ui.theme
import android.app.UiModeManager
import android.content.Context
import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.*
import androidx.compose.ui.unit.dp
import chat.simplex.common.model.ChatController
import chat.simplex.common.platform.isInNightMode
import chat.simplex.common.views.helpers.*
import chat.simplex.app.R
import chat.simplex.app.SimplexApp
import chat.simplex.app.views.helpers.*
import chat.simplex.res.MR
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import chat.simplex.res.MR
enum class DefaultTheme {
SYSTEM, LIGHT, DARK, SIMPLEX;
@@ -190,11 +193,6 @@ val DEFAULT_PADDING_HALF = DEFAULT_PADDING / 2
val DEFAULT_BOTTOM_PADDING = 48.dp
val DEFAULT_BOTTOM_BUTTON_PADDING = 20.dp
val DEFAULT_START_MODAL_WIDTH = 388.dp
val DEFAULT_MIN_CENTER_MODAL_WIDTH = 590.dp
val DEFAULT_END_MODAL_WIDTH = 388.dp
val DEFAULT_MAX_IMAGE_WIDTH = 500.dp
val DarkColorPalette = darkColors(
primary = SimplexBlue, // If this value changes also need to update #0088ff in string resource files
primaryVariant = SimplexBlue,
@@ -256,18 +254,13 @@ val SimplexColorPaletteApp = AppColors(
val CurrentColors: MutableStateFlow<ThemeManager.ActiveTheme> = MutableStateFlow(ThemeManager.currentColors(isInNightMode()))
// Non-@Composable implementation
private fun isInNightMode() =
(SimplexApp.context.getSystemService(Context.UI_MODE_SERVICE) as UiModeManager).nightMode == UiModeManager.MODE_NIGHT_YES
@Composable
fun isInDarkTheme(): Boolean = !CurrentColors.collectAsState().value.colors.isLight
expect fun isSystemInDarkTheme(): Boolean
fun reactOnDarkThemeChanges(isDark: Boolean) {
if (ChatController.appPrefs.currentTheme.get() == DefaultTheme.SYSTEM.name && CurrentColors.value.colors.isLight == isDark) {
// Change active colors from light to dark and back based on system theme
ThemeManager.applyTheme(DefaultTheme.SYSTEM.name, isDark)
}
}
@Composable
fun SimpleXTheme(darkTheme: Boolean? = null, content: @Composable () -> Unit) {
LaunchedEffect(darkTheme) {
@@ -277,7 +270,10 @@ fun SimpleXTheme(darkTheme: Boolean? = null, content: @Composable () -> Unit) {
}
val systemDark = isSystemInDarkTheme()
LaunchedEffect(systemDark) {
reactOnDarkThemeChanges(systemDark)
if (SimplexApp.context.chatModel.controller.appPrefs.currentTheme.get() == DefaultTheme.SYSTEM.name && CurrentColors.value.colors.isLight == systemDark) {
// Change active colors from light to dark and back based on system theme
ThemeManager.applyTheme(DefaultTheme.SYSTEM.name, systemDark)
}
}
val theme by CurrentColors.collectAsState()
MaterialTheme(

View File

@@ -1,20 +1,18 @@
package chat.simplex.common.ui.theme
package chat.simplex.app.ui.theme
import androidx.compose.material.Colors
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.text.font.FontFamily
import chat.simplex.app.R
import chat.simplex.app.SimplexApp
import chat.simplex.app.model.AppPreferences
import chat.simplex.app.views.helpers.generalGetString
import chat.simplex.res.MR
import chat.simplex.common.model.AppPreferences
import chat.simplex.common.model.ChatController
import chat.simplex.common.views.helpers.generalGetString
// https://github.com/rsms/inter
// I place it here because IDEA shows an error (but still works anyway) when this declaration inside Type.kt
expect val Inter: FontFamily
object ThemeManager {
private val appPrefs: AppPreferences = ChatController.appPrefs
private val appPrefs: AppPreferences by lazy {
SimplexApp.context.chatModel.controller.appPrefs
}
data class ActiveTheme(val name: String, val base: DefaultTheme, val colors: Colors, val appColors: AppColors)

View File

@@ -1,9 +1,20 @@
package chat.simplex.common.ui.theme
package chat.simplex.app.ui.theme
import androidx.compose.material.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.*
import androidx.compose.ui.unit.sp
import chat.simplex.res.MR
// https://github.com/rsms/inter
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)
)
// Set of Material typography styles to start with
val Typography = Typography(

View File

@@ -1,4 +1,4 @@
package chat.simplex.common.views
package chat.simplex.app.views
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material.MaterialTheme

View File

@@ -1,6 +1,7 @@
package chat.simplex.common.views
package chat.simplex.app.views
import androidx.compose.desktop.ui.tooling.preview.Preview
import android.content.res.Configuration
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.*
@@ -8,37 +9,32 @@ import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.common.model.*
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.chat.*
import chat.simplex.common.views.helpers.*
import chat.simplex.common.model.ChatModel
import chat.simplex.common.platform.*
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.chat.*
import chat.simplex.app.views.helpers.*
import com.google.accompanist.insets.ProvideWindowInsets
import com.google.accompanist.insets.navigationBarsWithImePadding
@Composable
fun TerminalView(chatModel: ChatModel, close: () -> Unit) {
val composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = false)) }
val close = {
close()
if (appPlatform.isDesktop) {
ModalManager.center.closeModals()
}
}
BackHandler(onBack = {
close()
})
TerminalLayout(
chatModel.terminalItems,
composeState,
sendCommand = { sendCommand(chatModel, composeState) },
close
)
TerminalLayout(
remember { chatModel.terminalItems },
composeState,
sendCommand = { sendCommand(chatModel, composeState) },
close
)
}
private fun sendCommand(chatModel: ChatModel, composeState: MutableState<ComposeState>) {
@@ -62,7 +58,7 @@ private fun sendCommand(chatModel: ChatModel, composeState: MutableState<Compose
@Composable
fun TerminalLayout(
terminalItems: MutableState<List<TerminalItem>>,
terminalItems: List<TerminalItem>,
composeState: MutableState<ComposeState>,
sendCommand: () -> Unit,
close: () -> Unit
@@ -115,13 +111,13 @@ fun TerminalLayout(
private var lazyListState = 0 to 0
@Composable
fun TerminalLog(terminalItems: MutableState<List<TerminalItem>>) {
fun TerminalLog(terminalItems: List<TerminalItem>) {
val listState = rememberLazyListState(lazyListState.first, lazyListState.second)
DisposableEffect(Unit) {
onDispose { lazyListState = listState.firstVisibleItemIndex to listState.firstVisibleItemScrollOffset }
}
val reversedTerminalItems by remember { derivedStateOf { terminalItems.value.reversed().toList() } }
val clipboard = LocalClipboardManager.current
val reversedTerminalItems by remember { derivedStateOf { terminalItems.reversed().toList() } }
val context = LocalContext.current
LazyColumn(state = listState, reverseLayout = true) {
items(reversedTerminalItems) { item ->
Text(
@@ -132,7 +128,7 @@ fun TerminalLog(terminalItems: MutableState<List<TerminalItem>>) {
modifier = Modifier
.fillMaxWidth()
.clickable {
ModalManager.start.showModal(endButtons = { ShareButton { clipboard.shareText(item.details) } }) {
ModalManager.shared.showModal(endButtons = { ShareButton { shareText(item.details) } }) {
SelectionContainer(modifier = Modifier.verticalScroll(rememberScrollState())) {
Text(item.details, modifier = Modifier.padding(horizontal = DEFAULT_PADDING).padding(bottom = DEFAULT_PADDING))
}
@@ -143,16 +139,17 @@ fun TerminalLog(terminalItems: MutableState<List<TerminalItem>>) {
}
}
@Preview/*(
@Preview(showBackground = true)
@Preview(
uiMode = Configuration.UI_MODE_NIGHT_YES,
showBackground = true,
name = "Dark Mode"
)*/
)
@Composable
fun PreviewTerminalLayout() {
SimpleXTheme {
TerminalLayout(
terminalItems = remember { mutableStateOf(TerminalItem.sampleData) },
terminalItems = TerminalItem.sampleData,
composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = false)) },
sendCommand = {},
close = {}

View File

@@ -1,4 +1,4 @@
package chat.simplex.common.views
package chat.simplex.app.views
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
@@ -22,13 +22,13 @@ import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.style.*
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.common.model.ChatModel
import chat.simplex.common.model.Profile
import chat.simplex.common.platform.navigationBarsWithImePadding
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.helpers.*
import chat.simplex.common.views.onboarding.OnboardingStage
import chat.simplex.common.views.onboarding.ReadableText
import chat.simplex.app.model.ChatModel
import chat.simplex.app.model.Profile
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.helpers.*
import chat.simplex.app.views.onboarding.OnboardingStage
import chat.simplex.app.views.onboarding.ReadableText
import com.google.accompanist.insets.navigationBarsWithImePadding
import chat.simplex.res.MR
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.distinctUntilChanged

View File

@@ -1,9 +1,10 @@
package chat.simplex.common.views.call
package chat.simplex.app.views.call
import chat.simplex.common.model.ChatModel
import chat.simplex.common.platform.*
import chat.simplex.common.views.helpers.withApi
import chat.simplex.common.views.usersettings.showInDevelopingAlert
import android.util.Log
import chat.simplex.app.TAG
import chat.simplex.app.model.ChatModel
import chat.simplex.app.views.helpers.ModalManager
import chat.simplex.app.views.helpers.withApi
import kotlinx.datetime.Clock
import kotlin.time.Duration.Companion.minutes
@@ -15,19 +16,16 @@ class CallManager(val chatModel: ChatModel) {
if (invitation.user.showNotifications) {
if (Clock.System.now() - invitation.callTs <= 3.minutes) {
activeCallInvitation.value = invitation
ntfManager.notifyCallInvitation(invitation)
controller.ntfManager.notifyCallInvitation(invitation)
} else {
val contact = invitation.contact
ntfManager.displayNotification(user = invitation.user, chatId = contact.id, displayName = contact.displayName, msgText = invitation.callTypeText)
controller.ntfManager.displayNotification(user = invitation.user, chatId = contact.id, displayName = contact.displayName, msgText = invitation.callTypeText)
}
}
}
}
fun acceptIncomingCall(invitation: RcvCallInvitation) {
if (appPlatform.isDesktop) {
return showInDevelopingAlert()
}
val call = chatModel.activeCall.value
if (call == null) {
justAcceptIncomingCall(invitation = invitation)
@@ -65,7 +63,7 @@ class CallManager(val chatModel: ChatModel) {
callInvitations.remove(invitation.contact.id)
if (invitation.contact.id == activeCallInvitation.value?.contact?.id) {
activeCallInvitation.value = null
ntfManager.cancelCallNotification()
controller.ntfManager.cancelCallNotification()
}
}
}
@@ -91,7 +89,7 @@ class CallManager(val chatModel: ChatModel) {
callInvitations.remove(invitation.contact.id)
if (invitation.contact.id == activeCallInvitation.value?.contact?.id) {
activeCallInvitation.value = null
ntfManager.cancelCallNotification()
controller.ntfManager.cancelCallNotification()
}
withApi {
if (!controller.apiRejectCall(invitation.contact)) {
@@ -104,7 +102,7 @@ class CallManager(val chatModel: ChatModel) {
fun reportCallRemoteEnded(invitation: RcvCallInvitation) {
if (chatModel.activeCallInvitation.value?.contact?.id == invitation.contact.id) {
chatModel.activeCallInvitation.value = null
ntfManager.cancelCallNotification()
chatModel.controller.ntfManager.cancelCallNotification()
}
}
}

View File

@@ -1,4 +1,4 @@
package chat.simplex.common.views.call
package chat.simplex.app.views.call
import android.Manifest
import android.annotation.SuppressLint
@@ -9,9 +9,10 @@ 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.compose.desktop.ui.tooling.preview.Preview
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.runtime.*
@@ -25,21 +26,22 @@ 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.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 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.res.MR
import dev.icerock.moko.resources.StringResource
import kotlinx.coroutines.*
import kotlinx.serialization.decodeFromString
@@ -47,21 +49,20 @@ import kotlinx.serialization.encodeToString
@SuppressLint("SourceLockedOrientationActivity")
@Composable
actual fun ActiveCallView() {
val chatModel = ChatModel
fun ActiveCallView(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 }
val ntfModeService = remember { chatModel.controller.appPrefs.notificationsMode.get() == NotificationsMode.SERVICE.name }
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) platform.androidServiceStart()
if (!ntfModeService) SimplexService.start()
}
DisposableEffect(Unit) {
val am = androidAppContext.getSystemService(Context.AUDIO_SERVICE) as AudioManager
val am = SimplexApp.context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
var btDeviceCount = 0
val audioCallback = object: AudioDeviceCallback() {
override fun onAudioDevicesAdded(addedDevices: Array<out AudioDeviceInfo>) {
@@ -88,16 +89,16 @@ actual fun ActiveCallView() {
}
}
am.registerAudioDeviceCallback(audioCallback, null)
val pm = (androidAppContext.getSystemService(Context.POWER_SERVICE) as PowerManager)
val pm = (SimplexApp.context.getSystemService(Context.POWER_SERVICE) as PowerManager)
val proximityLock = if (pm.isWakeLockLevelSupported(PROXIMITY_SCREEN_OFF_WAKE_LOCK)) {
pm.newWakeLock(PROXIMITY_SCREEN_OFF_WAKE_LOCK, androidAppContext.packageName + ":proximityLock")
pm.newWakeLock(PROXIMITY_SCREEN_OFF_WAKE_LOCK, SimplexApp.context.packageName + ":proximityLock")
} else {
null
}
proximityLock?.acquire()
onDispose {
// Stop it when call ended
if (!ntfModeService) platform.androidServiceSafeStop()
if (!ntfModeService) SimplexService.safeStopService(SimplexApp.context)
dropAudioManagerOverrides()
am.unregisterAudioDeviceCallback(audioCallback)
proximityLock?.release()
@@ -216,7 +217,7 @@ private fun ActiveCallOverlay(call: Call, chatModel: ChatModel, audioViaBluetoot
}
private fun setCallSound(speaker: Boolean, audioViaBluetooth: MutableState<Boolean>) {
val am = androidAppContext.getSystemService(Context.AUDIO_SERVICE) as AudioManager
val am = SimplexApp.context.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) {
@@ -242,7 +243,7 @@ private fun setCallSound(speaker: Boolean, audioViaBluetooth: MutableState<Boole
}
private fun dropAudioManagerOverrides() {
val am = androidAppContext.getSystemService(Context.AUDIO_SERVICE) as AudioManager
val am = SimplexApp.context.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) {
@@ -352,7 +353,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)
}

View File

@@ -4,14 +4,16 @@ 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 chat.simplex.common.platform.Log
import android.util.Log
import android.view.WindowManager
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.desktop.ui.tooling.preview.Preview
import androidx.activity.viewModels
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.*
@@ -22,27 +24,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 androidx.compose.ui.res.painterResource
import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
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.common.model.*
import chat.simplex.app.model.*
import chat.simplex.app.model.NtfManager.OpenChatAction
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.app.ui.theme.*
import chat.simplex.app.views.helpers.ProfileImage
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(ChatModel) }
setContent { IncomingCallActivityView(vm.chatModel) }
unlockForIncomingCall()
}
@@ -101,7 +103,7 @@ fun IncomingCallActivityView(m: ChatModel) {
) {
if (showCallView) {
Box {
ActiveCallView()
ActiveCallView(m)
if (invitation != null) IncomingCallAlertView(invitation, m)
}
} else if (invitation != null) {
@@ -119,7 +121,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
ntfManager.cancelCallNotification()
chatModel.controller.ntfManager.cancelCallNotification()
}
}
IncomingCallLockScreenAlertLayout(
@@ -129,7 +131,7 @@ fun IncomingCallLockScreenAlert(invitation: RcvCallInvitation, chatModel: ChatMo
rejectCall = { cm.endCall(invitation = invitation) },
ignoreCall = {
chatModel.activeCallInvitation.value = null
ntfManager.cancelCallNotification()
chatModel.controller.ntfManager.cancelCallNotification()
},
acceptCall = { cm.acceptIncomingCall(invitation = invitation) },
openApp = {
@@ -169,18 +171,18 @@ fun IncomingCallLockScreenAlertLayout(
Text(invitation.contact.chatViewName, style = MaterialTheme.typography.h2)
Spacer(Modifier.fillMaxHeight().weight(1f))
Row {
LockScreenCallButton(stringResource(MR.strings.reject), painterResource(R.drawable.ic_call_end_filled), Color.Red, rejectCall)
LockScreenCallButton(stringResource(MR.strings.reject), painterResource(MR.images.ic_call_end_filled), Color.Red, rejectCall)
Spacer(Modifier.size(48.dp))
LockScreenCallButton(stringResource(MR.strings.ignore), painterResource(R.drawable.ic_close), MaterialTheme.colors.primary, ignoreCall)
LockScreenCallButton(stringResource(MR.strings.ignore), painterResource(MR.images.ic_close), MaterialTheme.colors.primary, ignoreCall)
Spacer(Modifier.size(48.dp))
LockScreenCallButton(stringResource(MR.strings.accept), painterResource(R.drawable.ic_check_filled), SimplexGreen, acceptCall)
LockScreenCallButton(stringResource(MR.strings.accept), painterResource(MR.images.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(R.drawable.ic_check_filled), click = openApp)
SimpleButton(text = stringResource(MR.strings.open_verb), icon = painterResource(MR.images.ic_check_filled), click = openApp)
}
}
}
@@ -188,7 +190,7 @@ fun IncomingCallLockScreenAlertLayout(
@Composable
private fun SimpleXLogo() {
Image(
painter = painterResource(if (isInDarkTheme()) R.drawable.logo_light else R.drawable.logo),
painter = painterResource(if (isInDarkTheme()) MR.images.logo_light else MR.images.logo),
contentDescription = stringResource(MR.strings.image_descr_simplex_logo),
modifier = Modifier
.padding(vertical = DEFAULT_PADDING)
@@ -217,10 +219,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) {

View File

@@ -1,6 +1,5 @@
package chat.simplex.common.views.call
package chat.simplex.app.views.call
import androidx.compose.desktop.ui.tooling.preview.Preview
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
@@ -11,31 +10,34 @@ import androidx.compose.ui.Modifier
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.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import chat.simplex.common.model.*
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.helpers.ProfileImage
import chat.simplex.common.views.usersettings.ProfilePreview
import chat.simplex.common.platform.ntfManager
import chat.simplex.common.platform.SoundPlayer
import chat.simplex.app.R
import chat.simplex.app.SimplexApp
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.helpers.ProfileImage
import chat.simplex.app.views.usersettings.ProfilePreview
import chat.simplex.res.MR
import kotlinx.datetime.Clock
@Composable
fun IncomingCallAlertView(invitation: RcvCallInvitation, chatModel: ChatModel) {
val cm = chatModel.callManager
val cxt = LocalContext.current
val scope = rememberCoroutineScope()
LaunchedEffect(true) { SoundPlayer.start(scope, sound = !chatModel.showCallView.value) }
DisposableEffect(true) { onDispose { SoundPlayer.stop() } }
LaunchedEffect(true) { SoundPlayer.shared.start(scope, sound = !chatModel.showCallView.value) }
DisposableEffect(true) { onDispose { SoundPlayer.shared.stop() } }
IncomingCallAlertLayout(
invitation,
chatModel,
rejectCall = { cm.endCall(invitation = invitation) },
ignoreCall = {
chatModel.activeCallInvitation.value = null
ntfManager.cancelCallNotification()
chatModel.controller.ntfManager.cancelCallNotification()
},
acceptCall = { cm.acceptIncomingCall(invitation = invitation) }
)
@@ -112,7 +114,7 @@ fun PreviewIncomingCallAlertLayout() {
sharedKey = null,
callTs = Clock.System.now()
),
chatModel = ChatModel,
chatModel = SimplexApp.context.chatModel,
rejectCall = {},
ignoreCall = {},
acceptCall = {}

View File

@@ -1,22 +1,22 @@
package chat.simplex.common.helpers
package chat.simplex.app.views.call
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.common.R
import chat.simplex.common.platform.SoundPlayerInterface
import chat.simplex.common.platform.androidAppContext
import chat.simplex.common.views.helpers.withScope
import chat.simplex.app.R
import chat.simplex.app.SimplexApp
import chat.simplex.app.views.helpers.withScope
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
object SoundPlayer: SoundPlayerInterface {
class SoundPlayer {
private var player: MediaPlayer? = null
var playing = false
override fun start(scope: CoroutineScope, sound: Boolean) {
fun start(scope: CoroutineScope, sound: Boolean) {
player?.reset()
player = MediaPlayer().apply {
setAudioAttributes(
@@ -25,10 +25,10 @@ object SoundPlayer: SoundPlayerInterface {
.setUsage(AudioAttributes.USAGE_NOTIFICATION_RINGTONE)
.build()
)
setDataSource(androidAppContext, Uri.parse("android.resource://" + androidAppContext.packageName + "/" + R.raw.ring_once))
setDataSource(SimplexApp.context, Uri.parse("android.resource://" + SimplexApp.context.packageName + "/" + R.raw.ring_once))
prepare()
}
val vibrator = ContextCompat.getSystemService(androidAppContext, Vibrator::class.java)
val vibrator = ContextCompat.getSystemService(SimplexApp.context, Vibrator::class.java)
val effect = VibrationEffect.createOneShot(250, VibrationEffect.DEFAULT_AMPLITUDE)
playing = true
withScope(scope) {
@@ -40,8 +40,12 @@ object SoundPlayer: SoundPlayerInterface {
}
}
override fun stop() {
fun stop() {
playing = false
player?.stop()
}
companion object {
val shared = SoundPlayer()
}
}

View File

@@ -1,9 +1,11 @@
package chat.simplex.common.views.call
package chat.simplex.app.views.call
import androidx.compose.runtime.Composable
import dev.icerock.moko.resources.compose.stringResource
import chat.simplex.common.views.helpers.generalGetString
import chat.simplex.common.model.*
import chat.simplex.app.*
import chat.simplex.app.model.Contact
import chat.simplex.app.model.User
import chat.simplex.app.views.helpers.generalGetString
import chat.simplex.res.MR
import kotlinx.datetime.Instant
import kotlinx.serialization.SerialName
@@ -212,7 +214,7 @@ fun parseRTCIceServers(servers: List<String>): List<RTCIceServer>? {
}
fun getIceServers(): List<RTCIceServer>? {
val value = ChatController.appPrefs.webrtcIceServers.get() ?: return null
val value = SimplexApp.context.chatController.appPrefs.webrtcIceServers.get() ?: return null
val servers: List<String> = value.split("\n")
return parseRTCIceServers(servers)
}

View File

@@ -1,4 +1,4 @@
package chat.simplex.common.views.chat
package chat.simplex.app.views.chat
import InfoRow
import InfoRowEllipsis
@@ -8,7 +8,8 @@ import SectionItemView
import SectionSpacer
import SectionTextFooter
import SectionView
import androidx.compose.desktop.ui.tooling.preview.Preview
import android.widget.Toast
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.*
@@ -25,15 +26,16 @@ import dev.icerock.moko.resources.compose.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.common.model.*
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.helpers.*
import chat.simplex.common.views.newchat.QRCode
import chat.simplex.common.views.usersettings.*
import chat.simplex.common.platform.*
import chat.simplex.common.views.chatlist.updateChatSettings
import chat.simplex.app.SimplexApp
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.chatlist.updateChatSettings
import chat.simplex.app.views.helpers.*
import chat.simplex.app.views.newchat.QRCode
import chat.simplex.app.views.usersettings.*
import chat.simplex.res.MR
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.*
@@ -81,7 +83,7 @@ fun ChatInfoView(
setContactAlias(chat.chatInfo.apiId, it, chatModel)
},
openPreferences = {
ModalManager.end.showCustomModal { close ->
ModalManager.shared.showCustomModal { close ->
val user = chatModel.currentUser.value
if (user != null) {
ContactPreferencesView(chatModel, user, contact.contactId, close)
@@ -136,7 +138,7 @@ fun ChatInfoView(
})
},
verifyClicked = {
ModalManager.end.showModalCloseable { close ->
ModalManager.shared.showModalCloseable { close ->
remember { derivedStateOf { (chatModel.getContactChat(contact.contactId)?.chatInfo as? ChatInfo.Direct)?.contact } }.value?.let { ct ->
VerifyCodeView(
ct.displayName,
@@ -203,7 +205,7 @@ fun deleteContactDialog(chatInfo: ChatInfo, chatModel: ChatModel, close: (() ->
if (r) {
chatModel.removeChat(chatInfo.id)
chatModel.chatId.value = null
ntfManager.cancelNotificationsForChat(chatInfo.id)
chatModel.controller.ntfManager.cancelNotificationsForChat(chatInfo.id)
close?.invoke()
}
}
@@ -222,7 +224,7 @@ fun clearChatDialog(chatInfo: ChatInfo, chatModel: ChatModel, close: (() -> Unit
val updatedChatInfo = chatModel.controller.apiClearChat(chatInfo.chatType, chatInfo.apiId)
if (updatedChatInfo != null) {
chatModel.clearChat(updatedChatInfo)
ntfManager.cancelNotificationsForChat(chatInfo.id)
chatModel.controller.ntfManager.cancelNotificationsForChat(chatInfo.id)
close?.invoke()
}
}
@@ -294,8 +296,7 @@ fun ChatInfoLayout(
if (contact.contactLink != null) {
SectionView(stringResource(MR.strings.address_section_title).uppercase()) {
QRCode(contact.contactLink, Modifier.padding(horizontal = DEFAULT_PADDING, vertical = DEFAULT_PADDING_HALF).aspectRatio(1f))
val clipboard = LocalClipboardManager.current
ShareAddressButton { clipboard.shareText(contact.contactLink) }
ShareAddressButton { shareText(contact.contactLink) }
SectionTextFooter(stringResource(MR.strings.you_can_share_this_address_with_your_contacts).format(contact.displayName))
}
SectionDividerSpaced()
@@ -489,7 +490,7 @@ fun SimplexServers(text: String, servers: List<String>) {
val clipboardManager: ClipboardManager = LocalClipboardManager.current
InfoRowEllipsis(text, info) {
clipboardManager.setText(AnnotatedString(servers.joinToString(separator = ",")))
showToast(generalGetString(MR.strings.copied))
Toast.makeText(SimplexApp.context, generalGetString(MR.strings.copied), Toast.LENGTH_SHORT).show()
}
}

View File

@@ -1,9 +1,8 @@
package chat.simplex.common.views.chat
package chat.simplex.app.views.chat
import InfoRow
import SectionBottomSpacer
import SectionDividerSpaced
import SectionItemView
import SectionView
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
@@ -13,37 +12,32 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.text.AnnotatedString
import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.common.model.*
import chat.simplex.common.views.chat.item.ItemAction
import chat.simplex.common.views.chat.item.MarkdownText
import chat.simplex.common.views.helpers.*
import chat.simplex.common.platform.shareText
import chat.simplex.common.ui.theme.*
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.CurrentColors
import chat.simplex.app.ui.theme.DEFAULT_PADDING
import chat.simplex.app.views.chat.item.ItemAction
import chat.simplex.app.views.chat.item.MarkdownText
import chat.simplex.app.views.helpers.*
import chat.simplex.res.MR
import dev.icerock.moko.resources.ImageResource
sealed class CIInfoTab {
class Delivery(val memberDeliveryStatuses: List<MemberDeliveryStatus>): CIInfoTab()
object History: CIInfoTab()
class Quote(val quotedItem: CIQuote): CIInfoTab()
enum class CIInfoTab {
History, Quote
}
@Composable
fun ChatItemInfoView(chatModel: ChatModel, ci: ChatItem, ciInfo: ChatItemInfo, devTools: Boolean) {
fun ChatItemInfoView(ci: ChatItem, ciInfo: ChatItemInfo, devTools: Boolean) {
val sent = ci.chatDir.sent
val appColors = CurrentColors.collectAsState().value.appColors
val uriHandler = LocalUriHandler.current
val selection = remember { mutableStateOf<CIInfoTab>(CIInfoTab.History) }
val selection = remember { mutableStateOf(CIInfoTab.History) }
@Composable
fun TextBubble(text: String, formattedText: List<FormattedText>?, sender: String?, showMenu: MutableState<Boolean>) {
@@ -73,7 +67,6 @@ fun ChatItemInfoView(chatModel: ChatModel, ci: ChatItem, ciInfo: ChatItemInfo, d
Box(
Modifier.clip(RoundedCornerShape(18.dp)).background(itemColor).padding(bottom = 3.dp)
.combinedClickable(onLongClick = { showMenu.value = true }, onClick = {})
.onRightClick { showMenu.value = true }
) {
Box(Modifier.padding(vertical = 6.dp, horizontal = 12.dp)) {
TextBubble(text, ciVersion.formattedText, sender = null, showMenu)
@@ -95,14 +88,13 @@ fun ChatItemInfoView(chatModel: ChatModel, ci: ChatItem, ciInfo: ChatItemInfo, d
}
}
if (text != "") {
val clipboard = LocalClipboardManager.current
DefaultDropdownMenu(showMenu) {
ItemAction(stringResource(MR.strings.share_verb), painterResource(MR.images.ic_share), onClick = {
clipboard.shareText(text)
shareText(text)
showMenu.value = false
})
ItemAction(stringResource(MR.strings.copy_verb), painterResource(MR.images.ic_content_copy), onClick = {
clipboard.setText(AnnotatedString(text))
copyText(text)
showMenu.value = false
})
}
@@ -120,7 +112,6 @@ fun ChatItemInfoView(chatModel: ChatModel, ci: ChatItem, ciInfo: ChatItemInfo, d
Box(
Modifier.clip(RoundedCornerShape(18.dp)).background(quoteColor).padding(bottom = 3.dp)
.combinedClickable(onLongClick = { showMenu.value = true }, onClick = {})
.onRightClick { showMenu.value = true }
) {
Box(Modifier.padding(vertical = 6.dp, horizontal = 12.dp)) {
TextBubble(text, qi.formattedText, sender = qi.sender(null), showMenu)
@@ -135,14 +126,13 @@ fun ChatItemInfoView(chatModel: ChatModel, ci: ChatItem, ciInfo: ChatItemInfo, d
)
}
if (text != "") {
val clipboard = LocalClipboardManager.current
DefaultDropdownMenu(showMenu) {
ItemAction(stringResource(MR.strings.share_verb), painterResource(MR.images.ic_share), onClick = {
clipboard.shareText(text)
shareText(text)
showMenu.value = false
})
ItemAction(stringResource(MR.strings.copy_verb), painterResource(MR.images.ic_content_copy), onClick = {
clipboard.setText(AnnotatedString(text))
copyText(text)
showMenu.value = false
})
}
@@ -219,154 +209,55 @@ fun ChatItemInfoView(chatModel: ChatModel, ci: ChatItem, ciInfo: ChatItemInfo, d
}
}
@Composable
fun MemberDeliveryStatusView(member: GroupMember, status: CIStatus) {
SectionItemView(
padding = PaddingValues(horizontal = 0.dp)
) {
ProfileImage(size = 36.dp, member.image)
Spacer(Modifier.width(DEFAULT_SPACE_AFTER_ICON))
Text(
member.chatViewName,
modifier = Modifier.weight(10f, fill = true),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Spacer(Modifier.fillMaxWidth().weight(1f))
val statusIcon = status.statusIcon(MaterialTheme.colors.primary, CurrentColors.value.colors.secondary)
Box(
Modifier
.size(36.dp)
.clip(RoundedCornerShape(20.dp))
.clickable {
AlertManager.shared.showAlertMsg(
title = status.statusText,
text = status.statusDescription
)
},
contentAlignment = Alignment.Center
) {
if (statusIcon != null) {
val (icon, statusColor) = statusIcon
Icon(
painterResource(icon),
contentDescription = null,
tint = statusColor
)
} else {
Icon(
painterResource(MR.images.ic_more_horiz),
contentDescription = null,
tint = CurrentColors.value.colors.secondary
)
}
}
}
}
@Composable
fun DeliveryTab(memberDeliveryStatuses: List<MemberDeliveryStatus>) {
Column(Modifier.fillMaxWidth().verticalScroll(rememberScrollState())) {
Details()
SectionDividerSpaced(maxTopPadding = false, maxBottomPadding = false)
val mss = membersStatuses(chatModel, memberDeliveryStatuses)
if (mss.isNotEmpty()) {
SectionView(padding = PaddingValues(horizontal = DEFAULT_PADDING)) {
Text(stringResource(MR.strings.delivery), style = MaterialTheme.typography.h2, modifier = Modifier.padding(bottom = DEFAULT_PADDING))
mss.forEach { (member, status) ->
MemberDeliveryStatusView(member, status)
}
}
} else {
SectionView(padding = PaddingValues(horizontal = DEFAULT_PADDING)) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(stringResource(MR.strings.no_info_on_delivery), color = MaterialTheme.colors.secondary)
}
}
}
SectionBottomSpacer()
}
}
@Composable
fun tabTitle(tab: CIInfoTab): String {
return when (tab) {
is CIInfoTab.Delivery -> stringResource(MR.strings.delivery)
is CIInfoTab.History -> stringResource(MR.strings.edit_history)
is CIInfoTab.Quote -> stringResource(MR.strings.in_reply_to)
CIInfoTab.History -> stringResource(MR.strings.edit_history)
CIInfoTab.Quote -> stringResource(MR.strings.in_reply_to)
}
}
fun tabIcon(tab: CIInfoTab): ImageResource {
return when (tab) {
is CIInfoTab.Delivery -> MR.images.ic_double_check
is CIInfoTab.History -> MR.images.ic_history
is CIInfoTab.Quote -> MR.images.ic_reply
CIInfoTab.History -> MR.images.ic_history
CIInfoTab.Quote -> MR.images.ic_reply
}
}
fun numTabs(): Int {
var numTabs = 1
if (ciInfo.memberDeliveryStatuses != null) {
numTabs += 1
}
if (ci.quotedItem != null) {
numTabs += 1
}
return numTabs
}
Column {
if (numTabs() > 1) {
if (ci.quotedItem != null) {
Column(
Modifier
.fillMaxHeight(),
verticalArrangement = Arrangement.SpaceBetween
) {
LaunchedEffect(Unit) {
if (ciInfo.memberDeliveryStatuses != null) {
selection.value = CIInfoTab.Delivery(ciInfo.memberDeliveryStatuses)
}
}
Column(Modifier.weight(1f)) {
when (val sel = selection.value) {
is CIInfoTab.Delivery -> {
DeliveryTab(sel.memberDeliveryStatuses)
}
is CIInfoTab.History -> {
when (selection.value) {
CIInfoTab.History -> {
HistoryTab()
}
is CIInfoTab.Quote -> {
QuoteTab(sel.quotedItem)
CIInfoTab.Quote -> {
QuoteTab(ci.quotedItem)
}
}
}
val availableTabs = mutableListOf<CIInfoTab>()
if (ciInfo.memberDeliveryStatuses != null) {
availableTabs.add(CIInfoTab.Delivery(ciInfo.memberDeliveryStatuses))
}
availableTabs.add(CIInfoTab.History)
if (ci.quotedItem != null) {
availableTabs.add(CIInfoTab.Quote(ci.quotedItem))
}
TabRow(
selectedTabIndex = availableTabs.indexOfFirst { it::class == selection.value::class },
selectedTabIndex = selection.value.ordinal,
backgroundColor = Color.Transparent,
contentColor = MaterialTheme.colors.primary,
) {
availableTabs.forEach { ciInfoTab ->
CIInfoTab.values().forEachIndexed { index, it ->
Tab(
selected = selection.value::class == ciInfoTab::class,
selected = selection.value.ordinal == index,
onClick = {
selection.value = ciInfoTab
selection.value = CIInfoTab.values()[index]
},
text = { Text(tabTitle(ciInfoTab), fontSize = 13.sp) },
text = { Text(tabTitle(it), fontSize = 13.sp) },
icon = {
Icon(
painterResource(tabIcon(ciInfoTab)),
tabTitle(ciInfoTab)
painterResource(tabIcon(it)),
tabTitle(it)
)
},
selectedContentColor = MaterialTheme.colors.primary,
@@ -381,18 +272,10 @@ fun ChatItemInfoView(chatModel: ChatModel, ci: ChatItem, ciInfo: ChatItemInfo, d
}
}
private fun membersStatuses(chatModel: ChatModel, memberDeliveryStatuses: List<MemberDeliveryStatus>): List<Pair<GroupMember, CIStatus>> {
return memberDeliveryStatuses.mapNotNull { mds ->
chatModel.groupMembers.firstOrNull { it.groupMemberId == mds.groupMemberId }?.let { mem ->
mem to mds.memberDeliveryStatus
}
}
}
fun itemInfoShareText(chatModel: ChatModel, ci: ChatItem, chatItemInfo: ChatItemInfo, devTools: Boolean): String {
fun itemInfoShareText(ci: ChatItem, chatItemInfo: ChatItemInfo, devTools: Boolean): String {
val meta = ci.meta
val sent = ci.chatDir.sent
val shareText = mutableListOf<String>("# " + generalGetString(if (sent) MR.strings.sent_message else MR.strings.received_message), "")
val shareText = mutableListOf<String>(generalGetString(if (sent) MR.strings.sent_message else MR.strings.received_message), "")
shareText.add(String.format(generalGetString(MR.strings.share_text_sent_at), localTimestamp(meta.itemTs)))
if (!ci.chatDir.sent) {
@@ -422,7 +305,7 @@ fun itemInfoShareText(chatModel: ChatModel, ci: ChatItem, chatItemInfo: ChatItem
val qi = ci.quotedItem
if (qi != null) {
shareText.add("")
shareText.add("## " + generalGetString(MR.strings.in_reply_to))
shareText.add(generalGetString(MR.strings.in_reply_to))
shareText.add("")
val ts = localTimestamp(qi.sentAt)
val sender = qi.sender(null)
@@ -434,26 +317,10 @@ fun itemInfoShareText(chatModel: ChatModel, ci: ChatItem, chatItemInfo: ChatItem
val t = qi.text
shareText.add(if (t != "") t else generalGetString(MR.strings.item_info_no_text))
}
val mdss = chatItemInfo.memberDeliveryStatuses
if (mdss != null) {
val mss = membersStatuses(chatModel, mdss)
if (mss.isNotEmpty()) {
shareText.add("")
shareText.add("## " + generalGetString(MR.strings.delivery))
shareText.add("")
mss.forEach { (member, status) ->
shareText.add(String.format(
generalGetString(MR.strings.recipient_colon_delivery_status),
member.chatViewName,
status.statusDescription
))
}
}
}
val versions = chatItemInfo.itemVersions
if (versions.isNotEmpty()) {
shareText.add("")
shareText.add("## " + generalGetString(MR.strings.edit_history))
shareText.add(generalGetString(MR.strings.edit_history))
versions.forEachIndexed { index, itemVersion ->
val ts = localTimestamp(itemVersion.itemVersionTs)
shareText.add("")

View File

@@ -1,6 +1,10 @@
package chat.simplex.common.views.chat
package chat.simplex.app.views.chat
import androidx.compose.desktop.ui.tooling.preview.Preview
import android.content.res.Configuration
import android.graphics.Bitmap
import android.net.Uri
import android.util.Log
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.*
import androidx.compose.foundation.gestures.*
import androidx.compose.foundation.layout.*
@@ -22,25 +26,25 @@ import androidx.compose.ui.text.capitalize
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.intl.Locale
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.*
import chat.simplex.common.model.*
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.call.*
import chat.simplex.common.views.chat.group.*
import chat.simplex.common.views.chat.item.*
import chat.simplex.common.views.chatlist.*
import chat.simplex.common.views.helpers.*
import chat.simplex.common.model.GroupInfo
import chat.simplex.common.platform.*
import chat.simplex.common.platform.AudioPlayer
import chat.simplex.common.views.usersettings.showInDevelopingAlert
import androidx.core.content.FileProvider
import chat.simplex.app.*
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.call.*
import chat.simplex.app.views.chat.group.*
import chat.simplex.app.views.chat.item.*
import chat.simplex.app.views.chatlist.*
import chat.simplex.app.views.helpers.*
import chat.simplex.app.views.helpers.AppBarHeight
import com.google.accompanist.insets.ProvideWindowInsets
import com.google.accompanist.insets.navigationBarsWithImePadding
import chat.simplex.res.MR
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
import kotlinx.datetime.Clock
import java.io.File
import java.net.URI
import kotlin.math.sign
@Composable
@@ -96,7 +100,8 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: () -> Unit) {
.collect { activeChat.value = it }
}
}
val view = LocalMultiplatformView()
val view = LocalView.current
val context = LocalContext.current
if (activeChat.value == null || user == null) {
chatModel.chatId.value = null
} else {
@@ -108,7 +113,6 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: () -> Unit) {
chatModel.chats.firstOrNull { chat -> chat.chatInfo.id == chatId }?.chatStats?.unreadCount ?: 0
}
}
val clipboard = LocalClipboardManager.current
ChatLayout(
chat,
@@ -140,8 +144,7 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: () -> Unit) {
if (chat.chatInfo is ChatInfo.Direct) {
val contactInfo = chatModel.controller.apiContactInfo(chat.chatInfo.apiId)
val (_, code) = chatModel.controller.apiGetContactCode(chat.chatInfo.apiId)
ModalManager.end.closeModals()
ModalManager.end.showModalCloseable(true) { close ->
ModalManager.shared.showModalCloseable(true) { close ->
remember { derivedStateOf { (chatModel.getContactChat(chat.chatInfo.apiId)?.chatInfo as? ChatInfo.Direct)?.contact } }.value?.let { ct ->
ChatInfoView(chatModel, ct, contactInfo?.first, contactInfo?.second, chat.chatInfo.localAlias, code, close)
}
@@ -151,8 +154,7 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: () -> Unit) {
val link = chatModel.controller.apiGetGroupLink(chat.chatInfo.groupInfo.groupId)
var groupLink = link?.first
var groupLinkMemberRole = link?.second
ModalManager.end.closeModals()
ModalManager.end.showModalCloseable(true) { close ->
ModalManager.shared.showModalCloseable(true) { close ->
GroupChatInfoView(chatModel, groupLink, groupLinkMemberRole, {
groupLink = it.first;
groupLinkMemberRole = it.second
@@ -177,8 +179,7 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: () -> Unit) {
member to null
}
setGroupMembers(groupInfo, chatModel)
ModalManager.end.closeModals()
ModalManager.end.showModalCloseable(true) { close ->
ModalManager.shared.showModalCloseable(true) { close ->
remember { derivedStateOf { chatModel.groupMembers.firstOrNull { it.memberId == member.memberId } } }.value?.let { mem ->
GroupMemberInfoView(groupInfo, mem, stats, code, chatModel, close, close)
}
@@ -237,10 +238,7 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: () -> Unit) {
joinGroup = { groupId ->
withApi { chatModel.controller.apiJoinGroup(groupId) }
},
startCall = out@ { media ->
if (appPlatform.isDesktop) {
return@out showInDevelopingAlert()
}
startCall = { media ->
val cInfo = chat.chatInfo
if (cInfo is ChatInfo.Direct) {
chatModel.activeCall.value = Call(contact = cInfo.contact, callState = CallState.WaitCapabilities, localMedia = media)
@@ -321,14 +319,10 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: () -> Unit) {
withApi {
val ciInfo = chatModel.controller.apiGetChatItemInfo(cInfo.chatType, cInfo.apiId, cItem.id)
if (ciInfo != null) {
if (chat.chatInfo is ChatInfo.Group) {
setGroupMembers(chat.chatInfo.groupInfo, chatModel)
}
ModalManager.end.closeModals()
ModalManager.end.showModal(endButtons = { ShareButton {
clipboard.shareText(itemInfoShareText(chatModel, cItem, ciInfo, chatModel.controller.appPrefs.developerTools.get()))
ModalManager.shared.showModal(endButtons = { ShareButton {
shareText(itemInfoShareText(cItem, ciInfo, chatModel.controller.appPrefs.developerTools.get()))
} }) {
ChatItemInfoView(chatModel, cItem, ciInfo, devTools = chatModel.controller.appPrefs.developerTools.get())
ChatItemInfoView(cItem, ciInfo, devTools = chatModel.controller.appPrefs.developerTools.get())
}
}
}
@@ -337,15 +331,14 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: () -> Unit) {
hideKeyboard(view)
withApi {
setGroupMembers(groupInfo, chatModel)
ModalManager.end.closeModals()
ModalManager.end.showModalCloseable(true) { close ->
AddGroupMembersView(groupInfo, false, chatModel, close)
ModalManager.shared.showModalCloseable(true) { close ->
AddGroupMembersView(groupInfo, false, chatModel, close)
}
}
},
markRead = { range, unreadCountAfter ->
chatModel.markChatItemsRead(chat.chatInfo, range, unreadCountAfter)
ntfManager.cancelNotificationsForChat(chat.id)
chatModel.controller.ntfManager.cancelNotificationsForChat(chat.id)
withBGApi {
chatModel.controller.apiChatRead(
chat.chatInfo.chatType,
@@ -474,13 +467,11 @@ fun ChatInfoToolbar(
showSearch = false
}
}
if (appPlatform.isAndroid) {
BackHandler(onBack = onBackClicked)
}
BackHandler(onBack = onBackClicked)
val barButtons = arrayListOf<@Composable RowScope.() -> Unit>()
val menuItems = arrayListOf<@Composable () -> Unit>()
menuItems.add {
ItemAction(stringResource(MR.strings.search_verb), painterResource(MR.images.ic_search), onClick = {
ItemAction(stringResource(MR.strings.search_verb).capitalize(Locale.current), painterResource(MR.images.ic_search), onClick = {
showMenu.value = false
showSearch = true
})
@@ -534,7 +525,7 @@ fun ChatInfoToolbar(
}
DefaultTopAppBar(
navigationButton = { if (appPlatform.isAndroid || showSearch) { NavigationButtonBack(onBackClicked) } },
navigationButton = { NavigationButtonBack(onBackClicked) },
title = { ChatInfoToolbarTitle(chat.chatInfo) },
onTitleClick = info,
showSearch = showSearch,
@@ -666,8 +657,8 @@ fun BoxWithConstraintsScope.ChatItemsList(
.distinctUntilChanged()
.filter { !stopListening }
.collect {
onComposed()
stopListening = true
onComposed()
stopListening = true
}
}
DisposableEffectOnGone(
@@ -1025,8 +1016,8 @@ private fun markUnreadChatAsRead(activeChat: MutableState<Chat?>, chatModel: Cha
}
sealed class ProviderMedia {
data class Image(val uri: URI, val image: ImageBitmap): ProviderMedia()
data class Video(val uri: URI, val preview: String): ProviderMedia()
data class Image(val uri: Uri, val image: Bitmap): ProviderMedia()
data class Video(val uri: Uri, val preview: String): ProviderMedia()
}
private fun providerForGallery(
@@ -1063,17 +1054,17 @@ private fun providerForGallery(
val item = item(internalIndex, initialChatId)?.second ?: return null
return when (item.content.msgContent) {
is MsgContent.MCImage -> {
val imageBitmap: ImageBitmap? = getLoadedImage(item.file)
val imageBitmap: Bitmap? = getLoadedImage(item.file)
val filePath = getLoadedFilePath(item.file)
if (imageBitmap != null && filePath != null) {
val uri = getAppFileUri(filePath.substringAfterLast(File.separator))
val uri = FileProvider.getUriForFile(SimplexApp.context, "${BuildConfig.APPLICATION_ID}.provider", File(filePath))
ProviderMedia.Image(uri, imageBitmap)
} else null
}
is MsgContent.MCVideo -> {
val filePath = getLoadedFilePath(item.file)
if (filePath != null) {
val uri = getAppFileUri(filePath.substringAfterLast(File.separator))
val uri = FileProvider.getUriForFile(SimplexApp.context, "${BuildConfig.APPLICATION_ID}.provider", File(filePath))
ProviderMedia.Video(uri, (item.content.msgContent as MsgContent.MCVideo).image)
} else null
}
@@ -1117,11 +1108,12 @@ private fun ViewConfiguration.bigTouchSlop(slop: Float = 50f) = object: ViewConf
override val touchSlop: Float get() = slop
}
@Preview/*(
@Preview(showBackground = true)
@Preview(
uiMode = Configuration.UI_MODE_NIGHT_YES,
showBackground = true,
name = "Dark Mode"
)*/
)
@Composable
fun PreviewChatLayout() {
SimpleXTheme {
@@ -1189,7 +1181,7 @@ fun PreviewChatLayout() {
}
}
@Preview
@Preview(showBackground = true)
@Composable
fun PreviewGroupChatLayout() {
SimpleXTheme {

View File

@@ -1,6 +1,3 @@
package chat.simplex.common.views.chat
import androidx.compose.desktop.ui.tooling.preview.Preview
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
@@ -10,8 +7,10 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
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 chat.simplex.common.ui.theme.*
import chat.simplex.app.R
import chat.simplex.app.ui.theme.*
import chat.simplex.res.MR
@Composable

View File

@@ -1,4 +1,4 @@
package chat.simplex.common.views.chat
package chat.simplex.app.views.chat
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
@@ -10,13 +10,15 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
import androidx.compose.ui.unit.dp
import chat.simplex.common.platform.base64ToBitmap
import chat.simplex.app.R
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.helpers.UploadContent
import chat.simplex.app.views.helpers.base64ToBitmap
import chat.simplex.res.MR
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.helpers.UploadContent
@Composable
fun ComposeImageView(media: ComposePreview.MediaPreview, cancelImages: () -> Unit, cancelEnabled: Boolean) {
@@ -36,7 +38,7 @@ fun ComposeImageView(media: ComposePreview.MediaPreview, cancelImages: () -> Uni
val content = media.content[index]
if (content is UploadContent.Video) {
Box(contentAlignment = Alignment.Center) {
val imageBitmap = base64ToBitmap(item)
val imageBitmap = base64ToBitmap(item).asImageBitmap()
Image(
imageBitmap,
"preview video",
@@ -51,7 +53,7 @@ fun ComposeImageView(media: ComposePreview.MediaPreview, cancelImages: () -> Uni
)
}
} else {
val imageBitmap = base64ToBitmap(item)
val imageBitmap = base64ToBitmap(item).asImageBitmap()
Image(
imageBitmap,
"preview image",

View File

@@ -1,6 +1,22 @@
@file:UseSerializers(UriSerializer::class)
package chat.simplex.common.views.chat
package chat.simplex.app.views.chat
import ComposeFileView
import ComposeVoiceView
import android.Manifest
import android.app.Activity
import android.content.*
import android.content.pm.PackageManager
import android.graphics.*
import android.graphics.drawable.AnimatedImageDrawable
import android.net.Uri
import android.os.Build
import android.provider.MediaStore
import android.webkit.MimeTypeMap
import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContract
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.*
@@ -10,20 +26,21 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
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 chat.simplex.common.model.*
import chat.simplex.common.platform.*
import chat.simplex.common.views.chat.item.*
import chat.simplex.common.views.helpers.*
import androidx.core.content.ContextCompat
import chat.simplex.app.*
import chat.simplex.app.R
import chat.simplex.app.model.*
import chat.simplex.app.views.chat.item.*
import chat.simplex.app.views.helpers.*
import chat.simplex.res.MR
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.serialization.*
import java.io.File
import java.net.URI
import java.nio.file.Files
@Serializable
@@ -32,7 +49,7 @@ sealed class ComposePreview {
@Serializable class CLinkPreview(val linkPreview: LinkPreview?): ComposePreview()
@Serializable class MediaPreview(val images: List<String>, val content: List<UploadContent>): ComposePreview()
@Serializable data class VoicePreview(val voice: String, val durationMs: Int, val finished: Boolean): ComposePreview()
@Serializable class FilePreview(val fileName: String, val uri: URI): ComposePreview()
@Serializable class FilePreview(val fileName: String, val uri: Uri): ComposePreview()
}
@Serializable
@@ -147,14 +164,6 @@ fun chatItemPreview(chatItem: ChatItem): ComposePreview {
}
}
@Composable
expect fun AttachmentSelection(
composeState: MutableState<ComposeState>,
attachmentOption: MutableState<AttachmentOption?>,
processPickedFile: (URI?, String?) -> Unit,
processPickedMedia: (List<URI>, String?) -> Unit
)
@Composable
fun ComposeView(
chatModel: ChatModel,
@@ -163,6 +172,7 @@ fun ComposeView(
attachmentOption: MutableState<AttachmentOption?>,
showChooseAttachment: () -> Unit
) {
val context = LocalContext.current
val linkUrl = rememberSaveable { mutableStateOf<String?>(null) }
val prevLinkUrl = rememberSaveable { mutableStateOf<String?>(null) }
val pendingLinkUrl = rememberSaveable { mutableStateOf<String?>(null) }
@@ -170,18 +180,38 @@ fun ComposeView(
val useLinkPreviews = chatModel.controller.appPrefs.privacyLinkPreviews.get()
val maxFileSize = getMaxFileSize(FileProtocol.XFTP)
val smallFont = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground)
val textStyle = remember(MaterialTheme.colors.isLight) { mutableStateOf(smallFont) }
val processPickedMedia = { uris: List<URI>, text: String? ->
val textStyle = remember { mutableStateOf(smallFont) }
val cameraLauncher = rememberCameraLauncher { uri: Uri? ->
if (uri != null) {
val bitmap: Bitmap? = getBitmapFromUri(uri)
if (bitmap != null) {
val imagePreview = resizeImageToStrSize(bitmap, maxDataSize = 14000)
composeState.value = composeState.value.copy(preview = ComposePreview.MediaPreview(listOf(imagePreview), listOf(UploadContent.SimpleImage(uri))))
}
}
}
val cameraPermissionLauncher = rememberPermissionLauncher { isGranted: Boolean ->
if (isGranted) {
cameraLauncher.launchWithFallback()
} else {
Toast.makeText(context, generalGetString(MR.strings.toast_permission_denied), Toast.LENGTH_SHORT).show()
}
}
val processPickedMedia = { uris: List<Uri>, text: String? ->
val content = ArrayList<UploadContent>()
val imagesPreview = ArrayList<String>()
uris.forEach { uri ->
var bitmap: ImageBitmap?
var bitmap: Bitmap? = null
val isImage = MimeTypeMap.getSingleton().getMimeTypeFromExtension(getFileName(uri)?.split(".")?.last())?.contains("image/") == true
when {
isImage(uri) -> {
isImage -> {
// Image
val drawable = getDrawableFromUri(uri)
bitmap = getBitmapFromUri(uri)
if (isAnimImage(uri, drawable)) {
bitmap = if (drawable != null) getBitmapFromUri(uri) else null
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)
if (isAnimNewApi || isAnimOldApi) {
// It's a gif or webp
val fileSize = getFileSize(uri)
if (fileSize != null && fileSize <= maxFileSize) {
@@ -213,7 +243,7 @@ fun ComposeView(
composeState.value = composeState.value.copy(message = text ?: composeState.value.message, preview = ComposePreview.MediaPreview(imagesPreview, content))
}
}
val processPickedFile = { uri: URI?, text: String? ->
val processPickedFile = { uri: Uri?, text: String? ->
if (uri != null) {
val fileSize = getFileSize(uri)
if (fileSize != null && fileSize <= maxFileSize) {
@@ -229,9 +259,50 @@ fun ComposeView(
}
}
}
val galleryImageLauncher = rememberLauncherForActivityResult(contract = PickMultipleImagesFromGallery()) { processPickedMedia(it, null) }
val galleryImageLauncherFallback = rememberGetMultipleContentsLauncher { processPickedMedia(it, null) }
val galleryVideoLauncher = rememberLauncherForActivityResult(contract = PickMultipleVideosFromGallery()) { processPickedMedia(it, null) }
val galleryVideoLauncherFallback = rememberGetMultipleContentsLauncher { processPickedMedia(it, null) }
val filesLauncher = rememberGetContentLauncher { processPickedFile(it, null) }
val recState: MutableState<RecordingState> = remember { mutableStateOf(RecordingState.NotStarted) }
AttachmentSelection(composeState, attachmentOption, processPickedFile, processPickedMedia)
LaunchedEffect(attachmentOption.value) {
when (attachmentOption.value) {
AttachmentOption.CameraPhoto -> {
when (PackageManager.PERMISSION_GRANTED) {
ContextCompat.checkSelfPermission(context, 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 -> {}
}
}
fun isSimplexLink(link: String): Boolean =
link.startsWith("https://simplex.chat", true) || link.startsWith("http://simplex.chat", true)
@@ -407,7 +478,7 @@ fun ComposeView(
is ComposePreview.VoicePreview -> {
val tmpFile = File(preview.voice)
AudioPlayer.stop(tmpFile.absolutePath)
val actualFile = File(getAppFilePath(tmpFile.name.replaceAfter(RecorderInterface.extension, "")))
val actualFile = File(getAppFilePath(tmpFile.name.replaceAfter(RecorderNative.extension, "")))
withContext(Dispatchers.IO) {
Files.move(tmpFile.toPath(), actualFile.toPath())
}
@@ -439,7 +510,7 @@ fun ComposeView(
(cs.preview is ComposePreview.MediaPreview ||
cs.preview is ComposePreview.FilePreview ||
cs.preview is ComposePreview.VoicePreview)
) {
) {
sent = send(cInfo, MsgContent.MCText(msgText), quotedItemId, null, live, ttl)
}
}
@@ -497,7 +568,7 @@ fun ComposeView(
recState.value = RecordingState.NotStarted
composeState.value = composeState.value.copy(preview = ComposePreview.NoPreview)
withBGApi {
RecorderInterface.stopRecording?.invoke()
RecorderNative.stopRecording?.invoke()
AudioPlayer.stop(filePath)
filePath?.let { File(it).delete() }
}
@@ -655,11 +726,7 @@ fun ComposeView(
} else {
showChooseAttachment
}
IconButton(
attachmentClicked,
Modifier.padding(bottom = if (appPlatform.isAndroid) 0.dp else 7.dp),
enabled = !composeState.value.attachmentDisabled && rememberUpdatedState(chat.userCanSend).value
) {
IconButton(attachmentClicked, enabled = !composeState.value.attachmentDisabled && rememberUpdatedState(chat.userCanSend).value) {
Icon(
painterResource(MR.images.ic_attach_file_filled_500),
contentDescription = stringResource(MR.strings.attach),
@@ -708,26 +775,32 @@ fun ComposeView(
}
}
DisposableEffectOnGone {
val cs = composeState.value
if (cs.liveMessage != null && (cs.message.isNotEmpty() || cs.liveMessage.sent)) {
sendMessage(null)
resetLinkPreview()
clearCurrentDraft()
deleteUnusedFiles()
} else if (composeState.value.inProgress) {
clearCurrentDraft()
} else if (!composeState.value.empty) {
if (cs.preview is ComposePreview.VoicePreview && !cs.preview.finished) {
composeState.value = cs.copy(preview = cs.preview.copy(finished = true))
val activity = LocalContext.current as Activity
DisposableEffect(Unit) {
val orientation = activity.resources.configuration.orientation
onDispose {
if (orientation == activity.resources.configuration.orientation) {
val cs = composeState.value
if (cs.liveMessage != null && (cs.message.isNotEmpty() || cs.liveMessage.sent)) {
sendMessage(null)
resetLinkPreview()
clearCurrentDraft()
deleteUnusedFiles()
} else if (composeState.value.inProgress) {
clearCurrentDraft()
} else if (!composeState.value.empty) {
if (cs.preview is ComposePreview.VoicePreview && !cs.preview.finished) {
composeState.value = cs.copy(preview = cs.preview.copy(finished = true))
}
chatModel.draft.value = composeState.value
chatModel.draftChatId.value = chat.id
} else {
clearCurrentDraft()
deleteUnusedFiles()
}
chatModel.removeLiveDummy()
}
chatModel.draft.value = composeState.value
chatModel.draftChatId.value = chat.id
} else {
clearCurrentDraft()
deleteUnusedFiles()
}
chatModel.removeLiveDummy()
}
val timedMessageAllowed = remember(chat.chatInfo) { chat.chatInfo.featureEnabled(ChatFeature.TimedMessages) }
@@ -760,3 +833,65 @@ fun ComposeView(
}
}
}
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()
}

View File

@@ -1,7 +1,4 @@
package chat.simplex.common.views.chat
import androidx.compose.animation.core.Animatable
import androidx.compose.desktop.ui.tooling.preview.Preview
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
@@ -15,12 +12,13 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalDensity
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.compose.ui.unit.sp
import chat.simplex.common.model.durationText
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.helpers.*
import chat.simplex.common.platform.AudioPlayer
import chat.simplex.app.R
import chat.simplex.app.model.durationText
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.helpers.*
import chat.simplex.res.MR
import kotlinx.coroutines.flow.distinctUntilChanged

View File

@@ -1,4 +1,4 @@
package chat.simplex.common.views.chat
package chat.simplex.app.views.chat
import InfoRow
import SectionBottomSpacer
@@ -15,10 +15,11 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import dev.icerock.moko.resources.compose.stringResource
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.helpers.*
import chat.simplex.common.views.usersettings.PreferenceToggle
import chat.simplex.common.model.*
import chat.simplex.app.R
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.helpers.*
import chat.simplex.app.views.usersettings.PreferenceToggle
import chat.simplex.res.MR
@Composable

View File

@@ -1,4 +1,4 @@
package chat.simplex.common.views.chat
package chat.simplex.app.views.chat
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
@@ -10,11 +10,12 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
import androidx.compose.desktop.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.chat.item.*
import chat.simplex.common.model.*
import chat.simplex.app.R
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.chat.item.*
import chat.simplex.res.MR
import kotlinx.datetime.Clock

View File

@@ -1,20 +1,30 @@
package chat.simplex.common.views.chat
package chat.simplex.app.views.chat
import android.Manifest
import androidx.compose.foundation.layout.*
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier
import chat.simplex.common.ui.theme.DEFAULT_PADDING
import chat.simplex.common.views.helpers.*
import chat.simplex.common.views.newchat.QRCodeScanner
import chat.simplex.res.MR
import dev.icerock.moko.resources.compose.stringResource
import chat.simplex.app.R
import chat.simplex.app.ui.theme.DEFAULT_PADDING
import chat.simplex.app.views.helpers.*
import chat.simplex.app.views.newchat.QRCodeScanner
import com.google.accompanist.permissions.rememberPermissionState
import chat.simplex.res.MR
@Composable
expect fun ScanCodeView(verifyCode: (String?, cb: (Boolean) -> Unit) -> Unit, close: () -> Unit)
fun ScanCodeView(verifyCode: (String?, cb: (Boolean) -> Unit) -> Unit, close: () -> Unit) {
val cameraPermissionState = rememberPermissionState(permission = Manifest.permission.CAMERA)
LaunchedEffect(Unit) {
cameraPermissionState.launchPermissionRequest()
}
ScanCodeLayout(verifyCode, close)
}
@Composable
fun ScanCodeLayout(verifyCode: (String?, cb: (Boolean) -> Unit) -> Unit, close: () -> Unit) {
private fun ScanCodeLayout(verifyCode: (String?, cb: (Boolean) -> Unit) -> Unit, close: () -> Unit) {
Column(
Modifier
.fillMaxSize()

View File

@@ -1,7 +1,20 @@
package chat.simplex.common.views.chat
package chat.simplex.app.views.chat
import android.Manifest
import android.annotation.SuppressLint
import android.app.Activity
import android.content.Context
import android.content.pm.ActivityInfo
import android.content.res.Configuration
import android.os.Build
import android.text.InputType
import android.util.Log
import android.view.ViewGroup
import android.view.WindowManager
import android.view.inputmethod.*
import android.widget.EditText
import android.widget.TextView
import androidx.compose.animation.core.*
import androidx.compose.desktop.ui.tooling.preview.Preview
import androidx.compose.foundation.*
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
@@ -15,20 +28,32 @@ import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.*
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.platform.*
import androidx.compose.ui.res.*
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.*
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.*
import chat.simplex.common.model.*
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.chat.item.ItemAction
import chat.simplex.common.views.helpers.*
import chat.simplex.common.model.ChatItem
import chat.simplex.common.platform.*
import chat.simplex.common.views.usersettings.showInDevelopingAlert
import androidx.compose.ui.viewinterop.AndroidView
import androidx.compose.ui.window.Dialog
import androidx.core.graphics.drawable.DrawableCompat
import androidx.core.view.inputmethod.EditorInfoCompat
import androidx.core.view.inputmethod.InputConnectionCompat
import androidx.core.widget.*
import chat.simplex.app.R
import chat.simplex.app.SimplexApp
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.chat.item.ItemAction
import chat.simplex.app.views.helpers.*
import com.google.accompanist.permissions.rememberMultiplePermissionsState
import chat.simplex.res.MR
import dev.icerock.moko.resources.compose.stringResource
import dev.icerock.moko.resources.StringResource
import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
import kotlinx.coroutines.*
import java.lang.reflect.Field
@Composable
fun SendMsgView(
@@ -67,9 +92,7 @@ fun SendMsgView(
val showVoiceButton = cs.message.isEmpty() && showVoiceRecordIcon && !composeState.value.editing &&
cs.liveMessage == null && (cs.preview is ComposePreview.NoPreview || recState.value is RecordingState.Started)
val showDeleteTextButton = rememberSaveable { mutableStateOf(false) }
PlatformTextField(composeState, textStyle, showDeleteTextButton, userIsObserver, onMessageChange) {
sendMessage(null)
}
NativeKeyboard(composeState, textStyle, showDeleteTextButton, userIsObserver, onMessageChange)
// Disable clicks on text field
if (cs.preview is ComposePreview.VoicePreview || !userCanSend || cs.inProgress) {
Box(
@@ -86,9 +109,10 @@ fun SendMsgView(
if (showDeleteTextButton.value) {
DeleteTextButton(composeState)
}
Box(Modifier.align(Alignment.BottomEnd).padding(bottom = if (appPlatform.isAndroid) 0.dp else 5.dp)) {
Box(Modifier.align(Alignment.BottomEnd)) {
val sendButtonSize = remember { Animatable(36f) }
val sendButtonAlpha = remember { Animatable(1f) }
val permissionsState = rememberMultiplePermissionsState(listOf(Manifest.permission.RECORD_AUDIO))
val scope = rememberCoroutineScope()
LaunchedEffect(Unit) {
// Making LiveMessage alive when screen orientation was changed
@@ -111,8 +135,8 @@ fun SendMsgView(
}
}
}
!allowedToRecordVoiceByPlatform() ->
VoiceButtonWithoutPermissionByPlatform()
!permissionsState.allPermissionsGranted ->
VoiceButtonWithoutPermission { permissionsState.launchMultiplePermissionRequest() }
else ->
RecordVoiceView(recState, stopRecOnNextClick)
}
@@ -196,12 +220,6 @@ fun SendMsgView(
}
}
@Composable
expect fun allowedToRecordVoiceByPlatform(): Boolean
@Composable
expect fun VoiceButtonWithoutPermissionByPlatform()
@Composable
private fun CustomDisappearingMessageDialog(
sendMessage: (Int?) -> Unit,
@@ -240,7 +258,7 @@ private fun CustomDisappearingMessageDialog(
}
}
DefaultDialog(onDismissRequest = { setShowDialog(false) }) {
Dialog(onDismissRequest = { setShowDialog(false) }) {
Surface(
shape = RoundedCornerShape(corner = CornerSize(25.dp))
) {
@@ -272,6 +290,7 @@ private fun CustomDisappearingMessageDialog(
.clickable { setShowDialog(false) }
)
}
ChoiceButton(generalGetString(MR.strings.send_disappearing_message_30_seconds)) {
sendMessage(30)
setShowDialog(false)
@@ -294,6 +313,124 @@ private fun CustomDisappearingMessageDialog(
}
}
@Composable
private fun NativeKeyboard(
composeState: MutableState<ComposeState>,
textStyle: MutableState<TextStyle>,
showDeleteTextButton: MutableState<Boolean>,
userIsObserver: Boolean,
onMessageChange: (String) -> 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: android.view.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
}
SimplexApp.context.chatModel.sharedContent.value = SharedContent.Media("", listOf(inputContentInfo.contentUri))
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 = it.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(chat.simplex.app.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 = SimplexApp.context.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)
)
}
@Composable
private fun BoxScope.DeleteTextButton(composeState: MutableState<ComposeState>) {
IconButton(
@@ -306,7 +443,7 @@ private fun BoxScope.DeleteTextButton(composeState: MutableState<ComposeState>)
@Composable
private fun RecordVoiceView(recState: MutableState<RecordingState>, stopRecOnNextClick: MutableState<Boolean>) {
val rec: RecorderInterface = remember { RecorderNative() }
val rec: Recorder = remember { RecorderNative() }
DisposableEffect(Unit) { onDispose { rec.stop() } }
val stopRecordingAndAddAudio: () -> Unit = {
recState.value.filePathNullable?.let {
@@ -323,10 +460,7 @@ private fun RecordVoiceView(recState: MutableState<RecordingState>, stopRecOnNex
LockToCurrentOrientationUntilDispose()
StopRecordButton(stopRecordingAndAddAudio)
} else {
val startRecording: () -> Unit = out@ {
if (appPlatform.isDesktop) {
return@out showInDevelopingAlert()
}
val startRecording: () -> Unit = {
recState.value = RecordingState.Started(
filePath = rec.start { progress: Int?, finished: Boolean ->
val state = recState.value
@@ -371,7 +505,7 @@ private fun DisallowedVoiceButton(enabled: Boolean, onClick: () -> Unit) {
}
@Composable
fun VoiceButtonWithoutPermission(onClick: () -> Unit) {
private fun VoiceButtonWithoutPermission(onClick: () -> Unit) {
IconButton(onClick, Modifier.size(36.dp)) {
Icon(
painterResource(MR.images.ic_keyboard_voice_filled),
@@ -384,6 +518,24 @@ fun VoiceButtonWithoutPermission(onClick: () -> Unit) {
}
}
@Composable
private fun LockToCurrentOrientationUntilDispose() {
val context = LocalContext.current
DisposableEffect(Unit) {
val activity = context as Activity
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) {
android.view.Surface.ROTATION_90 -> ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
android.view.Surface.ROTATION_180 -> ActivityInfo.SCREEN_ORIENTATION_REVERSE_PORTRAIT
android.view.Surface.ROTATION_270 -> ActivityInfo.SCREEN_ORIENTATION_REVERSE_LANDSCAPE
else -> ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
}
// Unlock orientation
onDispose { activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED }
}
}
@Composable
private fun StopRecordButton(onClick: () -> Unit) {
IconButton(onClick, Modifier.size(36.dp)) {
@@ -452,8 +604,7 @@ private fun SendMsgButton(
role = Role.Button,
interactionSource = interactionSource,
indication = rememberRipple(bounded = false, radius = 24.dp)
)
.onRightClick { onLongClick?.invoke() },
),
contentAlignment = Alignment.Center
) {
Icon(
@@ -570,11 +721,12 @@ private fun showDisabledVoiceAlert(isDirectChat: Boolean) {
)
}
@Preview/*(
@Preview(showBackground = true)
@Preview(
uiMode = Configuration.UI_MODE_NIGHT_YES,
showBackground = true,
name = "Dark Mode"
)*/
)
@Composable
fun PreviewSendMsgView() {
val smallFont = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground)
@@ -599,11 +751,12 @@ fun PreviewSendMsgView() {
}
}
@Preview/*(
@Preview(showBackground = true)
@Preview(
uiMode = Configuration.UI_MODE_NIGHT_YES,
showBackground = true,
name = "Dark Mode"
)*/
)
@Composable
fun PreviewSendMsgViewEditing() {
val smallFont = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground)
@@ -629,11 +782,12 @@ fun PreviewSendMsgViewEditing() {
}
}
@Preview/*(
@Preview(showBackground = true)
@Preview(
uiMode = Configuration.UI_MODE_NIGHT_YES,
showBackground = true,
name = "Dark Mode"
)*/
)
@Composable
fun PreviewSendMsgViewInProgress() {
val smallFont = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground)

View File

@@ -1,4 +1,4 @@
package chat.simplex.common.views.chat
package chat.simplex.app.views.chat
import SectionBottomSpacer
import SectionView
@@ -10,17 +10,15 @@ import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalClipboardManager
import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.common.platform.appPlatform
import chat.simplex.common.platform.shareText
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.helpers.*
import chat.simplex.common.views.newchat.QRCode
import chat.simplex.app.R
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.helpers.*
import chat.simplex.app.views.newchat.QRCode
import chat.simplex.res.MR
@Composable
@@ -89,8 +87,7 @@ private fun VerifyCodeLayout(
)
}
Box(Modifier.weight(1f)) {
val clipboard = LocalClipboardManager.current
IconButton({ clipboard.shareText(connectionCode) }, Modifier.size(20.dp).align(Alignment.CenterStart)) {
IconButton({ shareText(connectionCode) }, Modifier.size(20.dp).align(Alignment.CenterStart)) {
Icon(painterResource(MR.images.ic_share_filled), null, tint = MaterialTheme.colors.primary)
}
}
@@ -111,11 +108,9 @@ private fun VerifyCodeLayout(
verifyCode(null) {}
}
} else {
if (appPlatform.isAndroid) {
SimpleButton(generalGetString(MR.strings.scan_code), painterResource(MR.images.ic_qr_code)) {
ModalManager.end.showModal {
ScanCodeView(verifyCode) { }
}
SimpleButton(generalGetString(MR.strings.scan_code), painterResource(MR.images.ic_qr_code)) {
ModalManager.shared.showModal {
ScanCodeView(verifyCode) { }
}
}
SimpleButton(generalGetString(MR.strings.mark_code_verified), painterResource(MR.images.ic_verified_user)) {

View File

@@ -1,4 +1,4 @@
package chat.simplex.common.views.chat.group
package chat.simplex.app.views.chat.group
import SectionBottomSpacer
import SectionCustomFooter
@@ -6,6 +6,7 @@ import SectionDividerSpaced
import SectionItemView
import SectionSpacer
import SectionView
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
@@ -15,22 +16,20 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.platform.LocalView
import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.desktop.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.common.model.*
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.chat.ChatInfoToolbarTitle
import chat.simplex.common.views.helpers.*
import chat.simplex.common.views.newchat.InfoAboutIncognito
import chat.simplex.common.views.usersettings.SettingsActionItem
import chat.simplex.common.model.GroupInfo
import chat.simplex.common.platform.*
import chat.simplex.common.views.chat.group.GroupPreferencesView
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.chat.ChatInfoToolbarTitle
import chat.simplex.app.views.helpers.*
import chat.simplex.app.views.newchat.InfoAboutIncognito
import chat.simplex.app.views.usersettings.SettingsActionItem
import chat.simplex.res.MR
@Composable
@@ -50,7 +49,7 @@ fun AddGroupMembersView(groupInfo: GroupInfo, creatingGroup: Boolean = false, ch
allowModifyMembers = allowModifyMembers,
searchText,
openPreferences = {
ModalManager.end.showCustomModal { close ->
ModalManager.shared.showCustomModal { close ->
GroupPreferencesView(chatModel, groupInfo.id, close)
}
},
@@ -185,7 +184,7 @@ private fun SearchRowView(
SearchTextField(Modifier.fillMaxWidth(), searchText = searchText, alwaysVisible = true) {
searchText.value = searchText.value.copy(it)
}
val view = LocalMultiplatformView()
val view = LocalView.current
LaunchedEffect(selectedContactsSize) {
searchText.value = searchText.value.copy("")
hideKeyboard(view)

View File

@@ -1,4 +1,4 @@
package chat.simplex.common.views.chat.group
package chat.simplex.app.views.chat.group
import InfoRow
import SectionBottomSpacer
@@ -7,7 +7,8 @@ import SectionItemView
import SectionSpacer
import SectionTextFooter
import SectionView
import androidx.compose.desktop.ui.tooling.preview.Preview
import android.util.Log
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
@@ -22,41 +23,29 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.common.model.*
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.helpers.*
import chat.simplex.common.views.usersettings.*
import chat.simplex.common.model.GroupInfo
import chat.simplex.common.platform.*
import chat.simplex.common.views.chat.*
import chat.simplex.common.views.chatlist.*
import chat.simplex.app.TAG
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.chat.*
import chat.simplex.app.views.chatlist.cantInviteIncognitoAlert
import chat.simplex.app.views.chatlist.setGroupMembers
import chat.simplex.app.views.helpers.*
import chat.simplex.app.views.usersettings.*
import chat.simplex.res.MR
const val SMALL_GROUPS_RCPS_MEM_LIMIT: Int = 20
@Composable
fun GroupChatInfoView(chatModel: ChatModel, groupLink: String?, groupLinkMemberRole: GroupMemberRole?, onGroupLinkUpdated: (Pair<String?, GroupMemberRole?>) -> Unit, close: () -> Unit) {
BackHandler(onBack = close)
val chat = chatModel.chats.firstOrNull { it.id == chatModel.chatId.value }
val currentUser = chatModel.currentUser.value
val developerTools = chatModel.controller.appPrefs.developerTools.get()
if (chat != null && chat.chatInfo is ChatInfo.Group && currentUser != null) {
if (chat != null && chat.chatInfo is ChatInfo.Group) {
val groupInfo = chat.chatInfo.groupInfo
val sendReceipts = remember { mutableStateOf(SendReceipts.fromBool(groupInfo.chatSettings.sendRcpts, currentUser.sendRcptsSmallGroups)) }
GroupChatInfoLayout(
chat,
groupInfo,
currentUser,
sendReceipts = sendReceipts,
setSendReceipts = { sendRcpts ->
withApi {
val chatSettings = (chat.chatInfo.chatSettings ?: ChatSettings.defaults).copy(sendRcpts = sendRcpts.bool)
updateChatSettings(chat, chatSettings, chatModel)
sendReceipts.value = sendRcpts
}
},
members = chatModel.groupMembers
.filter { it.memberStatus != GroupMemberStatus.MemLeft && it.memberStatus != GroupMemberStatus.MemRemoved }
.sortedBy { it.displayName.lowercase() },
@@ -65,7 +54,7 @@ fun GroupChatInfoView(chatModel: ChatModel, groupLink: String?, groupLinkMemberR
addMembers = {
withApi {
setGroupMembers(groupInfo, chatModel)
ModalManager.end.showModalCloseable(true) { close ->
ModalManager.shared.showModalCloseable(true) { close ->
AddGroupMembersView(groupInfo, false, chatModel, close)
}
}
@@ -84,7 +73,7 @@ fun GroupChatInfoView(chatModel: ChatModel, groupLink: String?, groupLinkMemberR
} else {
member to null
}
ModalManager.end.showModalCloseable(true) { closeCurrent ->
ModalManager.shared.showModalCloseable(true) { closeCurrent ->
remember { derivedStateOf { chatModel.groupMembers.firstOrNull { it.memberId == member.memberId } } }.value?.let { mem ->
GroupMemberInfoView(groupInfo, mem, stats, code, chatModel, closeCurrent) {
closeCurrent()
@@ -95,13 +84,13 @@ fun GroupChatInfoView(chatModel: ChatModel, groupLink: String?, groupLinkMemberR
}
},
editGroupProfile = {
ModalManager.end.showCustomModal { close -> GroupProfileView(groupInfo, chatModel, close) }
ModalManager.shared.showCustomModal { close -> GroupProfileView(groupInfo, chatModel, close) }
},
addOrEditWelcomeMessage = {
ModalManager.end.showCustomModal { close -> GroupWelcomeView(chatModel, groupInfo, close) }
ModalManager.shared.showCustomModal { close -> GroupWelcomeView(chatModel, groupInfo, close) }
},
openPreferences = {
ModalManager.end.showCustomModal { close ->
ModalManager.shared.showCustomModal { close ->
GroupPreferencesView(
chatModel,
chat.id,
@@ -113,7 +102,7 @@ fun GroupChatInfoView(chatModel: ChatModel, groupLink: String?, groupLinkMemberR
clearChat = { clearChatDialog(chat.chatInfo, chatModel, close) },
leaveGroup = { leaveGroupDialog(groupInfo, chatModel, close) },
manageGroupLink = {
ModalManager.end.showModal { GroupLinkView(chatModel, groupInfo, groupLink, groupLinkMemberRole, onGroupLinkUpdated) }
ModalManager.shared.showModal { GroupLinkView(chatModel, groupInfo, groupLink, groupLinkMemberRole, onGroupLinkUpdated) }
}
)
}
@@ -133,7 +122,7 @@ fun deleteGroupDialog(chatInfo: ChatInfo, groupInfo: GroupInfo, chatModel: ChatM
if (r) {
chatModel.removeChat(chatInfo.id)
chatModel.chatId.value = null
ntfManager.cancelNotificationsForChat(chatInfo.id)
chatModel.controller.ntfManager.cancelNotificationsForChat(chatInfo.id)
close?.invoke()
}
}
@@ -161,9 +150,6 @@ fun leaveGroupDialog(groupInfo: GroupInfo, chatModel: ChatModel, close: (() -> U
fun GroupChatInfoLayout(
chat: Chat,
groupInfo: GroupInfo,
currentUser: User,
sendReceipts: State<SendReceipts>,
setSendReceipts: (SendReceipts) -> Unit,
members: List<GroupMember>,
developerTools: Boolean,
groupLink: String?,
@@ -198,11 +184,6 @@ fun GroupChatInfoLayout(
AddOrEditWelcomeMessage(groupInfo.groupProfile.description, addOrEditWelcomeMessage)
}
GroupPreferencesButton(openPreferences)
if (members.filter { it.memberCurrent }.size <= SMALL_GROUPS_RCPS_MEM_LIMIT) {
SendReceiptsOption(currentUser, sendReceipts, setSendReceipts)
} else {
SendReceiptsOptionDisabled()
}
}
SectionTextFooter(stringResource(MR.strings.only_group_owners_can_change_prefs))
SectionDividerSpaced(maxTopPadding = true)
@@ -288,37 +269,6 @@ private fun GroupPreferencesButton(onClick: () -> Unit) {
)
}
@Composable
private fun SendReceiptsOption(currentUser: User, state: State<SendReceipts>, onSelected: (SendReceipts) -> Unit) {
val values = remember {
mutableListOf(SendReceipts.Yes, SendReceipts.No, SendReceipts.UserDefault(currentUser.sendRcptsSmallGroups)).map { it to it.text }
}
ExposedDropDownSettingRow(
generalGetString(MR.strings.send_receipts),
values,
state,
icon = painterResource(MR.images.ic_double_check),
enabled = remember { mutableStateOf(true) },
onSelected = onSelected
)
}
@Composable
fun SendReceiptsOptionDisabled() {
SettingsActionItemWithContent(
icon = painterResource(MR.images.ic_double_check),
text = generalGetString(MR.strings.send_receipts),
click = {
AlertManager.shared.showAlertMsg(
title = generalGetString(MR.strings.send_receipts_disabled_alert_title),
text = String.format(generalGetString(MR.strings.send_receipts_disabled_alert_msg), SMALL_GROUPS_RCPS_MEM_LIMIT)
)
}
) {
Text(generalGetString(MR.strings.send_receipts_disabled), color = MaterialTheme.colors.secondary)
}
}
@Composable
private fun AddMembersButton(tint: Color = MaterialTheme.colors.primary, onClick: () -> Unit) {
SettingsActionItem(
@@ -479,9 +429,6 @@ fun PreviewGroupChatInfoLayout() {
chatItems = arrayListOf()
),
groupInfo = GroupInfo.sampleData,
User.sampleData,
sendReceipts = remember { mutableStateOf(SendReceipts.Yes) },
setSendReceipts = {},
members = listOf(GroupMember.sampleData, GroupMember.sampleData, GroupMember.sampleData),
developerTools = false,
groupLink = null,

View File

@@ -1,4 +1,4 @@
package chat.simplex.common.views.chat.group
package chat.simplex.app.views.chat.group
import SectionBottomSpacer
import androidx.compose.foundation.layout.*
@@ -10,16 +10,15 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalClipboardManager
import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.common.model.*
import chat.simplex.common.platform.shareText
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.helpers.*
import chat.simplex.common.views.newchat.QRCode
import chat.simplex.app.R
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.helpers.*
import chat.simplex.app.views.newchat.QRCode
import chat.simplex.res.MR
@Composable
@@ -44,14 +43,13 @@ fun GroupLinkView(chatModel: ChatModel, groupInfo: GroupInfo, connReqContact: St
createLink()
}
}
val clipboard = LocalClipboardManager.current
GroupLinkLayout(
groupLink = groupLink,
groupInfo,
groupLinkMemberRole,
creatingLink,
createLink = ::createLink,
share = { clipboard.shareText(groupLink ?: return@GroupLinkLayout) },
share = { shareText(groupLink ?: return@GroupLinkLayout) },
updateLink = {
val role = groupLinkMemberRole.value
if (role != null) {

View File

@@ -1,4 +1,4 @@
package chat.simplex.common.views.chat.group
package chat.simplex.app.views.chat.group
import InfoRow
import SectionBottomSpacer
@@ -6,8 +6,9 @@ import SectionDividerSpaced
import SectionSpacer
import SectionTextFooter
import SectionView
import androidx.compose.desktop.ui.tooling.preview.Preview
import java.net.URI
import android.net.Uri
import android.util.Log
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.InlineTextContent
@@ -17,23 +18,23 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.*
import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.common.model.*
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.chat.*
import chat.simplex.common.views.helpers.*
import chat.simplex.common.views.newchat.*
import chat.simplex.common.views.usersettings.SettingsActionItem
import chat.simplex.common.model.GroupInfo
import chat.simplex.common.platform.*
import chat.simplex.app.*
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.chat.*
import chat.simplex.app.views.helpers.*
import chat.simplex.app.views.newchat.*
import chat.simplex.app.views.usersettings.SettingsActionItem
import chat.simplex.res.MR
import kotlinx.datetime.Clock
@@ -76,7 +77,7 @@ fun GroupMemberInfoView(
}
},
connectViaAddress = { connReqUri ->
val uri = URI(connReqUri)
val uri = Uri.parse(connReqUri)
withUriAction(uri) { linkType ->
withApi {
Log.d(TAG, "connectViaUri: connecting")
@@ -149,7 +150,7 @@ fun GroupMemberInfoView(
})
},
verifyClicked = {
ModalManager.end.showModalCloseable { close ->
ModalManager.shared.showModalCloseable { close ->
remember { derivedStateOf { chatModel.groupMembers.firstOrNull { it.memberId == member.memberId } } }.value?.let { mem ->
VerifyCodeView(
mem.displayName,
@@ -261,10 +262,10 @@ fun GroupMemberInfoLayout(
}
if (member.contactLink != null) {
val context = LocalContext.current
SectionView(stringResource(MR.strings.address_section_title).uppercase()) {
QRCode(member.contactLink, Modifier.padding(horizontal = DEFAULT_PADDING, vertical = DEFAULT_PADDING_HALF).aspectRatio(1f))
val clipboard = LocalClipboardManager.current
ShareAddressButton { clipboard.shareText(member.contactLink) }
ShareAddressButton { shareText(member.contactLink) }
if (contactId != null) {
if (knownDirectChat(contactId) == null && !groupInfo.fullGroupPreferences.directMessages.on) {
ConnectViaAddressButton(onClick = { connectViaAddress(member.contactLink) })

View File

@@ -1,4 +1,4 @@
package chat.simplex.common.views.chat.group
package chat.simplex.app.views.chat.group
import InfoRow
import SectionBottomSpacer
@@ -14,10 +14,11 @@ import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Modifier
import dev.icerock.moko.resources.compose.stringResource
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.helpers.*
import chat.simplex.common.views.usersettings.PreferenceToggleWithIcon
import chat.simplex.common.model.*
import chat.simplex.app.R
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.helpers.*
import chat.simplex.app.views.usersettings.PreferenceToggleWithIcon
import chat.simplex.res.MR
@Composable

View File

@@ -1,7 +1,8 @@
package chat.simplex.common.views.chat.group
package chat.simplex.app.views.chat.group
import SectionBottomSpacer
import androidx.compose.desktop.ui.tooling.preview.Preview
import android.content.res.Configuration
import android.net.Uri
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
@@ -14,20 +15,22 @@ import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.graphics.Color
import dev.icerock.moko.resources.compose.stringResource
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.common.model.*
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.ProfileNameField
import chat.simplex.common.views.helpers.*
import chat.simplex.common.views.isValidDisplayName
import chat.simplex.common.views.onboarding.ReadableText
import chat.simplex.common.views.usersettings.*
import chat.simplex.app.R
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.ProfileNameField
import chat.simplex.app.views.helpers.*
import chat.simplex.app.views.isValidDisplayName
import chat.simplex.app.views.onboarding.ReadableText
import chat.simplex.app.views.usersettings.*
import com.google.accompanist.insets.ProvideWindowInsets
import com.google.accompanist.insets.navigationBarsWithImePadding
import chat.simplex.res.MR
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import java.net.URI
@Composable
fun GroupProfileView(groupInfo: GroupInfo, chatModel: ChatModel, close: () -> Unit) {
@@ -55,7 +58,7 @@ fun GroupProfileLayout(
val bottomSheetModalState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden)
val displayName = rememberSaveable { mutableStateOf(groupProfile.displayName) }
val fullName = rememberSaveable { mutableStateOf(groupProfile.fullName) }
val chosenImage = rememberSaveable { mutableStateOf<URI?>(null) }
val chosenImage = rememberSaveable { mutableStateOf<Uri?>(null) }
val profileImage = rememberSaveable { mutableStateOf(groupProfile.image) }
val scope = rememberCoroutineScope()
val scrollState = rememberScrollState()
@@ -188,11 +191,12 @@ private fun showUnsavedChangesAlert(save: () -> Unit, revert: () -> Unit) {
)
}
@Preview/*(
@Preview(showBackground = true)
@Preview(
uiMode = Configuration.UI_MODE_NIGHT_YES,
showBackground = true,
name = "Dark Mode"
)*/
)
@Composable
fun PreviewGroupProfileLayout() {
SimpleXTheme {

View File

@@ -1,4 +1,4 @@
package chat.simplex.common.views.chat.group
package chat.simplex.app.views.chat.group
import SectionBottomSpacer
import SectionDividerSpaced
@@ -14,18 +14,16 @@ import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.text.AnnotatedString
import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.common.model.*
import chat.simplex.common.ui.theme.DEFAULT_PADDING
import chat.simplex.common.views.chat.item.MarkdownText
import chat.simplex.common.views.helpers.*
import chat.simplex.common.model.ChatModel
import chat.simplex.common.model.GroupInfo
import chat.simplex.app.*
import chat.simplex.app.R
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.DEFAULT_PADDING
import chat.simplex.app.views.chat.item.MarkdownText
import chat.simplex.app.views.helpers.*
import chat.simplex.res.MR
import kotlinx.coroutines.delay
@@ -101,17 +99,15 @@ private fun GroupWelcomeLayout(
},
wt.value.isEmpty()
)
val clipboard = LocalClipboardManager.current
CopyTextButton { clipboard.setText(AnnotatedString(wt.value)) }
CopyTextButton { copyText(wt.value) }
SectionDividerSpaced(maxBottomPadding = false)
SaveButton(
save = save,
disabled = wt.value == groupInfo.groupProfile.description || (wt.value == "" && groupInfo.groupProfile.description == null)
)
} else {
val clipboard = LocalClipboardManager.current
TextPreview(wt.value, linkMode)
CopyTextButton { clipboard.setText(AnnotatedString(wt.value)) }
CopyTextButton { copyText(wt.value) }
}
SectionBottomSpacer()
}

View File

@@ -1,4 +1,4 @@
package chat.simplex.common.views.chat.item
package chat.simplex.app.views.chat.item
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
@@ -10,9 +10,9 @@ import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.common.ui.theme.*
import chat.simplex.common.model.*
import chat.simplex.common.views.helpers.SimpleButton
import chat.simplex.app.R
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.*
import chat.simplex.res.MR
@Composable

View File

@@ -1,4 +1,4 @@
package chat.simplex.common.views.chat.item
package chat.simplex.app.views.chat.item
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
@@ -9,8 +9,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.common.model.ChatItem
import chat.simplex.common.model.Feature
import chat.simplex.app.model.*
@Composable
fun CIChatFeatureView(

View File

@@ -1,6 +1,6 @@
package chat.simplex.common.views.chat.item
package chat.simplex.app.views.chat.item
import androidx.compose.desktop.ui.tooling.preview.Preview
import android.content.res.Configuration
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.material.*
@@ -9,10 +9,11 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.*
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.common.model.ChatItem
import chat.simplex.common.ui.theme.*
import chat.simplex.app.model.ChatItem
import chat.simplex.app.ui.theme.*
@Composable
fun CIEventView(ci: ChatItem) {
@@ -45,10 +46,11 @@ fun chatEventText(ci: ChatItem): AnnotatedString =
withStyle(chatEventStyle) { append(ci.content.text + " " + ci.timestampText) }
}
@Preview/*(
@Preview(showBackground = true)
@Preview(
uiMode = Configuration.UI_MODE_NIGHT_YES,
name = "Dark Mode"
)*/
)
@Composable
fun CIEventViewPreview() {
SimpleXTheme {

View File

@@ -1,4 +1,4 @@
package chat.simplex.common.views.chat.item
package chat.simplex.app.views.chat.item
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
@@ -9,8 +9,9 @@ import androidx.compose.ui.text.*
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.common.views.helpers.generalGetString
import chat.simplex.common.model.*
import chat.simplex.app.R
import chat.simplex.app.model.*
import chat.simplex.app.views.helpers.generalGetString
import chat.simplex.res.MR
@Composable

View File

@@ -1,5 +1,6 @@
package chat.simplex.common.views.chat.item
package chat.simplex.app.views.chat.item
import android.widget.Toast
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CornerSize
@@ -11,18 +12,19 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
import androidx.compose.ui.tooling.preview.*
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.common.model.*
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.helpers.*
import chat.simplex.app.R
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.helpers.*
import chat.simplex.res.MR
import java.io.File
import java.net.URI
import kotlinx.datetime.Clock
@Composable
fun CIFileView(
@@ -30,6 +32,7 @@ fun CIFileView(
edited: Boolean,
receiveFile: (Long) -> Unit
) {
val context = LocalContext.current
val saveFileLauncher = rememberSaveFileLauncher(ciFile = file)
@Composable
@@ -95,11 +98,9 @@ fun CIFileView(
is CIFileStatus.RcvComplete -> {
val filePath = getLoadedFilePath(file)
if (filePath != null) {
withApi {
saveFileLauncher.launch(file.fileName)
}
saveFileLauncher.launch(file.fileName)
} else {
showToast(generalGetString(MR.strings.file_not_found))
Toast.makeText(context, generalGetString(MR.strings.file_not_found), Toast.LENGTH_SHORT).show()
}
}
else -> {}
@@ -169,6 +170,7 @@ fun CIFileView(
is CIFileStatus.RcvComplete -> fileIcon()
is CIFileStatus.RcvCancelled -> fileIcon(innerIcon = painterResource(MR.images.ic_close))
is CIFileStatus.RcvError -> fileIcon(innerIcon = painterResource(MR.images.ic_close))
is CIFileStatus.Invalid -> fileIcon(innerIcon = painterResource(MR.images.ic_question_mark))
}
} else {
fileIcon()
@@ -205,16 +207,6 @@ fun CIFileView(
}
}
@Composable
fun rememberSaveFileLauncher(ciFile: CIFile?): FileChooserLauncher =
rememberFileChooserLauncher(false) { to: URI? ->
val filePath = getLoadedFilePath(ciFile)
if (filePath != null && to != null) {
copyFileToFile(File(filePath), to) {}
}
}
/*
class ChatItemProvider: PreviewParameterProvider<ChatItem> {
private val sentFile = ChatItem(
chatDir = CIDirection.DirectSnd(),
@@ -253,4 +245,4 @@ fun PreviewCIFileFramedItemView(@PreviewParameter(ChatItemProvider::class) chatI
SimpleXTheme {
FramedItemView(ChatInfo.Direct.sampleData, chatItem, linkMode = SimplexLinkMode.DESCRIPTION, showMenu = showMenu, receiveFile = {})
}
}*/
}

View File

@@ -1,6 +1,6 @@
package chat.simplex.common.views.chat.item
package chat.simplex.app.views.chat.item
import androidx.compose.desktop.ui.tooling.preview.Preview
import android.content.res.Configuration
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
@@ -11,11 +11,13 @@ import androidx.compose.ui.Modifier
import dev.icerock.moko.resources.compose.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.*
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.helpers.*
import chat.simplex.common.model.*
import chat.simplex.app.R
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.helpers.*
import chat.simplex.res.MR
@Composable
@@ -113,10 +115,11 @@ fun CIGroupInvitationView(
}
}
@Preview/*(
@Preview(showBackground = true)
@Preview(
uiMode = Configuration.UI_MODE_NIGHT_YES,
name = "Dark Mode"
)*/
)
@Composable
fun PendingCIGroupInvitationViewPreview() {
SimpleXTheme {
@@ -129,10 +132,11 @@ fun PendingCIGroupInvitationViewPreview() {
}
}
@Preview/*(
@Preview(showBackground = true)
@Preview(
uiMode = Configuration.UI_MODE_NIGHT_YES,
name = "Dark Mode"
)*/
)
@Composable
fun CIGroupInvitationViewAcceptedPreview() {
SimpleXTheme {
@@ -145,7 +149,7 @@ fun CIGroupInvitationViewAcceptedPreview() {
}
}
@Preview
@Preview(showBackground = true)
@Composable
fun CIGroupInvitationViewLongNamePreview() {
SimpleXTheme {

View File

@@ -1,13 +1,18 @@
package chat.simplex.common.views.chat.item
package chat.simplex.app.views.chat.item
import androidx.compose.foundation.*
import android.graphics.Bitmap
import android.os.Build.VERSION.SDK_INT
import androidx.compose.foundation.Image
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.*
import androidx.compose.material.CircularProgressIndicator
import androidx.compose.material.Icon
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.*
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.painter.BitmapPainter
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.layoutId
@@ -16,14 +21,19 @@ import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import chat.simplex.common.views.helpers.*
import chat.simplex.common.model.*
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.DEFAULT_MAX_IMAGE_WIDTH
import androidx.core.content.FileProvider
import chat.simplex.app.*
import chat.simplex.app.R
import chat.simplex.app.model.*
import chat.simplex.app.views.helpers.*
import coil.ImageLoader
import coil.compose.rememberAsyncImagePainter
import coil.decode.GifDecoder
import coil.decode.ImageDecoderDecoder
import coil.request.ImageRequest
import chat.simplex.res.MR
import dev.icerock.moko.resources.StringResource
import java.io.File
import java.net.URI
@Composable
fun CIImageView(
@@ -76,6 +86,7 @@ fun CIImageView(
is CIFileStatus.RcvTransfer -> progressIndicator()
is CIFileStatus.RcvCancelled -> fileIcon(painterResource(MR.images.ic_close), MR.strings.icon_descr_file)
is CIFileStatus.RcvError -> fileIcon(painterResource(MR.images.ic_close), MR.strings.icon_descr_file)
is CIFileStatus.Invalid -> fileIcon(painterResource(MR.images.ic_question_mark), MR.strings.icon_descr_file)
else -> {}
}
}
@@ -85,41 +96,39 @@ fun CIImageView(
@Composable
fun imageViewFullWidth(): Dp {
val approximatePadding = 100.dp
return with(LocalDensity.current) { minOf(DEFAULT_MAX_IMAGE_WIDTH, LocalWindowWidth() - approximatePadding) }
return with(LocalDensity.current) { minOf(1000.dp, LocalView.current.width.toDp() - approximatePadding) }
}
@Composable
fun imageView(imageBitmap: ImageBitmap, onClick: () -> Unit) {
fun imageView(imageBitmap: Bitmap, onClick: () -> Unit) {
Image(
imageBitmap,
imageBitmap.asImageBitmap(),
contentDescription = stringResource(MR.strings.image_descr),
// .width(DEFAULT_MAX_IMAGE_WIDTH) is a hack for image to increase IntrinsicSize of FramedItemView
// .width(1000.dp) is a hack for image to increase IntrinsicSize of FramedItemView
// if text is short and take all available width if text is long
modifier = Modifier
.width(if (imageBitmap.width * 0.97 <= imageBitmap.height) imageViewFullWidth() * 0.75f else DEFAULT_MAX_IMAGE_WIDTH)
.width(if (imageBitmap.width * 0.97 <= imageBitmap.height) imageViewFullWidth() * 0.75f else 1000.dp)
.combinedClickable(
onLongClick = { showMenu.value = true },
onClick = onClick
)
.onRightClick { showMenu.value = true },
),
contentScale = ContentScale.FillWidth,
)
}
@Composable
fun ImageView(painter: Painter, onClick: () -> Unit) {
fun imageView(painter: Painter, onClick: () -> Unit) {
Image(
painter,
contentDescription = stringResource(MR.strings.image_descr),
// .width(DEFAULT_MAX_IMAGE_WIDTH) is a hack for image to increase IntrinsicSize of FramedItemView
// .width(1000.dp) is a hack for image to increase IntrinsicSize of FramedItemView
// if text is short and take all available width if text is long
modifier = Modifier
.width(if (painter.intrinsicSize.width * 0.97 <= painter.intrinsicSize.height) imageViewFullWidth() * 0.75f else DEFAULT_MAX_IMAGE_WIDTH)
.width(if (painter.intrinsicSize.width * 0.97 <= painter.intrinsicSize.height) imageViewFullWidth() * 0.75f else 1000.dp)
.combinedClickable(
onLongClick = { showMenu.value = true },
onClick = onClick
)
.onRightClick { showMenu.value = true },
),
contentScale = ContentScale.FillWidth,
)
}
@@ -131,8 +140,8 @@ fun CIImageView(
return false
}
fun imageAndFilePath(file: CIFile?): Pair<ImageBitmap?, String?> {
val imageBitmap: ImageBitmap? = getLoadedImage(file)
fun imageAndFilePath(file: CIFile?): Pair<Bitmap?, String?> {
val imageBitmap: Bitmap? = getLoadedImage(file)
val filePath = getLoadedFilePath(file)
return imageBitmap to filePath
}
@@ -141,10 +150,24 @@ fun CIImageView(
Modifier.layoutId(CHAT_IMAGE_LAYOUT_ID),
contentAlignment = Alignment.TopEnd
) {
val context = LocalContext.current
val (imageBitmap, filePath) = remember(file) { imageAndFilePath(file) }
if (imageBitmap != null && filePath != null) {
val uri = remember(filePath) { getAppFileUri(filePath.substringAfterLast(File.separator)) }
SimpleAndAnimatedImageView(uri, imageBitmap, file, imageProvider, @Composable { painter, onClick -> ImageView(painter, onClick) })
val uri = remember(filePath) { FileProvider.getUriForFile(context, "${BuildConfig.APPLICATION_ID}.provider", File(filePath)) }
val imagePainter = rememberAsyncImagePainter(
ImageRequest.Builder(context).data(data = uri).size(coil.size.Size.ORIGINAL).build(),
placeholder = BitmapPainter(imageBitmap.asImageBitmap()), // show original image while it's still loading by coil
imageLoader = imageLoader
)
val view = LocalView.current
imageView(imagePainter, onClick = {
hideKeyboard(view)
if (getLoadedFilePath(file) != null) {
ModalManager.shared.showCustomModal(animated = false) { close ->
ImageFullScreenView(imageProvider, close)
}
}
})
} else {
imageView(base64ToBitmap(image), onClick = {
if (file != null) {
@@ -183,11 +206,12 @@ fun CIImageView(
}
}
@Composable
expect fun SimpleAndAnimatedImageView(
uri: URI,
imageBitmap: ImageBitmap,
file: CIFile?,
imageProvider: () -> ImageGalleryProvider,
ImageView: @Composable (painter: Painter, onClick: () -> Unit) -> Unit
)
private val imageLoader = ImageLoader.Builder(SimplexApp.context)
.components {
if (SDK_INT >= 28) {
add(ImageDecoderDecoder.Factory())
} else {
add(GifDecoder.Factory())
}
}
.build()

View File

@@ -1,4 +1,4 @@
package chat.simplex.common.views.chat.item
package chat.simplex.app.views.chat.item
import SectionView
import androidx.compose.foundation.*
@@ -7,25 +7,20 @@ import androidx.compose.material.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalClipboardManager
import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.unit.dp
import chat.simplex.common.platform.shareText
import chat.simplex.common.ui.theme.DEFAULT_PADDING
import chat.simplex.common.views.helpers.*
import chat.simplex.common.views.usersettings.SettingsActionItem
import chat.simplex.app.R
import chat.simplex.app.ui.theme.DEFAULT_PADDING
import chat.simplex.app.views.helpers.*
import chat.simplex.app.views.usersettings.SettingsActionItem
import chat.simplex.res.MR
@Composable
fun CIInvalidJSONView(json: String) {
Row(Modifier
.clickable {
ModalManager.center.closeModals()
ModalManager.end.closeModals()
ModalManager.center.showModal(true) { InvalidJSONView(json) }
}
.clickable { ModalManager.shared.showModal(true) { InvalidJSONView(json) } }
.padding(horizontal = 10.dp, vertical = 6.dp)
) {
Text(stringResource(MR.strings.invalid_data), color = Color.Red, fontStyle = FontStyle.Italic)
@@ -37,9 +32,8 @@ fun InvalidJSONView(json: String) {
Column {
Spacer(Modifier.height(DEFAULT_PADDING))
SectionView {
val clipboard = LocalClipboardManager.current
SettingsActionItem(painterResource(MR.images.ic_share), generalGetString(MR.strings.share_verb), click = {
clipboard.shareText(json)
shareText(json)
})
}
Column(Modifier.padding(DEFAULT_PADDING).fillMaxWidth().verticalScroll(rememberScrollState())) {

View File

@@ -1,4 +1,4 @@
package chat.simplex.common.views.chat.item
package chat.simplex.app.views.chat.item
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
@@ -9,32 +9,15 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.desktop.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.common.ui.theme.CurrentColors
import chat.simplex.common.model.*
import chat.simplex.common.ui.theme.isInDarkTheme
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.*
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.CurrentColors
import chat.simplex.res.MR
import kotlinx.datetime.Clock
@Composable
fun CIMetaView(
chatItem: ChatItem,
timedMessagesTTL: Int?,
metaColor: Color = MaterialTheme.colors.secondary,
paleMetaColor: Color = if (isInDarkTheme()) {
metaColor.copy(
red = metaColor.red * 0.67F,
green = metaColor.green * 0.67F,
blue = metaColor.red * 0.67F)
} else {
metaColor.copy(
red = minOf(metaColor.red * 1.33F, 1F),
green = minOf(metaColor.green * 1.33F, 1F),
blue = minOf(metaColor.red * 1.33F, 1F))
}
) {
fun CIMetaView(chatItem: ChatItem, timedMessagesTTL: Int?, metaColor: Color = MaterialTheme.colors.secondary) {
Row(Modifier.padding(start = 3.dp), verticalAlignment = Alignment.CenterVertically) {
if (chatItem.isDeletedContent) {
Text(
@@ -44,14 +27,14 @@ fun CIMetaView(
modifier = Modifier.padding(start = 3.dp)
)
} else {
CIMetaText(chatItem.meta, timedMessagesTTL, metaColor, paleMetaColor)
CIMetaText(chatItem.meta, timedMessagesTTL, metaColor)
}
}
}
@Composable
// changing this function requires updating reserveSpaceForMeta
private fun CIMetaText(meta: CIMeta, chatTTL: Int?, color: Color, paleColor: Color) {
private fun CIMetaText(meta: CIMeta, chatTTL: Int?, color: Color) {
if (meta.itemEdited) {
StatusIconText(painterResource(MR.images.ic_edit), color)
Spacer(Modifier.width(3.dp))
@@ -64,7 +47,7 @@ private fun CIMetaText(meta: CIMeta, chatTTL: Int?, color: Color, paleColor: Col
}
Spacer(Modifier.width(4.dp))
}
val statusIcon = meta.statusIcon(MaterialTheme.colors.primary, color, paleColor)
val statusIcon = meta.statusIcon(MaterialTheme.colors.primary, color)
if (statusIcon != null) {
val (icon, statusColor) = statusIcon
if (meta.itemStatus is CIStatus.SndSent || meta.itemStatus is CIStatus.SndRcvd) {
@@ -154,7 +137,7 @@ fun PreviewCIMetaViewSendNoAuth() {
fun PreviewCIMetaViewSendSent() {
CIMetaView(
chatItem = ChatItem.getSampleData(
1, CIDirection.DirectSnd(), Clock.System.now(), "hello", status = CIStatus.SndSent(SndCIStatusProgress.Complete)
1, CIDirection.DirectSnd(), Clock.System.now(), "hello", status = CIStatus.SndSent()
),
null
)
@@ -179,7 +162,7 @@ fun PreviewCIMetaViewEditedUnread() {
chatItem = ChatItem.getSampleData(
1, CIDirection.DirectRcv(), Clock.System.now(), "hello",
itemEdited = true,
status= CIStatus.RcvNew()
status=CIStatus.RcvNew()
),
null
)
@@ -192,7 +175,7 @@ fun PreviewCIMetaViewEditedSent() {
chatItem = ChatItem.getSampleData(
1, CIDirection.DirectSnd(), Clock.System.now(), "hello",
itemEdited = true,
status= CIStatus.SndSent(SndCIStatusProgress.Complete)
status=CIStatus.SndSent()
),
null
)

View File

@@ -1,4 +1,4 @@
package chat.simplex.common.views.chat.item
package chat.simplex.app.views.chat.item
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
@@ -10,15 +10,14 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.*
import androidx.compose.ui.text.font.FontStyle
import dev.icerock.moko.resources.compose.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.common.model.*
import chat.simplex.common.ui.theme.CurrentColors
import chat.simplex.common.views.helpers.AlertManager
import chat.simplex.common.views.helpers.generalGetString
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.CurrentColors
import chat.simplex.app.views.helpers.*
import chat.simplex.res.MR
import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
@Composable
fun CIRcvDecryptionError(

View File

@@ -1,5 +1,8 @@
package chat.simplex.common.views.chat.item
package chat.simplex.app.views.chat.item
import android.graphics.Bitmap
import android.graphics.Rect
import android.net.Uri
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CornerSize
@@ -8,21 +11,27 @@ import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.*
import androidx.compose.ui.graphics.*
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.layout.*
import androidx.compose.ui.platform.*
import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
import androidx.compose.ui.unit.*
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.content.FileProvider
import androidx.core.graphics.drawable.toDrawable
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.google.android.exoplayer2.ui.AspectRatioFrameLayout.RESIZE_MODE_FIXED_WIDTH
import com.google.android.exoplayer2.ui.StyledPlayerView
import chat.simplex.res.MR
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.helpers.*
import chat.simplex.common.model.*
import chat.simplex.common.platform.*
import dev.icerock.moko.resources.StringResource
import java.io.File
import java.net.URI
@Composable
fun CIVideoView(
@@ -37,14 +46,15 @@ fun CIVideoView(
Modifier.layoutId(CHAT_IMAGE_LAYOUT_ID),
contentAlignment = Alignment.TopEnd
) {
val context = LocalContext.current
val filePath = remember(file) { getLoadedFilePath(file) }
val preview = remember(image) { base64ToBitmap(image) }
if (file != null && filePath != null) {
val uri = remember(filePath) { getAppFileUri(filePath.substringAfterLast(File.separator)) }
val view = LocalMultiplatformView()
val uri = remember(filePath) { FileProvider.getUriForFile(context, "${BuildConfig.APPLICATION_ID}.provider", File(filePath)) }
val view = LocalView.current
VideoView(uri, file, preview, duration * 1000L, showMenu, onClick = {
hideKeyboard(view)
ModalManager.fullscreen.showCustomModal(animated = false) { close ->
ModalManager.shared.showCustomModal(animated = false) { close ->
ImageFullScreenView(imageProvider, close)
}
})
@@ -89,13 +99,14 @@ fun CIVideoView(
}
@Composable
private fun VideoView(uri: URI, file: CIFile, defaultPreview: ImageBitmap, defaultDuration: Long, showMenu: MutableState<Boolean>, onClick: () -> Unit) {
private fun VideoView(uri: Uri, file: CIFile, defaultPreview: Bitmap, defaultDuration: Long, showMenu: MutableState<Boolean>, onClick: () -> Unit) {
val context = LocalContext.current
val player = remember(uri) { VideoPlayer.getOrCreate(uri, false, defaultPreview, defaultDuration, true) }
val videoPlaying = remember(uri.path) { player.videoPlaying }
val progress = remember(uri.path) { player.progress }
val duration = remember(uri.path) { player.duration }
val preview by remember { player.preview }
// val soundEnabled by rememberSaveable(uri.path) { player.soundEnabled }
// val soundEnabled by rememberSaveable(uri.path) { player.soundEnabled }
val brokenVideo by rememberSaveable(uri.path) { player.brokenVideo }
val play = {
player.enableSound(true)
@@ -113,13 +124,21 @@ private fun VideoView(uri: URI, file: CIFile, defaultPreview: ImageBitmap, defau
}
Box {
val windowWidth = LocalWindowWidth()
val width = remember(preview) { if (preview.width * 0.97 <= preview.height) videoViewFullWidth(windowWidth) * 0.75f else DEFAULT_MAX_IMAGE_WIDTH }
PlayerView(
player,
width,
onClick = onClick,
onLongClick = { showMenu.value = true },
stop
val width = remember(preview) { if (preview.width * 0.97 <= preview.height) videoViewFullWidth(windowWidth) * 0.75f else 1000.dp }
AndroidView(
factory = { ctx ->
StyledPlayerView(ctx).apply {
useController = false
resizeMode = RESIZE_MODE_FIXED_WIDTH
this.player = player.player
}
},
Modifier
.width(width)
.combinedClickable(
onLongClick = { showMenu.value = true },
onClick = { if (player.player.playWhenReady) stop() else onClick() }
)
)
if (showPreview.value) {
ImageView(preview, showMenu, onClick)
@@ -129,9 +148,6 @@ private fun VideoView(uri: URI, file: CIFile, defaultPreview: ImageBitmap, defau
}
}
@Composable
expect fun PlayerView(player: VideoPlayer, width: Dp, onClick: () -> Unit, onLongClick: () -> Unit, stop: () -> Unit)
@Composable
private fun BoxScope.PlayButton(error: Boolean = false, onLongClick: () -> Unit, onClick: () -> Unit) {
Surface(
@@ -142,8 +158,7 @@ private fun BoxScope.PlayButton(error: Boolean = false, onLongClick: () -> Unit,
Box(
Modifier
.defaultMinSize(minWidth = 40.dp, minHeight = 40.dp)
.combinedClickable(onClick = onClick, onLongClick = onLongClick)
.onRightClick { onLongClick.invoke() },
.combinedClickable(onClick = onClick, onLongClick = onLongClick),
contentAlignment = Alignment.Center
) {
Icon(
@@ -201,25 +216,32 @@ private fun DurationProgress(file: CIFile, playing: MutableState<Boolean>, durat
}
@Composable
private fun ImageView(preview: ImageBitmap, showMenu: MutableState<Boolean>, onClick: () -> Unit) {
private fun ImageView(preview: Bitmap, showMenu: MutableState<Boolean>, onClick: () -> Unit) {
val windowWidth = LocalWindowWidth()
val width = remember(preview) { if (preview.width * 0.97 <= preview.height) videoViewFullWidth(windowWidth) * 0.75f else DEFAULT_MAX_IMAGE_WIDTH }
val width = remember(preview) { if (preview.width * 0.97 <= preview.height) videoViewFullWidth(windowWidth) * 0.75f else 1000.dp }
Image(
preview,
preview.asImageBitmap(),
contentDescription = stringResource(MR.strings.video_descr),
modifier = Modifier
.width(width)
.combinedClickable(
onLongClick = { showMenu.value = true },
onClick = onClick
)
.onRightClick { showMenu.value = true },
),
contentScale = ContentScale.FillWidth,
)
}
@Composable
expect fun LocalWindowWidth(): Dp
private fun LocalWindowWidth(): Dp {
val view = LocalView.current
val density = LocalDensity.current.density
return remember {
val rect = Rect()
view.getWindowVisibleDisplayFrame(rect)
(rect.width() / density).dp
}
}
@Composable
private fun progressIndicator() {
@@ -287,6 +309,7 @@ private fun loadingIndicator(file: CIFile?) {
}
is CIFileStatus.RcvCancelled -> fileIcon(painterResource(MR.images.ic_close), MR.strings.icon_descr_file)
is CIFileStatus.RcvError -> fileIcon(painterResource(MR.images.ic_close), MR.strings.icon_descr_file)
is CIFileStatus.Invalid -> fileIcon(painterResource(MR.images.ic_question_mark), MR.strings.icon_descr_file)
else -> {}
}
}
@@ -313,5 +336,5 @@ private fun receiveFileIfValidSize(file: CIFile, receiveFile: (Long) -> Unit) {
private fun videoViewFullWidth(windowWidth: Dp): Dp {
val approximatePadding = 100.dp
return minOf(DEFAULT_MAX_IMAGE_WIDTH, windowWidth - approximatePadding)
return minOf(1000.dp, windowWidth - approximatePadding)
}

View File

@@ -1,4 +1,4 @@
package chat.simplex.common.views.chat.item
package chat.simplex.app.views.chat.item
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.*
@@ -15,14 +15,12 @@ import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.*
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.platform.*
import dev.icerock.moko.resources.compose.painterResource
import androidx.compose.ui.unit.*
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.helpers.*
import chat.simplex.common.model.*
import chat.simplex.common.platform.getLoadedFilePath
import chat.simplex.common.platform.AudioPlayer
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.helpers.*
import chat.simplex.res.MR
import dev.icerock.moko.resources.compose.painterResource
import kotlinx.coroutines.flow.distinctUntilChanged
// TODO refactor https://github.com/simplex-chat/simplex-chat/pull/1451#discussion_r1033429901
@@ -44,6 +42,7 @@ fun CIVoiceView(
verticalAlignment = Alignment.CenterVertically
) {
if (file != null) {
val context = LocalContext.current
val filePath = remember(file.filePath, file.fileStatus) { getLoadedFilePath(file) }
var brokenAudio by rememberSaveable(file.filePath) { mutableStateOf(false) }
val audioPlaying = rememberSaveable(file.filePath) { mutableStateOf(false) }
@@ -108,7 +107,7 @@ private fun VoiceLayout(
MaterialTheme.colors.primary.mixWith(
backgroundColor.copy(1f).mixWith(MaterialTheme.colors.background, backgroundColor.alpha),
0.24f)
val width = LocalWindowWidth()
val width = with(LocalDensity.current) { LocalView.current.width.toDp() }
val colors = SliderDefaults.colors(
inactiveTrackColor = inactiveTrackColor
)
@@ -222,8 +221,7 @@ private fun PlayPauseButton(
.combinedClickable(
onClick = { if (!audioPlaying) play() else pause() },
onLongClick = longClick
)
.onRightClick { longClick() },
),
contentAlignment = Alignment.Center
) {
Icon(

View File

@@ -1,6 +1,7 @@
package chat.simplex.common.views.chat.item
package chat.simplex.app.views.chat.item
import androidx.compose.desktop.ui.tooling.preview.Preview
import android.Manifest
import android.os.Build
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
@@ -13,18 +14,17 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.*
import androidx.compose.ui.text.AnnotatedString
import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.common.model.*
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.helpers.*
import chat.simplex.common.views.chat.ComposeContextItem
import chat.simplex.common.views.chat.ComposeState
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.chat.*
import chat.simplex.app.views.helpers.*
import com.google.accompanist.permissions.rememberPermissionState
import chat.simplex.res.MR
import kotlinx.datetime.Clock
@@ -55,12 +55,14 @@ fun ChatItemView(
setReaction: (ChatInfo, ChatItem, Boolean, MsgReaction) -> Unit,
showItemDetails: (ChatInfo, ChatItem) -> Unit,
) {
val context = LocalContext.current
val uriHandler = LocalUriHandler.current
val sent = cItem.chatDir.sent
val alignment = if (sent) Alignment.CenterEnd else Alignment.CenterStart
val showMenu = remember { mutableStateOf(false) }
val revealed = remember { mutableStateOf(false) }
val fullDeleteAllowed = remember(cInfo) { cInfo.featureEnabled(ChatFeature.FullDelete) }
val saveFileLauncher = rememberSaveFileLauncher(ciFile = cItem.file)
val onLinkLongClick = { _: String -> showMenu.value = true }
val live = composeState.value.liveMessage != null
@@ -111,8 +113,7 @@ fun ChatItemView(
Column(
Modifier
.clip(RoundedCornerShape(18.dp))
.combinedClickable(onLongClick = { showMenu.value = true }, onClick = onClick)
.onRightClick { showMenu.value = true },
.combinedClickable(onLongClick = { showMenu.value = true }, onClick = onClick),
) {
@Composable
fun framedItemView() {
@@ -163,7 +164,6 @@ fun ChatItemView(
@Composable
fun MsgContentItemDropdownMenu() {
val saveFileLauncher = rememberSaveFileLauncher(ciFile = cItem.file)
DefaultDropdownMenu(showMenu) {
if (cInfo.featureEnabled(ChatFeature.Reactions) && cItem.allowAddReaction) {
MsgReactionsMenu()
@@ -178,21 +178,37 @@ fun ChatItemView(
showMenu.value = false
})
}
val clipboard = LocalClipboardManager.current
ItemAction(stringResource(MR.strings.share_verb), painterResource(MR.images.ic_share), onClick = {
val filePath = getLoadedFilePath(cItem.file)
when {
filePath != null -> shareFile(cItem.text, filePath)
else -> clipboard.shareText(cItem.content.text)
else -> shareText(cItem.content.text)
}
showMenu.value = false
})
ItemAction(stringResource(MR.strings.copy_verb), painterResource(MR.images.ic_content_copy), onClick = {
clipboard.setText(AnnotatedString(cItem.content.text))
copyText(cItem.content.text)
showMenu.value = false
})
if (cItem.content.msgContent is MsgContent.MCImage || cItem.content.msgContent is MsgContent.MCVideo || cItem.content.msgContent is MsgContent.MCFile || cItem.content.msgContent is MsgContent.MCVoice && getLoadedFilePath(cItem.file) != null) {
SaveContentItemAction(cItem, saveFileLauncher, showMenu)
if (cItem.content.msgContent is MsgContent.MCImage || cItem.content.msgContent is MsgContent.MCVideo || cItem.content.msgContent is MsgContent.MCFile || cItem.content.msgContent is MsgContent.MCVoice) {
val filePath = getLoadedFilePath(cItem.file)
if (filePath != null) {
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 -> saveFileLauncher.launch(cItem.file?.fileName)
else -> {}
}
showMenu.value = false
})
}
}
if (cItem.meta.editable && cItem.content.msgContent !is MsgContent.MCVoice && !live) {
ItemAction(stringResource(MR.strings.edit_verb), painterResource(MR.images.ic_edit_filled), onClick = {
@@ -324,9 +340,6 @@ fun ChatItemView(
}
}
@Composable
expect fun SaveContentItemAction(cItem: ChatItem, saveFileLauncher: FileChooserLauncher, showMenu: MutableState<Boolean>)
@Composable
fun CancelFileItemAction(
fileId: Long,

View File

@@ -1,5 +1,6 @@
package chat.simplex.common.views.chat.item
package chat.simplex.app.views.chat.item
import android.content.res.Configuration
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.*
@@ -9,11 +10,11 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.*
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.desktop.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.common.model.ChatItem
import chat.simplex.common.ui.theme.*
import chat.simplex.app.model.ChatItem
import chat.simplex.app.ui.theme.*
@Composable
fun DeletedItemView(ci: ChatItem, timedMessagesTTL: Int?, showMember: Boolean = false) {
@@ -41,10 +42,11 @@ fun DeletedItemView(ci: ChatItem, timedMessagesTTL: Int?, showMember: Boolean =
}
}
@Preview/*(
@Preview(showBackground = true)
@Preview(
uiMode = Configuration.UI_MODE_NIGHT_YES,
name = "Dark Mode"
)*/
)
@Composable
fun PreviewDeletedItemView() {
SimpleXTheme {

View File

@@ -1,4 +1,4 @@
package chat.simplex.common.views.chat.item
package chat.simplex.app.views.chat.item
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
@@ -9,7 +9,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.common.model.ChatItem
import chat.simplex.app.model.ChatItem
val largeEmojiFont: TextStyle = TextStyle(fontSize = 48.sp)
val mediumEmojiFont: TextStyle = TextStyle(fontSize = 36.sp)

View File

@@ -1,4 +1,4 @@
package chat.simplex.common.views.chat.item
package chat.simplex.app.views.chat.item
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
@@ -10,6 +10,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.layout.*
import androidx.compose.ui.platform.UriHandler
@@ -18,12 +19,14 @@ import dev.icerock.moko.resources.compose.stringResource
import androidx.compose.ui.text.*
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.*
import androidx.compose.ui.unit.*
import chat.simplex.common.model.*
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.helpers.*
import chat.simplex.common.platform.base64ToBitmap
import chat.simplex.app.R
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.helpers.*
import chat.simplex.res.MR
import kotlinx.datetime.Clock
import kotlin.math.min
@Composable
@@ -107,14 +110,13 @@ fun FramedItemView(
onLongClick = { showMenu.value = true },
onClick = { scrollToItem(qi.itemId?: return@combinedClickable) }
)
.onRightClick { showMenu.value = true }
) {
when (qi.content) {
is MsgContent.MCImage -> {
Box(Modifier.fillMaxWidth().weight(1f)) {
ciQuotedMsgView(qi)
}
val imageBitmap = base64ToBitmap(qi.content.image)
val imageBitmap = base64ToBitmap(qi.content.image).asImageBitmap()
Image(
imageBitmap,
contentDescription = stringResource(MR.strings.image_descr),
@@ -126,7 +128,7 @@ fun FramedItemView(
Box(Modifier.fillMaxWidth().weight(1f)) {
ciQuotedMsgView(qi)
}
val imageBitmap = base64ToBitmap(qi.content.image)
val imageBitmap = base64ToBitmap(qi.content.image).asImageBitmap()
Image(
imageBitmap,
contentDescription = stringResource(MR.strings.video_descr),
@@ -281,8 +283,8 @@ fun PriorityLayout(
content: @Composable () -> Unit
) {
/**
* Limiting max value for height + width in order to not crash the app, see [androidx.compose.ui.unit.Constraints.createConstraints]
* */
* Limiting max value for height + width in order to not crash the app, see [androidx.compose.ui.unit.Constraints.createConstraints]
* */
fun maxSafeHeight(width: Int) = when { // width bits + height bits should be <= 31
width < 0x1FFF /*MaxNonFocusMask*/ -> 0x3FFFF - 1 /* MaxFocusMask */ // 13 bits width + 18 bits height
width < 0x7FFF /*MinNonFocusMask*/ -> 0xFFFF - 1 /* MinFocusMask */ // 15 bits width + 16 bits height
@@ -315,7 +317,6 @@ fun PriorityLayout(
}
}
}
/*
class EditedProvider: PreviewParameterProvider<Boolean> {
override val values = listOf(false, true).asSequence()
@@ -505,4 +506,3 @@ fun PreviewQuoteWithLongTextAndFile(@PreviewParameter(EditedProvider::class) edi
)
}
}
*/

View File

@@ -1,23 +1,43 @@
package chat.simplex.common.views.chat.item
package chat.simplex.app.views.chat.item
import android.graphics.Bitmap
import android.net.Uri
import android.os.Build
import android.view.View
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.*
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
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.*
import androidx.compose.ui.graphics.painter.BitmapPainter
import androidx.compose.ui.input.pointer.*
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.onGloballyPositioned
import chat.simplex.common.platform.*
import chat.simplex.common.views.chat.ProviderMedia
import chat.simplex.common.views.helpers.*
import androidx.compose.ui.platform.*
import dev.icerock.moko.resources.compose.stringResource
import androidx.compose.ui.unit.*
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.view.isVisible
import chat.simplex.app.R
import chat.simplex.app.views.chat.ProviderMedia
import chat.simplex.app.views.helpers.*
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.accompanist.pager.*
import com.google.android.exoplayer2.ui.AspectRatioFrameLayout
import com.google.android.exoplayer2.ui.StyledPlayerView
import chat.simplex.res.MR
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.launch
import java.net.URI
import kotlin.math.absoluteValue
interface ImageGalleryProvider {
@@ -29,6 +49,7 @@ interface ImageGalleryProvider {
fun onDismiss(index: Int)
}
@OptIn(ExperimentalPagerApi::class)
@Composable
fun ImageFullScreenView(imageProvider: () -> ImageGalleryProvider, close: () -> Unit) {
val provider = remember { imageProvider() }
@@ -44,11 +65,11 @@ fun ImageFullScreenView(imageProvider: () -> ImageGalleryProvider, close: () ->
}
}
val scope = rememberCoroutineScope()
val playersToRelease = rememberSaveable { mutableSetOf<URI>() }
val playersToRelease = rememberSaveable { mutableSetOf<Uri>() }
DisposableEffectOnGone(
whenGone = { playersToRelease.forEach { VideoPlayer.release(it, true, true) } }
)
HorizontalPager(pageCount = remember { provider.totalMediaSize }.value, state = pagerState) { index ->
HorizontalPager(count = remember { provider.totalMediaSize }.value, state = pagerState) { index ->
Column(
Modifier
.fillMaxSize()
@@ -120,11 +141,29 @@ fun ImageFullScreenView(imageProvider: () -> ImageGalleryProvider, close: () ->
)
}
.fillMaxSize()
// LALAL
// https://github.com/JetBrains/compose-multiplatform/pull/2015/files#diff-841b3825c504584012e1d1c834d731bae794cce6acad425d81847c8bbbf239e0R24
if (media is ProviderMedia.Image) {
val (uri: URI, imageBitmap: ImageBitmap) = media
FullScreenImageView(modifier, uri, imageBitmap)
val (uri: Uri, imageBitmap: Bitmap) = media
// 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).size(Size.ORIGINAL).build(),
placeholder = BitmapPainter(imageBitmap.asImageBitmap()), // show original image while it's still loading by coil
imageLoader = imageLoader
),
contentDescription = stringResource(MR.strings.image_descr),
contentScale = ContentScale.Fit,
modifier = modifier,
)
} else if (media is ProviderMedia.Video) {
val preview = remember(media.uri.path) { base64ToBitmap(media.preview) }
VideoView(modifier, media.uri, preview, index == settledCurrentPage)
@@ -138,10 +177,8 @@ fun ImageFullScreenView(imageProvider: () -> ImageGalleryProvider, close: () ->
}
@Composable
expect fun FullScreenImageView(modifier: Modifier, uri: URI, imageBitmap: ImageBitmap)
@Composable
private fun VideoView(modifier: Modifier, uri: URI, defaultPreview: ImageBitmap, currentPage: Boolean) {
private fun VideoView(modifier: Modifier, uri: Uri, defaultPreview: Bitmap, currentPage: Boolean) {
val context = LocalContext.current
val player = remember(uri) { VideoPlayer.getOrCreate(uri, true, defaultPreview, 0L, true) }
val isCurrentPage = rememberUpdatedState(currentPage)
val play = {
@@ -158,9 +195,25 @@ private fun VideoView(modifier: Modifier, uri: URI, defaultPreview: ImageBitmap,
}
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
FullScreenVideoView(player, 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
)
}
}
@Composable
expect fun FullScreenVideoView(player: VideoPlayer, modifier: Modifier)

View File

@@ -1,5 +1,6 @@
package chat.simplex.common.views.chat.item
package chat.simplex.app.views.chat.item
import android.content.res.Configuration
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
@@ -12,15 +13,16 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.*
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.desktop.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.common.model.ChatItem
import chat.simplex.common.model.MsgErrorType
import chat.simplex.common.ui.theme.CurrentColors
import chat.simplex.common.ui.theme.SimpleXTheme
import chat.simplex.common.views.helpers.AlertManager
import chat.simplex.common.views.helpers.generalGetString
import chat.simplex.app.R
import chat.simplex.app.model.ChatItem
import chat.simplex.app.model.MsgErrorType
import chat.simplex.app.ui.theme.CurrentColors
import chat.simplex.app.ui.theme.SimpleXTheme
import chat.simplex.app.views.helpers.AlertManager
import chat.simplex.app.views.helpers.generalGetString
import chat.simplex.res.MR
@Composable
@@ -74,10 +76,11 @@ fun CIMsgError(ci: ChatItem, timedMessagesTTL: Int?, showMember: Boolean = false
}
}
@Preview/*(
@Preview(showBackground = true)
@Preview(
uiMode = Configuration.UI_MODE_NIGHT_YES,
name = "Dark Mode"
)*/
)
@Composable
fun IntegrityErrorItemViewPreview() {
SimpleXTheme {

View File

@@ -1,4 +1,6 @@
package chat.simplex.common.views.chat.item
package chat.simplex.app.views.chat.item
import android.content.res.Configuration
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.*
@@ -9,13 +11,14 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.text.*
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.desktop.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.common.model.CIDeleted
import chat.simplex.common.model.ChatItem
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.helpers.generalGetString
import chat.simplex.app.R
import chat.simplex.app.model.CIDeleted
import chat.simplex.app.model.ChatItem
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.helpers.generalGetString
import chat.simplex.res.MR
import kotlinx.datetime.Clock
@@ -57,10 +60,11 @@ private fun MarkedDeletedText(text: String) {
)
}
@Preview/*(
@Preview(showBackground = true)
@Preview(
uiMode = Configuration.UI_MODE_NIGHT_YES,
name = "Dark Mode"
)*/
)
@Composable
fun PreviewMarkedDeletedItemView() {
SimpleXTheme {

View File

@@ -1,5 +1,9 @@
package chat.simplex.common.views.chat.item
package chat.simplex.app.views.chat.item
import android.app.Activity
import android.content.ActivityNotFoundException
import android.util.Log
import androidx.annotation.IntRange
import androidx.compose.foundation.text.*
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
@@ -15,11 +19,11 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.*
import chat.simplex.common.model.*
import chat.simplex.common.platform.Log
import chat.simplex.common.platform.TAG
import chat.simplex.common.ui.theme.CurrentColors
import chat.simplex.common.views.helpers.*
import androidx.core.text.BidiFormatter
import chat.simplex.app.TAG
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.CurrentColors
import chat.simplex.app.views.helpers.detectGesture
import kotlinx.coroutines.*
val reserveTimestampStyle = SpanStyle(color = Color.Transparent)
@@ -52,7 +56,7 @@ private val typingIndicators: List<AnnotatedString> = listOf(
)
private fun typingIndicator(recent: Boolean, typingIdx: Int): AnnotatedString = buildAnnotatedString {
private fun typingIndicator(recent: Boolean, @IntRange (from = 0, to = 4) typingIdx: Int): AnnotatedString = buildAnnotatedString {
pushStyle(SpanStyle(color = CurrentColors.value.colors.secondary, fontFamily = FontFamily.Monospace, letterSpacing = (-1).sp))
append(if (recent) typingIndicators[typingIdx] else noTyping)
}
@@ -78,7 +82,7 @@ fun MarkdownText (
onLinkLongClick: (link: String) -> Unit = {}
) {
val textLayoutDirection = remember (text) {
if (isRtl(text.subSequence(0, kotlin.math.min(50, text.length)))) LayoutDirection.Rtl else LayoutDirection.Ltr
if (BidiFormatter.getInstance().isRtl(text.subSequence(0, kotlin.math.min(50, text.length)))) LayoutDirection.Rtl else LayoutDirection.Ltr
}
val reserve = if (textLayoutDirection != LocalLayoutDirection.current && meta != null) {
"\n"
@@ -113,14 +117,18 @@ fun MarkdownText (
}
}
if (meta?.isLive == true) {
val activity = LocalContext.current as Activity
LaunchedEffect(meta.recent, meta.isLive) {
switchTyping()
}
DisposableEffectOnGone(
whenGone = {
stopTyping()
DisposableEffect(Unit) {
val orientation = activity.resources.configuration.orientation
onDispose {
if (orientation == activity.resources.configuration.orientation) {
stopTyping()
}
}
)
}
}
if (formattedText == null) {
val annotatedText = buildAnnotatedString {
@@ -148,7 +156,7 @@ fun MarkdownText (
} else {
ft.format.style
}
withAnnotation(tag = if (ft.format is Format.SimplexLink && linkMode != SimplexLinkMode.BROWSER) "SIMPLEX_URL" else "URL", annotation = link) {
withAnnotation(tag = "URL", annotation = link) {
withStyle(ftStyle) { append(ft.viewText(linkMode)) }
}
} else {
@@ -169,28 +177,21 @@ fun MarkdownText (
onLongClick = { offset ->
annotatedText.getStringAnnotations(tag = "URL", start = offset, end = offset)
.firstOrNull()?.let { annotation -> onLinkLongClick(annotation.item) }
annotatedText.getStringAnnotations(tag = "SIMPLEX_URL", start = offset, end = offset)
.firstOrNull()?.let { annotation -> onLinkLongClick(annotation.item) }
},
onClick = { offset ->
annotatedText.getStringAnnotations(tag = "URL", start = offset, end = offset)
.firstOrNull()?.let { annotation ->
try {
uriHandler.openUri(annotation.item)
} catch (e: Exception) {
} catch (e: ActivityNotFoundException) {
// It can happen, for example, when you click on a text 0.00001 but don't have any app that can catch
// `tel:` scheme in url installed on a device (no phone app or contacts, maybe)
Log.e(TAG, "Open url: ${e.stackTraceToString()}")
}
}
annotatedText.getStringAnnotations(tag = "SIMPLEX_URL", start = offset, end = offset)
.firstOrNull()?.let { annotation ->
uriHandler.openVerifiedSimplexUri(annotation.item)
}
},
shouldConsumeEvent = { offset ->
annotatedText.getStringAnnotations(tag = "URL", start = offset, end = offset).any()
annotatedText.getStringAnnotations(tag = "SIMPLEX_URL", start = offset, end = offset).any()
}
)
} else {
@@ -227,12 +228,12 @@ fun ClickableText(
}
}
}, shouldConsumeEvent = { pos ->
var consume = false
layoutResult.value?.let { layoutResult ->
consume = shouldConsumeEvent(layoutResult.getOffsetForPosition(pos))
}
var consume = false
layoutResult.value?.let { layoutResult ->
consume = shouldConsumeEvent(layoutResult.getOffsetForPosition(pos))
}
consume
}
}
)
}
@@ -249,13 +250,3 @@ fun ClickableText(
}
)
}
private fun isRtl(s: CharSequence): Boolean {
for (element in s) {
val d = Character.getDirectionality(element)
if (d == Character.DIRECTIONALITY_RIGHT_TO_LEFT || d == Character.DIRECTIONALITY_RIGHT_TO_LEFT_ARABIC || d == Character.DIRECTIONALITY_RIGHT_TO_LEFT_EMBEDDING || d == Character.DIRECTIONALITY_RIGHT_TO_LEFT_OVERRIDE) {
return true
}
}
return false
}

View File

@@ -1,5 +1,6 @@
package chat.simplex.common.views.chatlist
package chat.simplex.app.views.chatlist
import android.content.res.Configuration
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
@@ -10,14 +11,15 @@ import androidx.compose.ui.Modifier
import dev.icerock.moko.resources.compose.stringResource
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.desktop.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.common.ui.theme.SimpleXTheme
import chat.simplex.common.views.helpers.annotatedStringResource
import chat.simplex.common.views.onboarding.ReadableTextWithLink
import chat.simplex.common.views.usersettings.MarkdownHelpView
import chat.simplex.common.views.usersettings.simplexTeamUri
import chat.simplex.app.R
import chat.simplex.app.ui.theme.SimpleXTheme
import chat.simplex.app.views.helpers.annotatedStringResource
import chat.simplex.app.views.onboarding.ReadableTextWithLink
import chat.simplex.app.views.usersettings.MarkdownHelpView
import chat.simplex.app.views.usersettings.simplexTeamUri
import chat.simplex.res.MR
val bold = SpanStyle(fontWeight = FontWeight.Bold)
@@ -28,7 +30,7 @@ fun ChatHelpView(addContact: (() -> Unit)? = null) {
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
Text(stringResource(MR.strings.thank_you_for_installing_simplex), lineHeight = 22.sp)
ReadableTextWithLink(MR.strings.you_can_connect_to_simplex_chat_founder, simplexTeamUri, simplexLink = true)
ReadableTextWithLink(MR.strings.you_can_connect_to_simplex_chat_founder, simplexTeamUri)
Column(
Modifier.padding(top = 24.dp),
verticalArrangement = Arrangement.spacedBy(10.dp)
@@ -74,11 +76,12 @@ fun ChatHelpView(addContact: (() -> Unit)? = null) {
}
}
@Preview/*(
@Preview(showBackground = true)
@Preview(
uiMode = Configuration.UI_MODE_NIGHT_YES,
showBackground = true,
name = "Dark Mode"
)*/
)
@Composable
fun PreviewChatHelpLayout() {
SimpleXTheme {

View File

@@ -1,5 +1,6 @@
package chat.simplex.common.views.chatlist
package chat.simplex.app.views.chatlist
import android.content.res.Configuration
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
@@ -12,20 +13,18 @@ import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.desktop.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.common.model.*
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.chat.*
import chat.simplex.common.views.chat.group.deleteGroupDialog
import chat.simplex.common.views.chat.group.leaveGroupDialog
import chat.simplex.common.views.chat.item.InvalidJSONView
import chat.simplex.common.views.chat.item.ItemAction
import chat.simplex.common.views.helpers.*
import chat.simplex.common.views.newchat.ContactConnectionInfoView
import chat.simplex.common.platform.appPlatform
import chat.simplex.common.platform.ntfManager
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.chat.*
import chat.simplex.app.views.chat.group.deleteGroupDialog
import chat.simplex.app.views.chat.group.leaveGroupDialog
import chat.simplex.app.views.chat.item.InvalidJSONView
import chat.simplex.app.views.chat.item.ItemAction
import chat.simplex.app.views.helpers.*
import chat.simplex.app.views.newchat.ContactConnectionInfoView
import chat.simplex.res.MR
import kotlinx.coroutines.delay
import kotlinx.datetime.Clock
@@ -73,9 +72,7 @@ fun ChatListNavLinkView(chat: Chat, chatModel: ChatModel) {
ChatListNavLinkLayout(
chatLinkPreview = { ContactConnectionView(chat.chatInfo.contactConnection) },
click = {
ModalManager.center.closeModals()
ModalManager.end.closeModals()
ModalManager.center.showModalCloseable(true, showClose = appPlatform.isAndroid) { close ->
ModalManager.shared.showModalCloseable(true) { close ->
ContactConnectionInfoView(chatModel, chat.chatInfo.contactConnection.connReqInv, chat.chatInfo.contactConnection, false, close)
}
},
@@ -89,8 +86,7 @@ fun ChatListNavLinkView(chat: Chat, chatModel: ChatModel) {
InvalidDataView()
},
click = {
ModalManager.end.closeModals()
ModalManager.center.showModal(true) { InvalidJSONView(chat.chatInfo.json) }
ModalManager.shared.showModal(true) { InvalidJSONView(chat.chatInfo.json) }
},
dropdownMenuItems = null,
showMenu,
@@ -209,7 +205,7 @@ fun MarkReadChatAction(chat: Chat, chatModel: ChatModel, showMenu: MutableState<
painterResource(MR.images.ic_check),
onClick = {
markChatRead(chat, chatModel)
ntfManager.cancelNotificationsForChat(chat.id)
chatModel.controller.ntfManager.cancelNotificationsForChat(chat.id)
showMenu.value = false
}
)
@@ -345,9 +341,7 @@ fun ContactConnectionMenuItems(chatInfo: ChatInfo.ContactConnection, chatModel:
stringResource(MR.strings.set_contact_name),
painterResource(MR.images.ic_edit),
onClick = {
ModalManager.center.closeModals()
ModalManager.end.closeModals()
ModalManager.center.showModalCloseable(true, showClose = appPlatform.isAndroid) { close ->
ModalManager.shared.showModalCloseable(true) { close ->
ContactConnectionInfoView(chatModel, chatInfo.contactConnection.connReqInv, chatInfo.contactConnection, true, close)
}
showMenu.value = false
@@ -554,7 +548,7 @@ fun deleteGroup(groupInfo: GroupInfo, chatModel: ChatModel) {
if (r) {
chatModel.removeChat(groupInfo.id)
chatModel.chatId.value = null
ntfManager.cancelNotificationsForChat(groupInfo.id)
chatModel.controller.ntfManager.cancelNotificationsForChat(groupInfo.id)
}
}
}
@@ -599,7 +593,7 @@ fun updateChatSettings(chat: Chat, chatSettings: ChatSettings, chatModel: ChatMo
if (res && newChatInfo != null) {
chatModel.updateChatInfo(newChatInfo)
if (!chatSettings.enableNtfs) {
ntfManager.cancelNotificationsForChat(chat.id)
chatModel.controller.ntfManager.cancelNotificationsForChat(chat.id)
}
val current = currentState?.value
if (current != null) {
@@ -618,9 +612,7 @@ fun ChatListNavLinkLayout(
stopped: Boolean
) {
var modifier = Modifier.fillMaxWidth()
if (!stopped) modifier = modifier
.combinedClickable(onClick = click, onLongClick = { showMenu.value = true })
.onRightClick { showMenu.value = true }
if (!stopped) modifier = modifier.combinedClickable(onClick = click, onLongClick = { showMenu.value = true })
Box(modifier) {
Row(
modifier = Modifier
@@ -637,11 +629,12 @@ fun ChatListNavLinkLayout(
Divider(Modifier.padding(horizontal = 8.dp))
}
@Preview/*(
@Preview
@Preview(
uiMode = Configuration.UI_MODE_NIGHT_YES,
showBackground = true,
name = "Dark Mode"
)*/
)
@Composable
fun PreviewChatListNavLinkDirect() {
SimpleXTheme {
@@ -677,11 +670,12 @@ fun PreviewChatListNavLinkDirect() {
}
}
@Preview/*(
@Preview
@Preview(
uiMode = Configuration.UI_MODE_NIGHT_YES,
showBackground = true,
name = "Dark Mode"
)*/
)
@Composable
fun PreviewChatListNavLinkGroup() {
SimpleXTheme {
@@ -717,11 +711,12 @@ fun PreviewChatListNavLinkGroup() {
}
}
@Preview/*(
@Preview
@Preview(
uiMode = Configuration.UI_MODE_NIGHT_YES,
showBackground = true,
name = "Dark Mode"
)*/
)
@Composable
fun PreviewChatListNavLinkContactRequest() {
SimpleXTheme {

View File

@@ -1,5 +1,6 @@
package chat.simplex.common.views.chatlist
package chat.simplex.app.views.chatlist
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.*
@@ -8,33 +9,34 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.*
import androidx.compose.ui.platform.LocalUriHandler
import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
import androidx.compose.ui.text.capitalize
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.intl.Locale
import androidx.compose.ui.unit.*
import chat.simplex.common.SettingsViewState
import chat.simplex.common.model.*
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.helpers.*
import chat.simplex.common.views.onboarding.WhatsNewView
import chat.simplex.common.views.onboarding.shouldShowWhatsNew
import chat.simplex.common.views.usersettings.SettingsView
import chat.simplex.common.views.usersettings.simplexTeamUri
import chat.simplex.common.platform.*
import chat.simplex.common.views.newchat.*
import chat.simplex.app.*
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.helpers.*
import chat.simplex.app.views.newchat.NewChatSheet
import chat.simplex.app.views.onboarding.WhatsNewView
import chat.simplex.app.views.onboarding.shouldShowWhatsNew
import chat.simplex.app.views.usersettings.SettingsView
import chat.simplex.app.views.usersettings.simplexTeamUri
import chat.simplex.res.MR
import kotlinx.coroutines.*
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import java.net.URI
import kotlinx.coroutines.launch
@Composable
fun ChatListView(chatModel: ChatModel, settingsState: SettingsViewState, setPerformLA: (Boolean) -> Unit, stopped: Boolean) {
fun ChatListView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit, stopped: Boolean) {
val newChatSheetState by rememberSaveable(stateSaver = AnimatedViewState.saver()) { mutableStateOf(MutableStateFlow(AnimatedViewState.GONE)) }
val userPickerState by rememberSaveable(stateSaver = AnimatedViewState.saver()) { mutableStateOf(MutableStateFlow(AnimatedViewState.GONE)) }
val showNewChatSheet = {
newChatSheetState.value = AnimatedViewState.VISIBLE
}
@@ -45,7 +47,7 @@ fun ChatListView(chatModel: ChatModel, settingsState: SettingsViewState, setPerf
LaunchedEffect(Unit) {
if (shouldShowWhatsNew(chatModel)) {
delay(1000L)
ModalManager.center.showCustomModal { close -> WhatsNewView(close = close) }
ModalManager.shared.showCustomModal { close -> WhatsNewView(close = close) }
}
}
LaunchedEffect(chatModel.clearOverlays.value) {
@@ -58,13 +60,13 @@ fun ChatListView(chatModel: ChatModel, settingsState: SettingsViewState, setPerf
connectIfOpenedViaUri(url, chatModel)
}
}
val endPadding = if (appPlatform.isDesktop) 56.dp else 0.dp
var searchInList by rememberSaveable { mutableStateOf("") }
val scaffoldState = rememberScaffoldState()
val scope = rememberCoroutineScope()
val (userPickerState, scaffoldState, switchingUsers ) = settingsState
Scaffold(topBar = { Box(Modifier.padding(end = endPadding)) { ChatListToolbar(chatModel, scaffoldState.drawerState, userPickerState, stopped) { searchInList = it.trim() } } },
val switchingUsers = rememberSaveable { mutableStateOf(false) }
Scaffold(topBar = { ChatListToolbar(chatModel, scaffoldState.drawerState, userPickerState, stopped) { searchInList = it.trim() } },
scaffoldState = scaffoldState,
drawerContent = { SettingsView(chatModel, setPerformLA, scaffoldState.drawerState) },
drawerContent = { SettingsView(chatModel, setPerformLA) },
drawerScrimColor = MaterialTheme.colors.onSurface.copy(alpha = if (isInDarkTheme()) 0.16f else 0.32f),
floatingActionButton = {
if (searchInList.isEmpty()) {
@@ -74,7 +76,7 @@ fun ChatListView(chatModel: ChatModel, settingsState: SettingsViewState, setPerf
if (newChatSheetState.value.isVisible()) hideNewChatSheet(true) else showNewChatSheet()
}
},
Modifier.padding(end = DEFAULT_PADDING - 16.dp + endPadding, bottom = DEFAULT_PADDING - 16.dp),
Modifier.padding(end = DEFAULT_PADDING - 16.dp, bottom = DEFAULT_PADDING - 16.dp),
elevation = FloatingActionButtonDefaults.elevation(
defaultElevation = 0.dp,
pressedElevation = 0.dp,
@@ -89,7 +91,7 @@ fun ChatListView(chatModel: ChatModel, settingsState: SettingsViewState, setPerf
}
}
) {
Box(Modifier.padding(it).padding(end = endPadding)) {
Box(Modifier.padding(it)) {
Column(
modifier = Modifier
.fillMaxSize()
@@ -110,10 +112,8 @@ fun ChatListView(chatModel: ChatModel, settingsState: SettingsViewState, setPerf
if (searchInList.isEmpty()) {
NewChatSheet(chatModel, newChatSheetState, stopped, hideNewChatSheet)
}
if (appPlatform.isAndroid) {
UserPicker(chatModel, userPickerState, switchingUsers) {
scope.launch { if (scaffoldState.drawerState.isOpen) scaffoldState.drawerState.close() else scaffoldState.drawerState.open() }
}
UserPicker(chatModel, userPickerState, switchingUsers) {
scope.launch { if (scaffoldState.drawerState.isOpen) scaffoldState.drawerState.close() else scaffoldState.drawerState.open() }
}
if (switchingUsers.value) {
Box(
@@ -130,7 +130,7 @@ private fun OnboardingButtons(openNewChatSheet: () -> Unit) {
Column(Modifier.fillMaxSize().padding(DEFAULT_PADDING), horizontalAlignment = Alignment.End, verticalArrangement = Arrangement.Bottom) {
val uriHandler = LocalUriHandler.current
ConnectButton(generalGetString(MR.strings.chat_with_developers)) {
uriHandler.openVerifiedSimplexUri(simplexTeamUri)
uriHandler.openUriCatching(simplexTeamUri)
}
Spacer(Modifier.height(DEFAULT_PADDING))
ConnectButton(generalGetString(MR.strings.tap_to_start_new_chat), openNewChatSheet)
@@ -178,7 +178,7 @@ private fun ChatListToolbar(chatModel: ChatModel, drawerState: DrawerState, user
if (chatModel.chats.size > 0) {
barButtons.add {
IconButton({ showSearch = true }) {
Icon(painterResource(MR.images.ic_search_500), stringResource(MR.strings.search_verb), tint = MaterialTheme.colors.primary)
Icon(painterResource(MR.images.ic_search_500), stringResource(MR.strings.search_verb).capitalize(Locale.current), tint = MaterialTheme.colors.primary)
}
}
}
@@ -279,7 +279,7 @@ private fun BoxScope.unreadBadge(text: String? = "") {
@Composable
private fun ToggleFilterButton() {
val pref = remember { ChatController.appPrefs.showUnreadAndFavorites }
val pref = remember { SimplexApp.context.chatModel.controller.appPrefs.showUnreadAndFavorites }
IconButton(onClick = { pref.set(!pref.get()) }) {
Icon(
painterResource(MR.images.ic_filter_list),
@@ -306,35 +306,6 @@ private fun ProgressIndicator() {
)
}
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)
}
}
)
}
}
}
private var lazyListState = 0 to 0
@Composable
@@ -343,12 +314,8 @@ private fun ChatList(chatModel: ChatModel, search: String) {
DisposableEffect(Unit) {
onDispose { lazyListState = listState.firstVisibleItemIndex to listState.firstVisibleItemScrollOffset }
}
val showUnreadAndFavorites = remember { ChatController.appPrefs.showUnreadAndFavorites.state }.value
val allChats = remember { chatModel.chats }
// In some not always reproducible situations this code produce IndexOutOfBoundsException on Compose's side
// which is related to [derivedStateOf]. Using safe alternative instead
// val chats by remember(search, showUnreadAndFavorites) { derivedStateOf { filteredChats(showUnreadAndFavorites, search, allChats.toList()) } }
val chats = filteredChats(showUnreadAndFavorites, search, allChats.toList())
val showUnreadAndFavorites = remember { chatModel.controller.appPrefs.showUnreadAndFavorites.state }.value
val chats by remember(search, showUnreadAndFavorites) { derivedStateOf { filteredChats(showUnreadAndFavorites, search) } }
LazyColumn(
modifier = Modifier.fillMaxWidth(),
listState
@@ -364,12 +331,13 @@ private fun ChatList(chatModel: ChatModel, search: String) {
}
}
private fun filteredChats(showUnreadAndFavorites: Boolean, searchText: String, chats: List<Chat>): List<Chat> {
private fun filteredChats(showUnreadAndFavorites: Boolean, searchText: String): List<Chat> {
val chatModel = SimplexApp.context.chatModel
val s = searchText.trim().lowercase()
return if (s.isEmpty() && !showUnreadAndFavorites)
chats
chatModel.chats
else {
chats.filter { chat ->
chatModel.chats.filter { chat ->
when (val cInfo = chat.chatInfo) {
is ChatInfo.Direct -> if (s.isEmpty()) {
filtered(chat)
@@ -396,3 +364,4 @@ private fun filtered(chat: Chat): Boolean =
private fun viewNameContains(cInfo: ChatInfo, s: String): Boolean =
cInfo.chatViewName.lowercase().contains(s.lowercase())

View File

@@ -1,5 +1,6 @@
package chat.simplex.common.views.chatlist
package chat.simplex.app.views.chatlist
import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
@@ -16,15 +17,15 @@ import dev.icerock.moko.resources.compose.stringResource
import androidx.compose.ui.text.*
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.desktop.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.*
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.chat.ComposePreview
import chat.simplex.common.views.chat.ComposeState
import chat.simplex.common.views.chat.item.MarkdownText
import chat.simplex.common.views.helpers.*
import chat.simplex.common.model.*
import chat.simplex.common.model.GroupInfo
import chat.simplex.app.R
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.chat.ComposePreview
import chat.simplex.app.views.chat.ComposeState
import chat.simplex.app.views.chat.item.MarkdownText
import chat.simplex.app.views.helpers.*
import chat.simplex.res.MR
import dev.icerock.moko.resources.ImageResource
@@ -311,11 +312,12 @@ fun ChatStatusImage(s: NetworkStatus?) {
}
}
@Preview/*(
@Preview(showBackground = true)
@Preview(
uiMode = Configuration.UI_MODE_NIGHT_YES,
showBackground = true,
name = "Dark Mode"
)*/
)
@Composable
fun PreviewChatPreviewView() {
SimpleXTheme {

View File

@@ -1,4 +1,4 @@
package chat.simplex.common.views.chatlist
package chat.simplex.app.views.chatlist
import androidx.compose.foundation.layout.*
import androidx.compose.material.MaterialTheme
@@ -7,14 +7,14 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalDensity
import dev.icerock.moko.resources.compose.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.helpers.ProfileImage
import chat.simplex.common.model.PendingContactConnection
import chat.simplex.common.model.getTimestampText
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.helpers.ProfileImage
import chat.simplex.res.MR
@Composable

View File

@@ -1,4 +1,4 @@
package chat.simplex.common.views.chatlist
package chat.simplex.app.views.chatlist
import androidx.compose.foundation.layout.*
import androidx.compose.material.MaterialTheme
@@ -11,10 +11,10 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.helpers.ChatInfoImage
import chat.simplex.common.model.ChatInfo
import chat.simplex.common.model.getTimestampText
import chat.simplex.app.R
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.helpers.ChatInfoImage
import chat.simplex.res.MR
@Composable

View File

@@ -1,4 +1,4 @@
package chat.simplex.common.views.chatlist
package chat.simplex.app.views.chatlist
import SectionItemView
import androidx.compose.foundation.layout.*
@@ -9,9 +9,9 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import chat.simplex.common.ui.theme.Indigo
import chat.simplex.common.views.helpers.ProfileImage
import chat.simplex.common.model.*
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.Indigo
import chat.simplex.app.views.helpers.ProfileImage
@Composable
fun ShareListNavLinkView(chat: Chat, chatModel: ChatModel) {

View File

@@ -1,5 +1,7 @@
package chat.simplex.common.views.chatlist
package chat.simplex.app.views.chatlist
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
@@ -11,26 +13,23 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
import androidx.compose.ui.text.capitalize
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.intl.Locale
import androidx.compose.ui.unit.dp
import chat.simplex.common.SettingsViewState
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.helpers.*
import chat.simplex.common.model.Chat
import chat.simplex.common.model.ChatModel
import chat.simplex.common.platform.BackHandler
import chat.simplex.common.platform.appPlatform
import chat.simplex.app.R
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.helpers.*
import chat.simplex.res.MR
import kotlinx.coroutines.flow.MutableStateFlow
@Composable
fun ShareListView(chatModel: ChatModel, settingsState: SettingsViewState, stopped: Boolean) {
fun ShareListView(chatModel: ChatModel, stopped: Boolean) {
var searchInList by rememberSaveable { mutableStateOf("") }
val (userPickerState, scaffoldState, switchingUsers) = settingsState
val endPadding = if (appPlatform.isDesktop) 56.dp else 0.dp
val userPickerState by rememberSaveable(stateSaver = AnimatedViewState.saver()) { mutableStateOf(MutableStateFlow(AnimatedViewState.GONE)) }
val switchingUsers = rememberSaveable { mutableStateOf(false) }
Scaffold(
Modifier.padding(end = endPadding),
scaffoldState = scaffoldState,
topBar = { Column { ShareListToolbar(chatModel, userPickerState, stopped) { searchInList = it.trim() } } },
) {
Box(Modifier.padding(it)) {
@@ -46,11 +45,9 @@ fun ShareListView(chatModel: ChatModel, settingsState: SettingsViewState, stoppe
}
}
}
if (appPlatform.isAndroid) {
UserPicker(chatModel, userPickerState, switchingUsers, showSettings = false, showCancel = true, cancelClicked = {
chatModel.sharedContent.value = null
})
}
UserPicker(chatModel, userPickerState, switchingUsers, showSettings = false, showCancel = true, cancelClicked = {
chatModel.sharedContent.value = null
})
}
@Composable
@@ -86,7 +83,7 @@ private fun ShareListToolbar(chatModel: ChatModel, userPickerState: MutableState
if (chatModel.chats.size >= 8) {
barButtons.add {
IconButton({ showSearch = true }) {
Icon(painterResource(MR.images.ic_search_500), stringResource(MR.strings.search_verb), tint = MaterialTheme.colors.primary)
Icon(painterResource(MR.images.ic_search_500), stringResource(MR.strings.search_verb).capitalize(Locale.current), tint = MaterialTheme.colors.primary)
}
}
}

View File

@@ -1,6 +1,7 @@
package chat.simplex.common.views.chatlist
package chat.simplex.app.views.chatlist
import SectionItemView
import android.util.Log
import androidx.compose.animation.core.*
import androidx.compose.foundation.*
import androidx.compose.foundation.interaction.MutableInteractionSource
@@ -12,17 +13,19 @@ import androidx.compose.ui.*
import androidx.compose.ui.draw.*
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalDensity
import dev.icerock.moko.resources.compose.painterResource
import androidx.compose.ui.text.capitalize
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.intl.Locale
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.*
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.helpers.*
import chat.simplex.common.model.ChatModel
import chat.simplex.common.model.User
import chat.simplex.common.platform.*
import chat.simplex.app.R
import chat.simplex.app.TAG
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.helpers.*
import chat.simplex.res.MR
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.*
@@ -41,11 +44,6 @@ fun UserPicker(
) {
val scope = rememberCoroutineScope()
var newChat by remember { mutableStateOf(userPickerState.value) }
if (newChat.isVisible()) {
BackHandler {
userPickerState.value = AnimatedViewState.HIDING
}
}
val users by remember {
derivedStateOf {
chatModel.users
@@ -93,10 +91,10 @@ fun UserPicker(
}
}
val xOffset = with(LocalDensity.current) { 10.dp.roundToPx() }
val maxWidth = with(LocalDensity.current) { screenWidth() * density }
val maxWidth = with(LocalDensity.current) { LocalConfiguration.current.screenWidthDp * density }
Box(Modifier
.fillMaxSize()
.offset { IntOffset(if (newChat.isGone()) -maxWidth.value.roundToInt() else xOffset, 0) }
.offset { IntOffset(if (newChat.isGone()) -maxWidth.roundToInt() else xOffset, 0) }
.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = { userPickerState.value = AnimatedViewState.HIDING })
.padding(bottom = 10.dp, top = 10.dp)
.graphicsLayer {
@@ -126,7 +124,6 @@ fun UserPicker(
delay(500)
switchingUsers.value = true
}
ModalManager.closeAllModalsEverywhere()
chatModel.controller.changeActiveUser(u.user.userId, null)
job.cancel()
switchingUsers.value = false
@@ -165,16 +162,15 @@ fun UserProfilePickerItem(u: User, unreadCount: Int = 0, padding: PaddingValues
interactionSource = remember { MutableInteractionSource() },
indication = if (!u.activeUser) LocalIndication.current else null
)
.onRightClick { onLongClick() }
.padding(padding),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
UserProfileRow(u)
if (u.activeUser) {
Icon(painterResource(MR.images.ic_done_filled), null, Modifier.size(20.dp), tint = MaterialTheme.colors.onBackground)
Icon(painterResource(MR.images.ic_done_filled), null, Modifier.size(20.dp), tint = MaterialTheme.colors.onBackground)
} else if (u.hidden) {
Icon(painterResource(MR.images.ic_lock), null, Modifier.size(20.dp), tint = MaterialTheme.colors.secondary)
Icon(painterResource(MR.images.ic_lock), null, Modifier.size(20.dp), tint = MaterialTheme.colors.secondary)
} else if (unreadCount > 0) {
Box(
contentAlignment = Alignment.Center
@@ -201,7 +197,7 @@ fun UserProfilePickerItem(u: User, unreadCount: Int = 0, padding: PaddingValues
fun UserProfileRow(u: User) {
Row(
Modifier
.widthIn(max = screenWidth() * 0.7f)
.widthIn(max = LocalConfiguration.current.screenWidthDp.dp * 0.7f)
.padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {

View File

@@ -1,40 +1,47 @@
package chat.simplex.common.views.database
package chat.simplex.app.views.database
import SectionBottomSpacer
import SectionTextFooter
import SectionView
import androidx.compose.desktop.ui.tooling.preview.Preview
import android.content.Context
import android.content.res.Configuration
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.layout.*
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
import chat.simplex.common.model.ChatModel
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.SimpleXTheme
import chat.simplex.common.views.helpers.*
import chat.simplex.common.views.usersettings.*
import androidx.compose.ui.tooling.preview.Preview
import chat.simplex.app.*
import chat.simplex.app.R
import chat.simplex.app.model.ChatModel
import chat.simplex.app.ui.theme.SimpleXTheme
import chat.simplex.app.views.helpers.*
import chat.simplex.app.views.usersettings.*
import chat.simplex.res.MR
import kotlinx.datetime.*
import java.io.BufferedOutputStream
import java.io.File
import java.net.URI
import java.text.SimpleDateFormat
import java.util.*
@Composable
fun ChatArchiveView(m: ChatModel, title: String, archiveName: String, archiveTime: Instant) {
val archivePath = filesDir.absolutePath + File.separator + archiveName
val saveArchiveLauncher = rememberFileChooserLauncher(false) { to: URI? ->
if (to != null) {
copyFileToFile(File(archivePath), to) {}
}
}
val context = LocalContext.current
val archivePath = "${getFilesDirectory()}/$archiveName"
val saveArchiveLauncher = rememberSaveArchiveLauncher(archivePath)
ChatArchiveLayout(
title,
archiveTime,
saveArchive = { withApi { saveArchiveLauncher.launch(archivePath.substringAfterLast(File.separator)) }},
saveArchive = { saveArchiveLauncher.launch(archivePath.substringAfterLast("/")) },
deleteArchiveAlert = { deleteArchiveAlert(m, archivePath) }
)
}
@@ -74,6 +81,29 @@ fun ChatArchiveLayout(
}
}
@Composable
private fun rememberSaveArchiveLauncher(chatArchivePath: String): ManagedActivityResultLauncher<String, Uri?> =
rememberLauncherForActivityResult(
contract = ActivityResultContracts.CreateDocument(),
onResult = { destination ->
val cxt = SimplexApp.context
try {
destination?.let {
val contentResolver = cxt.contentResolver
contentResolver.openOutputStream(destination)?.let { stream ->
val outputStream = BufferedOutputStream(stream)
File(chatArchivePath).inputStream().use { it.copyTo(outputStream) }
outputStream.close()
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, "rememberSaveArchiveLauncher error saving archive $e")
}
}
)
private fun deleteArchiveAlert(m: ChatModel, archivePath: String) {
AlertManager.shared.showAlertDialog(
title = generalGetString(MR.strings.delete_chat_archive_question),
@@ -83,7 +113,7 @@ private fun deleteArchiveAlert(m: ChatModel, archivePath: String) {
if (fileDeleted) {
m.controller.appPrefs.chatArchiveName.set(null)
m.controller.appPrefs.chatArchiveTime.set(null)
ModalManager.start.closeModal()
ModalManager.shared.closeModal()
} else {
Log.e(TAG, "deleteArchiveAlert delete() error")
}
@@ -92,11 +122,12 @@ private fun deleteArchiveAlert(m: ChatModel, archivePath: String) {
)
}
@Preview/*(
@Preview(showBackground = true)
@Preview(
uiMode = Configuration.UI_MODE_NIGHT_YES,
showBackground = true,
name = "Dark Mode"
)*/
)
@Composable
fun PreviewChatArchiveLayout() {
SimpleXTheme {

View File

@@ -1,4 +1,4 @@
package chat.simplex.common.views.database
package chat.simplex.app.views.database
import SectionBottomSpacer
import SectionItemView
@@ -23,12 +23,13 @@ import dev.icerock.moko.resources.compose.stringResource
import androidx.compose.ui.text.*
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.*
import androidx.compose.desktop.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.*
import chat.simplex.common.model.*
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.helpers.*
import chat.simplex.common.model.*
import chat.simplex.app.R
import chat.simplex.app.SimplexApp
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.helpers.*
import chat.simplex.res.MR
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.datetime.Clock

View File

@@ -1,9 +1,10 @@
package chat.simplex.common.views.database
package chat.simplex.app.views.database
import SectionBottomSpacer
import SectionSpacer
import SectionView
import androidx.compose.desktop.ui.tooling.preview.Preview
import android.content.Context
import android.util.Log
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardActions
@@ -12,13 +13,17 @@ import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import chat.simplex.common.model.AppPreferences
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.helpers.*
import chat.simplex.common.views.usersettings.AppVersionText
import chat.simplex.app.*
import chat.simplex.app.R
import chat.simplex.app.model.AppPreferences
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.helpers.*
import chat.simplex.app.views.usersettings.AppVersionText
import chat.simplex.app.views.usersettings.NotificationsMode
import chat.simplex.res.MR
import dev.icerock.moko.resources.StringResource
import kotlinx.coroutines.*
@@ -37,6 +42,7 @@ fun DatabaseErrorView(
val dbKey = remember { mutableStateOf("") }
var storedDBKey by remember { mutableStateOf(DatabaseUtils.ksDatabasePassword.get()) }
var useKeychain by remember { mutableStateOf(appPreferences.storeDBPassphrase.get()) }
val context = LocalContext.current
val restoreDbFromBackup = remember { mutableStateOf(shouldShowRestoreDbButton(appPreferences)) }
fun callRunChat(confirmMigrations: MigrationConfirmation? = null) {
@@ -193,14 +199,18 @@ private fun runChat(
if (progressIndicator.value) return@launch
progressIndicator.value = true
try {
initChatController(dbKey, confirmMigrations)
SimplexApp.context.initChatController(dbKey, confirmMigrations)
} catch (e: Exception) {
Log.d(TAG, "initializeChat ${e.stackTraceToString()}")
}
progressIndicator.value = false
when (val status = chatDbStatus.value) {
is DBMigrationResult.OK -> {
platform.androidChatStartedAfterBeingOff()
SimplexService.cancelPassphraseNotification()
when (prefs.notificationsMode.get()) {
NotificationsMode.SERVICE.name -> CoroutineScope(Dispatchers.Default).launch { SimplexService.start() }
NotificationsMode.PERIODIC.name -> SimplexApp.context.schedulePeriodicWakeUp()
}
}
is DBMigrationResult.ErrorNotADatabase ->
AlertManager.shared.showAlertMsg(generalGetString(MR.strings.wrong_passphrase_title), generalGetString(MR.strings.enter_correct_passphrase))
@@ -218,11 +228,12 @@ private fun runChat(
}
private fun shouldShowRestoreDbButton(prefs: AppPreferences): Boolean {
val context = SimplexApp.context
val startedAt = prefs.encryptionStartedAt.get() ?: return false
/** Just in case there is any small difference between reported Java's [Clock.System.now] and Linux's time on a file */
val safeDiffInTime = 10_000L
val filesChat = File(dataDir.absolutePath + File.separator + "${chatDatabaseFileName}.bak")
val filesAgent = File(dataDir.absolutePath + File.separator + "${agentDatabaseFileName}.bak")
val filesChat = File(context.dataDir.absolutePath + File.separator + "files_chat.db.bak")
val filesAgent = File(context.dataDir.absolutePath + File.separator + "files_agent.db.bak")
return filesChat.exists() &&
filesAgent.exists() &&
startedAt.toEpochMilliseconds() - safeDiffInTime <= filesChat.lastModified() &&
@@ -230,8 +241,9 @@ private fun shouldShowRestoreDbButton(prefs: AppPreferences): Boolean {
}
private fun restoreDb(restoreDbFromBackup: MutableState<Boolean>, prefs: AppPreferences) {
val filesChatBase = dataDir.absolutePath + File.separator + chatDatabaseFileName
val filesAgentBase = dataDir.absolutePath + File.separator + agentDatabaseFileName
val context = SimplexApp.context
val filesChatBase = context.dataDir.absolutePath + File.separator + "files_chat.db"
val filesAgentBase = context.dataDir.absolutePath + File.separator + "files_agent.db"
try {
Files.copy(Path("$filesChatBase.bak"), Path(filesChatBase), StandardCopyOption.REPLACE_EXISTING)
Files.copy(Path("$filesAgentBase.bak"), Path(filesAgentBase), StandardCopyOption.REPLACE_EXISTING)

View File

@@ -1,11 +1,18 @@
package chat.simplex.common.views.database
package chat.simplex.app.views.database
import SectionBottomSpacer
import SectionDividerSpaced
import SectionTextFooter
import SectionItemView
import SectionView
import androidx.compose.desktop.ui.tooling.preview.Preview
import android.content.Context
import android.content.res.Configuration
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.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
@@ -14,21 +21,25 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
import androidx.compose.ui.text.*
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import chat.simplex.common.model.*
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.helpers.*
import chat.simplex.common.views.usersettings.*
import chat.simplex.common.platform.*
import androidx.fragment.app.FragmentActivity
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 chat.simplex.app.views.usersettings.*
import chat.simplex.res.MR
import kotlinx.coroutines.*
import kotlinx.datetime.*
import org.apache.commons.io.IOUtils
import java.io.*
import java.net.URI
import java.nio.file.Files
import java.text.SimpleDateFormat
import java.util.*
import kotlin.collections.ArrayList
@@ -38,6 +49,7 @@ fun DatabaseView(
m: ChatModel,
showSettingsModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit)
) {
val context = LocalContext.current
val progressIndicator = remember { mutableStateOf(false) }
val runChat = remember { m.chatRunning }
val prefs = m.controller.appPrefs
@@ -46,18 +58,11 @@ fun DatabaseView(
val chatArchiveTime = remember { mutableStateOf(prefs.chatArchiveTime.get()) }
val chatLastStart = remember { mutableStateOf(prefs.chatLastStart.get()) }
val chatArchiveFile = remember { mutableStateOf<String?>(null) }
val saveArchiveLauncher = rememberFileChooserLauncher(false) { to: URI? ->
val file = chatArchiveFile.value
if (file != null && to != null) {
copyFileToFile(File(file), to) {
chatArchiveFile.value = null
}
}
}
val appFilesCountAndSize = remember { mutableStateOf(directoryFileCountAndSize(appFilesDir.absolutePath)) }
val importArchiveLauncher = rememberFileChooserLauncher(true) { to: URI? ->
if (to != null) {
importArchiveAlert(m, to, appFilesCountAndSize, progressIndicator)
val saveArchiveLauncher = rememberSaveArchiveLauncher(chatArchiveFile)
val appFilesCountAndSize = remember { mutableStateOf(directoryFileCountAndSize(getAppFilesDirectory())) }
val importArchiveLauncher = rememberGetContentLauncher { uri: Uri? ->
if (uri != null) {
importArchiveAlert(m, uri, appFilesCountAndSize, progressIndicator)
}
}
LaunchedEffect(m.chatRunning) {
@@ -124,7 +129,7 @@ fun DatabaseLayout(
useKeyChain: Boolean,
chatDbEncrypted: Boolean?,
initialRandomDBPassphrase: SharedPreference<Boolean>,
importArchiveLauncher: FileChooserLauncher,
importArchiveLauncher: ManagedActivityResultLauncher<String, Uri?>,
chatArchiveName: MutableState<String?>,
chatArchiveTime: MutableState<Instant?>,
chatLastStart: MutableState<Instant?>,
@@ -199,7 +204,7 @@ fun DatabaseLayout(
SettingsActionItem(
painterResource(MR.images.ic_download),
stringResource(MR.strings.import_database),
{ withApi { importArchiveLauncher.launch("application/zip") }},
{ importArchiveLauncher.launch("application/zip") },
textColor = Color.Red,
iconColor = Color.Red,
disabled = operationsDisabled
@@ -352,16 +357,16 @@ private fun startChat(m: ChatModel, runChat: MutableState<Boolean?>, chatLastSta
withApi {
try {
if (chatDbChanged.value) {
initChatController()
SimplexApp.context.initChatController()
chatDbChanged.value = false
}
if (m.chatDbStatus.value !is DBMigrationResult.OK) {
/** Hide current view and show [DatabaseErrorView] */
ModalManager.closeAllModalsEverywhere()
ModalManager.shared.closeModals()
return@withApi
}
if (m.currentUser.value == null) {
ModalManager.closeAllModalsEverywhere()
ModalManager.shared.closeModals()
return@withApi
} else {
m.controller.apiStartChat()
@@ -371,7 +376,10 @@ private fun startChat(m: ChatModel, runChat: MutableState<Boolean?>, chatLastSta
val ts = Clock.System.now()
m.controller.appPrefs.chatLastStart.set(ts)
chatLastStart.value = ts
platform.androidChatStartedAfterBeingOff()
when (m.controller.appPrefs.notificationsMode.get()) {
NotificationsMode.SERVICE.name -> CoroutineScope(Dispatchers.Default).launch { SimplexService.start() }
NotificationsMode.PERIODIC.name -> SimplexApp.context.schedulePeriodicWakeUp()
}
} catch (e: Error) {
runChat.value = false
AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error_starting_chat), e.toString())
@@ -425,7 +433,8 @@ private fun stopChat(m: ChatModel, runChat: MutableState<Boolean?>) {
try {
runChat.value = false
stopChatAsync(m)
platform.androidChatStopped()
SimplexService.safeStopService(SimplexApp.context)
MessagesFetcherWorker.cancelAll()
} catch (e: Error) {
runChat.value = true
AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error_stopping_chat), e.toString())
@@ -450,14 +459,14 @@ private fun exportArchive(
chatArchiveName: MutableState<String?>,
chatArchiveTime: MutableState<Instant?>,
chatArchiveFile: MutableState<String?>,
saveArchiveLauncher: FileChooserLauncher
saveArchiveLauncher: ManagedActivityResultLauncher<String, Uri?>
) {
progressIndicator.value = true
withApi {
try {
val archiveFile = exportChatArchive(m, chatArchiveName, chatArchiveTime, chatArchiveFile)
chatArchiveFile.value = archiveFile
saveArchiveLauncher.launch(archiveFile.substringAfterLast(File.separator))
saveArchiveLauncher.launch(archiveFile.substringAfterLast("/"))
progressIndicator.value = false
} catch (e: Error) {
AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error_exporting_chat_database), e.toString())
@@ -475,8 +484,8 @@ private suspend fun exportChatArchive(
val archiveTime = Clock.System.now()
val ts = SimpleDateFormat("yyyy-MM-dd'T'HHmmss", Locale.US).format(Date.from(archiveTime.toJavaInstant()))
val archiveName = "simplex-chat.$ts.zip"
val archivePath = "${filesDir.absolutePath}${File.separator}$archiveName"
val config = ArchiveConfig(archivePath, parentTempDirectory = databaseExportDir.toString())
val archivePath = "${getFilesDirectory()}/$archiveName"
val config = ArchiveConfig(archivePath, parentTempDirectory = SimplexApp.context.cacheDir.toString())
m.controller.apiExportArchive(config)
deleteOldArchive(m)
m.controller.appPrefs.chatArchiveName.set(archiveName)
@@ -490,7 +499,7 @@ private suspend fun exportChatArchive(
private fun deleteOldArchive(m: ChatModel) {
val chatArchiveName = m.controller.appPrefs.chatArchiveName.get()
if (chatArchiveName != null) {
val file = File("${filesDir.absolutePath}${File.separator}$chatArchiveName")
val file = File("${getFilesDirectory()}/$chatArchiveName")
val fileDeleted = file.delete()
if (fileDeleted) {
m.controller.appPrefs.chatArchiveName.set(null)
@@ -501,9 +510,39 @@ private fun deleteOldArchive(m: ChatModel) {
}
}
@Composable
private fun rememberSaveArchiveLauncher(chatArchiveFile: MutableState<String?>): ManagedActivityResultLauncher<String, Uri?> =
rememberLauncherForActivityResult(
contract = ActivityResultContracts.CreateDocument(),
onResult = { destination ->
val cxt = SimplexApp.context
try {
destination?.let {
val filePath = chatArchiveFile.value
if (filePath != null) {
val contentResolver = SimplexApp.context.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()
}
}
} catch (e: Error) {
Toast.makeText(cxt, generalGetString(MR.strings.error_saving_file), Toast.LENGTH_SHORT).show()
Log.e(TAG, "rememberSaveArchiveLauncher error saving archive $e")
} finally {
chatArchiveFile.value = null
}
}
)
private fun importArchiveAlert(
m: ChatModel,
importedArchiveURI: URI,
importedArchiveUri: Uri,
appFilesCountAndSize: MutableState<Pair<Int, Long>>,
progressIndicator: MutableState<Boolean>
) {
@@ -511,28 +550,28 @@ private fun importArchiveAlert(
title = generalGetString(MR.strings.import_database_question),
text = generalGetString(MR.strings.your_current_chat_database_will_be_deleted_and_replaced_with_the_imported_one),
confirmText = generalGetString(MR.strings.import_database_confirmation),
onConfirm = { importArchive(m, importedArchiveURI, appFilesCountAndSize, progressIndicator) },
onConfirm = { importArchive(m, importedArchiveUri, appFilesCountAndSize, progressIndicator) },
destructive = true,
)
}
private fun importArchive(
m: ChatModel,
importedArchiveURI: URI,
importedArchiveUri: Uri,
appFilesCountAndSize: MutableState<Pair<Int, Long>>,
progressIndicator: MutableState<Boolean>
) {
progressIndicator.value = true
val archivePath = saveArchiveFromURI(importedArchiveURI)
val archivePath = saveArchiveFromUri(importedArchiveUri)
if (archivePath != null) {
withApi {
try {
m.controller.apiDeleteStorage()
try {
val config = ArchiveConfig(archivePath, parentTempDirectory = databaseExportDir.toString())
val config = ArchiveConfig(archivePath, parentTempDirectory = SimplexApp.context.cacheDir.toString())
val archiveErrors = m.controller.apiImportArchive(config)
DatabaseUtils.ksDatabasePassword.remove()
appFilesCountAndSize.value = directoryFileCountAndSize(appFilesDir.absolutePath)
appFilesCountAndSize.value = directoryFileCountAndSize(getAppFilesDirectory())
if (archiveErrors.isEmpty()) {
operationEnded(m, progressIndicator) {
AlertManager.shared.showAlertMsg(generalGetString(MR.strings.chat_database_imported), text = generalGetString(MR.strings.restart_the_app_to_use_imported_chat_database))
@@ -558,21 +597,21 @@ private fun importArchive(
}
}
private fun saveArchiveFromURI(importedArchiveURI: URI): String? {
private fun saveArchiveFromUri(importedArchiveUri: Uri): String? {
return try {
val inputStream = importedArchiveURI.inputStream()
val archiveName = getFileName(importedArchiveURI)
val inputStream = SimplexApp.context.contentResolver.openInputStream(importedArchiveUri)
val archiveName = getFileName(importedArchiveUri)
if (inputStream != null && archiveName != null) {
val archivePath = "$databaseExportDir${File.separator}$archiveName"
val archivePath = "${SimplexApp.context.cacheDir}/$archiveName"
val destFile = File(archivePath)
Files.copy(inputStream, destFile.toPath())
IOUtils.copy(inputStream, FileOutputStream(destFile))
archivePath
} else {
Log.e(TAG, "saveArchiveFromURI null inputStream")
Log.e(TAG, "saveArchiveFromUri null inputStream")
null
}
} catch (e: Exception) {
Log.e(TAG, "saveArchiveFromURI error: ${e.message}")
Log.e(TAG, "saveArchiveFromUri error: ${e.message}")
null
}
}
@@ -629,10 +668,10 @@ private fun setCiTTL(
private fun afterSetCiTTL(
m: ChatModel,
progressIndicator: MutableState<Boolean>,
appFilesCountAndSize: MutableState<Pair<Int, Long>>,
appFilesCountAndSize: MutableState<Pair<Int, Long>>
) {
progressIndicator.value = false
appFilesCountAndSize.value = directoryFileCountAndSize(appFilesDir.absolutePath)
appFilesCountAndSize.value = directoryFileCountAndSize(getAppFilesDirectory())
withApi {
try {
val chats = m.controller.apiGetChats()
@@ -655,7 +694,7 @@ private fun deleteFilesAndMediaAlert(appFilesCountAndSize: MutableState<Pair<Int
private fun deleteFiles(appFilesCountAndSize: MutableState<Pair<Int, Long>>) {
deleteAppFiles()
appFilesCountAndSize.value = directoryFileCountAndSize(appFilesDir.absolutePath)
appFilesCountAndSize.value = directoryFileCountAndSize(getAppFilesDirectory())
}
private fun operationEnded(m: ChatModel, progressIndicator: MutableState<Boolean>, alert: () -> Unit) {
@@ -664,11 +703,12 @@ private fun operationEnded(m: ChatModel, progressIndicator: MutableState<Boolean
alert.invoke()
}
@Preview/*(
@Preview(showBackground = true)
@Preview(
uiMode = Configuration.UI_MODE_NIGHT_YES,
showBackground = true,
name = "Dark Mode"
)*/
)
@Composable
fun PreviewDatabaseLayout() {
SimpleXTheme {
@@ -679,7 +719,7 @@ fun PreviewDatabaseLayout() {
useKeyChain = false,
chatDbEncrypted = false,
initialRandomDBPassphrase = SharedPreference({ true }, {}),
importArchiveLauncher = rememberFileChooserLauncher(true) {},
importArchiveLauncher = rememberGetContentLauncher {},
chatArchiveName = remember { mutableStateOf("dummy_archive") },
chatArchiveTime = remember { mutableStateOf(Clock.System.now()) },
chatLastStart = remember { mutableStateOf(Clock.System.now()) },

View File

@@ -1,5 +1,6 @@
package chat.simplex.common.views.helpers
package chat.simplex.app.views.helpers
import android.util.Log
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CornerSize
@@ -12,8 +13,10 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.*
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.*
import androidx.compose.ui.window.Dialog
import chat.simplex.app.R
import chat.simplex.app.TAG
import chat.simplex.app.ui.theme.*
import chat.simplex.res.MR
import dev.icerock.moko.resources.StringResource
@@ -51,7 +54,7 @@ class AlertManager {
buttons: @Composable () -> Unit,
) {
showAlert {
DefaultDialog(onDismissRequest = ::hideAlert) {
Dialog(onDismissRequest = this::hideAlert) {
Column(
Modifier
.background(MaterialTheme.colors.surface, RoundedCornerShape(corner = CornerSize(25.dp)))
@@ -208,4 +211,4 @@ private fun alertText(text: String?): (@Composable () -> Unit)? {
)
})
}
}
}

View File

@@ -1,4 +1,4 @@
package chat.simplex.common.views.helpers
package chat.simplex.app.views.helpers
import androidx.compose.animation.core.*

View File

@@ -1,6 +1,5 @@
package chat.simplex.common.views.helpers
package chat.simplex.app.views.helpers
import androidx.compose.desktop.ui.tooling.preview.Preview
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
@@ -13,11 +12,12 @@ import androidx.compose.ui.graphics.*
import androidx.compose.ui.layout.ContentScale
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.compose.ui.unit.dp
import chat.simplex.common.model.ChatInfo
import chat.simplex.common.platform.base64ToBitmap
import chat.simplex.common.ui.theme.SimpleXTheme
import chat.simplex.app.R
import chat.simplex.app.model.ChatInfo
import chat.simplex.app.ui.theme.SimpleXTheme
import chat.simplex.res.MR
import dev.icerock.moko.resources.ImageResource
@@ -25,7 +25,7 @@ import dev.icerock.moko.resources.ImageResource
fun ChatInfoImage(chatInfo: ChatInfo, size: Dp, iconColor: Color = MaterialTheme.colors.secondaryVariant) {
val icon =
if (chatInfo is ChatInfo.Group) MR.images.ic_supervised_user_circle_filled
else MR.images.ic_account_circle_filled
else MR.images.ic_account_circle_filled
ProfileImage(size, chatInfo.image, icon, iconColor)
}
@@ -70,7 +70,7 @@ fun ProfileImage(
)
}
} else {
val imageBitmap = base64ToBitmap(image)
val imageBitmap = base64ToBitmap(image).asImageBitmap()
Image(
imageBitmap,
stringResource(MR.strings.image_descr_profile_image),

View File

@@ -0,0 +1,62 @@
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()
}
}
}
}

View File

@@ -1,5 +1,6 @@
package chat.simplex.common.views.helpers
package chat.simplex.app.views.helpers
import android.content.res.Configuration
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.runtime.*
@@ -10,13 +11,13 @@ import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.desktop.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import chat.simplex.common.ui.theme.*
import chat.simplex.app.ui.theme.*
@Composable
fun CloseSheetBar(close: (() -> Unit)?, showClose: Boolean = true, endButtons: @Composable RowScope.() -> Unit = {}) {
fun CloseSheetBar(close: (() -> Unit)?, endButtons: @Composable RowScope.() -> Unit = {}) {
Column(
Modifier
.fillMaxWidth()
@@ -32,11 +33,7 @@ fun CloseSheetBar(close: (() -> Unit)?, showClose: Boolean = true, endButtons: @
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
if (showClose) {
NavigationButtonBack(onButtonClicked = close)
} else {
Spacer(Modifier)
}
NavigationButtonBack(onButtonClicked = close)
Row {
endButtons()
}
@@ -66,11 +63,12 @@ fun AppBarTitle(title: String, withPadding: Boolean = true, bottomPadding: Dp =
)
}
@Preview/*(
@Preview(showBackground = true)
@Preview(
uiMode = Configuration.UI_MODE_NIGHT_YES,
showBackground = true,
name = "Dark Mode"
)*/
)
@Composable
fun PreviewCloseSheetBar() {
SimpleXTheme {

Some files were not shown because too many files have changed in this diff Show More