SubscriptionAddView.swift 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308
  1. import SwiftUI
  2. import AVFoundation
  3. struct SubscriptionAddView: View {
  4. private let tag = "SubscriptionAddView"
  5. @Binding var isShowing: Bool
  6. @EnvironmentObject private var store: Store
  7. @State private var topic: String = ""
  8. @State private var useAnother: Bool = false
  9. @State private var baseUrl: String = ""
  10. @State private var showLogin: Bool = false
  11. @State private var username: String = ""
  12. @State private var password: String = ""
  13. @State private var loading = false
  14. @State private var addError: String?
  15. @State private var loginError: String?
  16. @State private var hasCameraPermission: Bool = false
  17. private var subscriptionManager: SubscriptionManager {
  18. return SubscriptionManager(store: store)
  19. }
  20. var body: some View {
  21. NavigationView {
  22. VStack {
  23. addView
  24. // TODO: hide this if permission not granted
  25. QRScannerUIView { code in
  26. onQRCodeScanned(text: code)
  27. }
  28. .frame(height: 250) // You can adjust the height as needed.
  29. .padding()
  30. .onAppear(perform: checkCameraPermission)
  31. }
  32. // This is a little weird, but it works. The nagivation link for the login view
  33. // is rendered in the background (it's hidden), abd we toggle it manually.
  34. // If anyone has a better way to do a two-page layout let me know.
  35. .background(Group {
  36. NavigationLink(
  37. destination: loginView,
  38. isActive: $showLogin
  39. ) {
  40. EmptyView()
  41. }
  42. })
  43. }
  44. }
  45. private var addView: some View {
  46. VStack(alignment: .leading, spacing: 0) {
  47. Form {
  48. Section(
  49. footer: Text("Topics may not be password-protected, so choose a name that's not easy to guess. Once subscribed, you can PUT/POST notifications")
  50. ) {
  51. TextField("Topic name, e.g. phil_alerts", text: $topic)
  52. .disableAutocapitalization()
  53. .disableAutocorrection(true)
  54. }
  55. Section(
  56. footer:
  57. (useAnother) ? Text("To ensure instant delivery from your self-hosted server, be sure to set upstream-base-url in your server's config, otherwise messages may arrive with significant delay.") : Text("")
  58. ) {
  59. Toggle("Use another server", isOn: $useAnother)
  60. if useAnother {
  61. TextField("Service URL, e.g. https://ntfy.home.io", text: $baseUrl)
  62. .disableAutocapitalization()
  63. .disableAutocorrection(true)
  64. }
  65. }
  66. }
  67. if let error = addError {
  68. ErrorView(error: error)
  69. }
  70. }
  71. .navigationTitle("Add subscription")
  72. .navigationBarTitleDisplayMode(.inline)
  73. .toolbar {
  74. ToolbarItem(placement: .navigationBarLeading) {
  75. Button(action: cancelAction) {
  76. Text("Cancel")
  77. }
  78. }
  79. ToolbarItem(placement: .navigationBarTrailing) {
  80. Button(action: subscribeOrShowLoginAction) {
  81. VStack {
  82. if loading {
  83. ProgressView()
  84. .progressViewStyle(CircularProgressViewStyle())
  85. } else {
  86. Text("Subscribe")
  87. }
  88. }
  89. .fixedSize(horizontal: true, vertical: false)
  90. }
  91. .disabled(!isAddViewValid())
  92. }
  93. }
  94. }
  95. private var loginView: some View {
  96. VStack(alignment: .leading, spacing: 0) {
  97. Form {
  98. Section(
  99. footer: Text("This topic requires that you log in with username and password. The user will be stored on your device, and will be re-used for other topics.")
  100. ) {
  101. TextField("Username", text: $username)
  102. .disableAutocapitalization()
  103. .disableAutocorrection(true)
  104. SecureField("Password", text: $password)
  105. }
  106. }
  107. if let error = loginError {
  108. ErrorView(error: error)
  109. }
  110. }
  111. .navigationTitle("Login required")
  112. .navigationBarTitleDisplayMode(.inline)
  113. .toolbar {
  114. ToolbarItem(placement: .navigationBarTrailing) {
  115. Button(action: subscribeWithUserAction) {
  116. if loading {
  117. ProgressView()
  118. .progressViewStyle(CircularProgressViewStyle())
  119. } else {
  120. Text("Subscribe")
  121. }
  122. }
  123. .disabled(!isLoginViewValid())
  124. }
  125. }
  126. }
  127. private var sanitizedTopic: String {
  128. return topic.trimmingCharacters(in: .whitespaces)
  129. }
  130. private func checkCameraPermission() {
  131. switch AVCaptureDevice.authorizationStatus(for: .video) {
  132. case .authorized:
  133. self.hasCameraPermission = true
  134. case .notDetermined:
  135. AVCaptureDevice.requestAccess(for: .video) { granted in
  136. DispatchQueue.main.async {
  137. self.hasCameraPermission = granted
  138. if !granted {
  139. self.hasCameraPermission = false
  140. }
  141. }
  142. }
  143. case .denied, .restricted:
  144. self.hasCameraPermission = false
  145. @unknown default:
  146. break
  147. }
  148. }
  149. func onQRCodeScanned(text: String){
  150. // Check if the text is a valid URL with HTTP or HTTPS scheme
  151. guard let url = URL(string: text), let scheme = url.scheme, ["http", "https"].contains(scheme) else {
  152. return
  153. }
  154. // Extract the base URL without the path
  155. var components = URLComponents(url: url, resolvingAgainstBaseURL: false)
  156. components?.path = ""
  157. guard let foundBaseUrl = components?.url else {
  158. return
  159. }
  160. // Extract the route from the original URL
  161. baseUrl = foundBaseUrl.absoluteString
  162. useAnother = baseUrl != store.getDefaultBaseUrl()
  163. topic = url.path
  164. if (topic.hasPrefix("/")) {
  165. topic.removeFirst()
  166. }
  167. print("------> \(baseUrl) : \(topic) : \(useAnother)")
  168. subscribeOrShowLoginAction()
  169. }
  170. private func isAddViewValid() -> Bool {
  171. if sanitizedTopic.isEmpty {
  172. return false
  173. } else if sanitizedTopic.range(of: "^[-_A-Za-z0-9]{1,64}$", options: .regularExpression, range: nil, locale: nil) == nil {
  174. return false
  175. } else if selectedBaseUrl.range(of: "^https?://.+", options: .regularExpression, range: nil, locale: nil) == nil {
  176. return false
  177. } else if store.getSubscription(baseUrl: selectedBaseUrl, topic: topic) != nil {
  178. return false
  179. }
  180. return true
  181. }
  182. private func isLoginViewValid() -> Bool {
  183. if username.isEmpty || password.isEmpty {
  184. return false
  185. }
  186. return true
  187. }
  188. private func subscribeOrShowLoginAction() {
  189. loading = true
  190. addError = nil
  191. let user = store.getUser(baseUrl: selectedBaseUrl)?.toBasicUser()
  192. ApiService.shared.checkAuth(baseUrl: selectedBaseUrl, topic: topic, user: user) { result in
  193. switch result {
  194. case .Success:
  195. DispatchQueue.global(qos: .background).async {
  196. subscriptionManager.subscribe(baseUrl: selectedBaseUrl, topic: sanitizedTopic)
  197. resetAndHide()
  198. }
  199. // Do not reset "loading", because resetAndHide() will do that after everything is done
  200. case .Unauthorized:
  201. if let user = user {
  202. addError = "User \(user.username) is not authorized to read this topic"
  203. } else {
  204. addError = nil // Reset
  205. showLogin = true
  206. }
  207. loading = false
  208. case .Error(let err):
  209. addError = err
  210. loading = false
  211. }
  212. }
  213. }
  214. private func subscribeWithUserAction() {
  215. loading = true
  216. loginError = nil
  217. let user = BasicUser(username: username, password: password)
  218. ApiService.shared.checkAuth(baseUrl: selectedBaseUrl, topic: topic, user: user) { result in
  219. switch result {
  220. case .Success:
  221. DispatchQueue.global(qos: .background).async {
  222. store.saveUser(baseUrl: selectedBaseUrl, username: username, password: password)
  223. subscriptionManager.subscribe(baseUrl: selectedBaseUrl, topic: sanitizedTopic)
  224. resetAndHide()
  225. }
  226. // Do not reset "loading", because resetAndHide() will do that after everything is done
  227. case .Unauthorized:
  228. loginError = "Invalid credentials, or user \(username) is not authorized to read this topic"
  229. loading = false
  230. case .Error(let err):
  231. loginError = err
  232. loading = false
  233. }
  234. }
  235. }
  236. private func cancelAction() {
  237. resetAndHide()
  238. }
  239. private var selectedBaseUrl: String {
  240. return (useAnother) ? baseUrl : store.getDefaultBaseUrl()
  241. }
  242. private func resetAndHide() {
  243. isShowing = false
  244. DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) {
  245. // Hide first and then reset, otherwise we'll see the text fields change
  246. addError = nil
  247. loginError = nil
  248. loading = false
  249. baseUrl = ""
  250. topic = ""
  251. useAnother = false
  252. }
  253. }
  254. }
  255. struct ErrorView: View {
  256. var error: String
  257. var body: some View {
  258. HStack {
  259. Image(systemName: "exclamationmark.triangle.fill")
  260. .foregroundColor(.red)
  261. .font(.title2)
  262. Text(error)
  263. .font(.subheadline)
  264. }
  265. .padding([.leading, .trailing], 20)
  266. .padding([.top, .bottom], 10)
  267. }
  268. }
  269. struct SubscriptionAddView_Previews: PreviewProvider {
  270. @State static var isShowing = true
  271. static var previews: some View {
  272. let store = Store.preview
  273. SubscriptionAddView(isShowing: $isShowing)
  274. .environmentObject(store)
  275. }
  276. }