diff --git a/ooniprobe.xcodeproj/project.pbxproj b/ooniprobe.xcodeproj/project.pbxproj index b8cc63cc..db260ce3 100644 --- a/ooniprobe.xcodeproj/project.pbxproj +++ b/ooniprobe.xcodeproj/project.pbxproj @@ -12,13 +12,18 @@ 17E7EDC021BFEE0C001961C7 /* SnapshotHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17E7EDBF21BFEE0C001961C7 /* SnapshotHelper.swift */; }; 526C702A25C99AB200C7A164 /* Colors.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 526C702925C99AB100C7A164 /* Colors.xcassets */; }; 58E18F9EBF4FAD4EFCE020F3 /* Pods_ooniprobe.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = BB32DF3D6FC174AA1AF76009 /* Pods_ooniprobe.framework */; }; - 793587D32B8E081600038F88 /* Utils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 793587D22B8E081600038F88 /* Utils.swift */; }; 793587BA2B852EDD00038F88 /* OoniRunViewUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 793587B92B852EDD00038F88 /* OoniRunViewUITests.swift */; }; + 793587D32B8E081600038F88 /* Utils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 793587D22B8E081600038F88 /* Utils.swift */; }; 7940AA8B28117E9000C0EB5D /* ShareViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7940AA8A28117E9000C0EB5D /* ShareViewController.swift */; }; 7940AA8E28117E9000C0EB5D /* MainInterface.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 7940AA8C28117E9000C0EB5D /* MainInterface.storyboard */; }; 7940AA9228117E9000C0EB5D /* share.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = 7940AA8828117E9000C0EB5D /* share.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 7945903D2C21BFB1008116BF /* OONIDescriptor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7945903C2C21BFB1008116BF /* OONIDescriptor.swift */; }; 79780FCF27E9F18E002A38B1 /* Languages.plist in Resources */ = {isa = PBXBuildFile; fileRef = 79780FCE27E9F18E002A38B1 /* Languages.plist */; }; + 79BC192B2C53AD890017B7EC /* InputTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79BC192A2C53AD890017B7EC /* InputTableViewCell.swift */; }; + 79BC192D2C53ADC20017B7EC /* NettestTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79BC192C2C53ADC20017B7EC /* NettestTableViewCell.swift */; }; + 79BC192F2C53B3D10017B7EC /* NettestStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79BC192E2C53B3D10017B7EC /* NettestStatus.swift */; }; + 79BC19312C53C24E0017B7EC /* DashboardTableViewController+Actions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79BC19302C53C24E0017B7EC /* DashboardTableViewController+Actions.swift */; }; + 79DB62342C2D8F020076FA0C /* TestOverviewViewController+TableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79DB62332C2D8F020076FA0C /* TestOverviewViewController+TableView.swift */; }; 7AED19812A6EC9A2003B265A /* libresolv.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = 7AED19802A6EC9A2003B265A /* libresolv.tbd */; }; 7AED19832A6EC9C7003B265A /* libresolv.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = 7AED19822A6EC9C7003B265A /* libresolv.tbd */; }; D4A2F5DF1A6C3244001B8460 /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = D4A2F5DE1A6C3244001B8460 /* main.m */; }; @@ -221,8 +226,8 @@ 17E7EDBF21BFEE0C001961C7 /* SnapshotHelper.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = SnapshotHelper.swift; path = fastlane/SnapshotHelper.swift; sourceTree = SOURCE_ROOT; }; 526C702925C99AB100C7A164 /* Colors.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Colors.xcassets; sourceTree = ""; }; 588219FACC9F793A15BDEA33 /* Pods_OONIProbeUnitTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_OONIProbeUnitTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; - 793587D22B8E081600038F88 /* Utils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Utils.swift; sourceTree = ""; }; 793587B92B852EDD00038F88 /* OoniRunViewUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OoniRunViewUITests.swift; sourceTree = ""; }; + 793587D22B8E081600038F88 /* Utils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Utils.swift; sourceTree = ""; }; 7940AA8828117E9000C0EB5D /* share.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = share.appex; sourceTree = BUILT_PRODUCTS_DIR; }; 7940AA8A28117E9000C0EB5D /* ShareViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareViewController.swift; sourceTree = ""; }; 7940AA8D28117E9000C0EB5D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/MainInterface.storyboard; sourceTree = ""; }; @@ -233,6 +238,11 @@ 79780FCE27E9F18E002A38B1 /* Languages.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Languages.plist; sourceTree = ""; }; 79AA093C2A86E44400C23E27 /* vi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = vi; path = vi.lproj/Localizable.strings; sourceTree = ""; }; 79AA093D2A86E47600C23E27 /* my */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = my; path = my.lproj/Localizable.strings; sourceTree = ""; }; + 79BC192A2C53AD890017B7EC /* InputTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InputTableViewCell.swift; sourceTree = ""; }; + 79BC192C2C53ADC20017B7EC /* NettestTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NettestTableViewCell.swift; sourceTree = ""; }; + 79BC192E2C53B3D10017B7EC /* NettestStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NettestStatus.swift; sourceTree = ""; }; + 79BC19302C53C24E0017B7EC /* DashboardTableViewController+Actions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DashboardTableViewController+Actions.swift"; sourceTree = ""; }; + 79DB62332C2D8F020076FA0C /* TestOverviewViewController+TableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TestOverviewViewController+TableView.swift"; sourceTree = ""; }; 7A8CB0932ADDDAC1005AB2BC /* libcrypto.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = libcrypto.xcframework; path = Pods/libcrypto/libcrypto.xcframework; sourceTree = ""; }; 7A8CB0942ADDDAC1005AB2BC /* libevent.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = libevent.xcframework; path = Pods/libevent/libevent.xcframework; sourceTree = ""; }; 7A8CB0952ADDDAC1005AB2BC /* libssl.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = libssl.xcframework; path = Pods/libssl/libssl.xcframework; sourceTree = ""; }; @@ -606,6 +616,16 @@ path = share; sourceTree = ""; }; + 79BC19292C53AD470017B7EC /* Rows */ = { + isa = PBXGroup; + children = ( + 79BC192A2C53AD890017B7EC /* InputTableViewCell.swift */, + 79BC192C2C53ADC20017B7EC /* NettestTableViewCell.swift */, + 79BC192E2C53B3D10017B7EC /* NettestStatus.swift */, + ); + path = Rows; + sourceTree = ""; + }; A226141514CB239FFA382514 /* Pods */ = { isa = PBXGroup; children = ( @@ -714,12 +734,14 @@ ED0D8D6D201866D8003DDF23 /* RunTest */ = { isa = PBXGroup; children = ( + 79BC19292C53AD470017B7EC /* Rows */, ED1CC80020159D970041089A /* TestRunningViewController.h */, ED1CC80120159D970041089A /* TestRunningViewController.m */, ED0D8D6E20186742003DDF23 /* TestOverviewViewController.h */, ED0D8D6F20186742003DDF23 /* TestOverviewViewController.m */, ED21D090269C93B900BB09D8 /* ProgressViewController.h */, ED21D091269C93B900BB09D8 /* ProgressViewController.m */, + 79DB62332C2D8F020076FA0C /* TestOverviewViewController+TableView.swift */, ); path = RunTest; sourceTree = ""; @@ -747,6 +769,7 @@ ED0A61FF200B52A300235E70 /* OONIRun */, EDDB0B561FFF32B900EFD9C8 /* Settings */, EDDB0B631FFF6C8B00EFD9C8 /* Onboarding */, + 79BC19302C53C24E0017B7EC /* DashboardTableViewController+Actions.swift */, ); path = View; sourceTree = ""; @@ -1585,6 +1608,7 @@ EDBBB2DF203D2EF200B84F6F /* TestResultTableViewCell.m in Sources */, ED90A92D2198DE5100204B46 /* Options.m in Sources */, ED4D1E5D24629A870087B36D /* OONIApi.m in Sources */, + 79BC19312C53C24E0017B7EC /* DashboardTableViewController+Actions.swift in Sources */, D4A2F5DF1A6C3244001B8460 /* main.m in Sources */, ED5F8A2A1F77C4AA0093C8F5 /* VersionUtility.m in Sources */, ED4F77F5265E3C5A00C28AC0 /* ProxySettings.m in Sources */, @@ -1612,11 +1636,13 @@ ED41D8B82089E9C500543191 /* WebsitesDetailsViewController.m in Sources */, ED3AE9F020341C8600DAAF36 /* ResultsHeaderViewController.m in Sources */, EDDB0B761FFF6C8B00EFD9C8 /* Onboarding3ViewController.m in Sources */, + 79BC192F2C53B3D10017B7EC /* NettestStatus.swift in Sources */, EDF4ED20248A8B50001A5406 /* EventResult.m in Sources */, ED4D1E60246315EF0087B36D /* BasicTableViewCell.m in Sources */, EDF4ED31248FBFEC001A5406 /* CircumventionDetailsViewController.m in Sources */, EDB3D64926203DC000724ECF /* ExperimentalSuite.m in Sources */, EDB33B7E21838824002CEC34 /* CustomURLViewController.m in Sources */, + 79BC192B2C53AD890017B7EC /* InputTableViewCell.swift in Sources */, ED14B2342465411E009AA906 /* Psiphon.m in Sources */, ED2E067121D4867B00E9B9EE /* NdtTest.m in Sources */, ED9CCEF7252B3298004F8420 /* OONISessionConfig.m in Sources */, @@ -1637,6 +1663,7 @@ ED2E066F21D4867B00E9B9EE /* HttpInvalidRequestLine.m in Sources */, ED4DF7DA2609F0ED00521C5B /* BackgroundTask.m in Sources */, ED4A00B526494F140083C8F6 /* ProxyViewController.m in Sources */, + 79BC192D2C53ADC20017B7EC /* NettestTableViewCell.swift in Sources */, ED9CCEE8252B30D3004F8420 /* OONIContext.m in Sources */, ED21D092269C93B900BB09D8 /* ProgressViewController.m in Sources */, ED58CF7E1FEBBE2800E3C415 /* TestUtility.m in Sources */, @@ -1648,6 +1675,7 @@ EDA9F21E224255E7003D40E8 /* TestSummaryViewController.m in Sources */, EDFE073D1F375E3C00960AF2 /* DictionaryUtility.m in Sources */, ED2E066D21D4867B00E9B9EE /* WebConnectivity.m in Sources */, + 79DB62342C2D8F020076FA0C /* TestOverviewViewController+TableView.swift in Sources */, EDF4ED27248A9A64001A5406 /* Simple.m in Sources */, EDF4ECF4248549BD001A5406 /* Engine.m in Sources */, ED58CF7A1FEBB9A900E3C415 /* DashboardTableViewController.m in Sources */, diff --git a/ooniprobe/OONIDescriptor.swift b/ooniprobe/OONIDescriptor.swift index a96065cf..df858521 100644 --- a/ooniprobe/OONIDescriptor.swift +++ b/ooniprobe/OONIDescriptor.swift @@ -78,7 +78,7 @@ public class Nettest: NSObject { /// The class also provides a method to get the OONI descriptors for the OONI dashboard. /// The class also provides a method to get the test suite for the current descriptor. @objc(OONIDescriptor) -public class OONIDescriptor: NSObject { +public class OONIDescriptor: NSObject,Identifiable { // MARK: Initializers init(name: String, @@ -192,7 +192,17 @@ public class OONIDescriptor: NSObject { name: "experimental", title: NSLocalizedString("Test.Experimental.Fullname", comment: ""), shortDescription: NSLocalizedString("Dashboard.Experimental.Card.Description", comment: ""), - longDescription: NSLocalizedString("Dashboard.Experimental.Overview.Paragraph", comment: ""), + longDescription: String.localizedStringWithFormat( + NSLocalizedString("Dashboard.Experimental.Overview.Paragraph", comment: ""), + """ + \n- [STUN Reachability](https://github.com/ooni/spec/blob/master/nettests/ts-025-stun-reachability.md) + \n- [DNS Check](https://github.com/ooni/spec/blob/master/nettests/ts-028-dnscheck.md) + \n- [RiseupVPN](https://ooni.org/nettest/riseupvpn/) + \n- [ECH Check](https://github.com/ooni/spec/blob/master/nettests/ts-039-echcheck.md) + \(String(format: "%@ (%@)", "\n- [Tor Snowflake](https://ooni.org/nettest/tor-snowflake/)", NSLocalizedString("Settings.TestOptions.LongRunningTest", comment: ""))) + \(String(format: "%@ (%@)", "\n- [Vanilla Tor](https://github.com/ooni/spec/blob/master/nettests/ts-016-vanilla-tor.md)", NSLocalizedString("Settings.TestOptions.LongRunningTest", comment: ""))) + """ + ) , icon: "experimental", color: UIColor(named: "color_indigo1")!, animation: "experimental", diff --git a/ooniprobe/Storyboards/Dashboard.storyboard b/ooniprobe/Storyboards/Dashboard.storyboard index 741d4dc6..8937e3a7 100644 --- a/ooniprobe/Storyboards/Dashboard.storyboard +++ b/ooniprobe/Storyboards/Dashboard.storyboard @@ -1,10 +1,11 @@ - + - + + @@ -524,6 +525,10 @@ + + + + + + + + - - - - - - - - - - - - @@ -574,6 +571,7 @@ + @@ -584,7 +582,7 @@ - + @@ -685,8 +683,7 @@ - - + @@ -722,5 +719,8 @@ + + + diff --git a/ooniprobe/View/DashboardTableViewController+Actions.swift b/ooniprobe/View/DashboardTableViewController+Actions.swift new file mode 100644 index 00000000..da5374f4 --- /dev/null +++ b/ooniprobe/View/DashboardTableViewController+Actions.swift @@ -0,0 +1,300 @@ +import Foundation +import SwiftUI + +extension DashboardTableViewController { + @objc @IBAction private func runAll() { + + if let descriptorList = self.items as NSArray as? [OONIDescriptor]{ + + let hostingController = UIHostingController( + rootView: ModalView( + descriptors: descriptorList.map { descriptor in OONIDescriptorStatus(descriptor: descriptor) }, + runTests: { descriptors in + if(TestUtility.checkConnectivity(self) && TestUtility.checkTestRunning(self)){ + RunningTest.current().setAndRun( + NSMutableArray(array: descriptors.filter{ descriptor in + descriptor.isSelected + }.map{ descriptor in + descriptor.getTestSuites() + }), + inView: self + ) + } + self.dismiss(animated: true, completion: nil) + + } + ) + ) + + hostingController.modalPresentationStyle = .formSheet + present(hostingController, animated: true, completion: nil) + } + + } +} + +// MARK: - OONIDescriptorStatus + +/// A struct that represents the status of an OONIDescriptor. +class OONIDescriptorStatus : ObservableObject { + var descriptor: OONIDescriptor + @Published var nettests: [NettestStatus] + @Published var isSelected: Bool = false + @Published var isExpanded: Bool = true + + init(descriptor: OONIDescriptor) { + self.descriptor = descriptor + self.nettests = descriptor.nettest.map { nettest in NettestStatus(nettest: nettest) } + } + + + @objc public func getTestSuites() -> Any { + descriptor.nettest = nettests.filter{ nettest in + nettest.isSelected + }.map{ nettest in + nettest.nettest + } + return DynamicTestSuite(descriptor: descriptor) + } +} + + +struct ModalView: View { + + @State var descriptors: [OONIDescriptorStatus] + var runTests: (([OONIDescriptorStatus]) -> Void) // Event listener closure + + var body: some View { + VStack(alignment: .leading,spacing: 10){ + Text("Select the tests to run") + Button(action: { + toggleStatusForAll(true) + }, label: { + Text("Select all tests") + + .padding(.all,10) + .foregroundColor(Color("color_blue5")) + .overlay( + RoundedRectangle(cornerRadius: 32) + .stroke(Color("color_blue5"), lineWidth: 2) + ) + }) + .cornerRadius(32) + Button(action: { + toggleStatusForAll(false) + }, label: { + Text("Deselect all tests") + + .padding(.all,10) + .foregroundColor(Color("color_blue5")) + .overlay( + RoundedRectangle(cornerRadius: 32) + .stroke(Color("color_blue5"), lineWidth: 2) + ) + }) + .cornerRadius(32) + + RunTestsUITableViewWrapper( + descriptors: $descriptors, + didSelectRow: { indexPath in + + }) + HStack { + Spacer() + + Button(action: { + runTests(descriptors) + }, label: { + Text("Run test") + + .padding(.all,10) + .foregroundColor(Color("color_white")) + .background(Color("color_blue5")) + .overlay( + RoundedRectangle(cornerRadius: 32) + .stroke(Color("color_blue5"), lineWidth: 2) + ) + }) + .cornerRadius(32) + Spacer() + + } + + } + .padding() + } + + func toggleStatusForAll(_ newState: Bool) { + descriptors.forEach({ descriptor in + descriptor.isSelected = newState + descriptor.nettests.forEach({ nettest in + nettest.isSelected = newState + }) + }) + + descriptors = descriptors + } +} + + +// MARK: - RunTestsUITableViewWrapper + +/// A SwiftUI view that wraps a UITableView. +struct RunTestsUITableViewWrapper: UIViewRepresentable { + @Binding var descriptors: [OONIDescriptorStatus] + var didSelectRow: ((IndexPath) -> Void) // Event listener closure + + + func makeUIView(context: Context) -> UITableView { + let tableView = UITableView() + tableView.dataSource = context.coordinator + tableView.delegate = context.coordinator + tableView.register(DescriptorTableViewCell.self, forCellReuseIdentifier: "descriptor_cell") + tableView.register(NettestTableViewCell.self, forCellReuseIdentifier: "nettests_cell") + tableView.register(InputTableViewCell.self, forCellReuseIdentifier: "inputs_cell") + return tableView + } + + func updateUIView(_ uiView: UITableView, context: Context) { + uiView.reloadData() + } + + func makeCoordinator() -> Coordinator { + Coordinator(self) + } + + /// A class that conforms to the UITableViewDataSource and UITableViewDelegate protocols. + class Coordinator: NSObject, UITableViewDataSource, UITableViewDelegate { + var parent: RunTestsUITableViewWrapper + + init(_ parent: RunTestsUITableViewWrapper) { + self.parent = parent + } + + func numberOfSections(in tableView: UITableView) -> Int { + parent.descriptors.count + } + + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + let section = parent.descriptors[section] + if section.isExpanded { + return section.nettests.count + 1 + } else { + return 1 // Return 1 if the section is not expanded (only the section header) + } + } + + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + + + if indexPath.row == 0 { + let cell = tableView.dequeueReusableCell(withIdentifier: "descriptor_cell") as! DescriptorTableViewCell + + cell.configure( + with: parent.descriptors[indexPath.section], + onToggleChange: { [weak self] newValue in + self?.parent.descriptors[indexPath.section].isSelected = newValue + self?.parent.didSelectRow(indexPath) + tableView.reloadData() + } + ) + return cell + } else { + let cell = tableView.dequeueReusableCell(withIdentifier: "nettests_cell") as! NettestTableViewCell + + let inputs = parent.descriptors[indexPath.section].nettests[indexPath.row - 1] + cell.configure( + with: inputs, + isChild: true, + onToggleChange: { [weak self] newValue in + self?.parent.descriptors[indexPath.section].nettests[indexPath.row - 1].isSelected = newValue + + self?.parent.didSelectRow(indexPath) + tableView.reloadData() + } + ) + + return cell + } + } + + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + tableView.deselectRow(at: indexPath, animated: true) + + if indexPath.row == 0{ + parent.descriptors[indexPath.section].isExpanded = !parent.descriptors[indexPath.section].isExpanded + } + + UIView.transition( + with: tableView, + duration: 0.35, + options: .transitionCrossDissolve, + animations: { + tableView.reloadData() + } + ) + + } + } +} + + + +// MARK: - Descriptor views and TableCell + +/// A SwiftUI view that represents a section in the table view. +struct DescriptorTableCell: View { + var item: OONIDescriptorStatus + @Binding var isSelected: Bool + + var body: some View { + HStack { + Text(LocalizationUtility.getNameForTest(item.descriptor.title)) + .font(.custom("FiraSans-Regular", size: 14.0)) + .foregroundColor(Color("color_gray9")) + .lineLimit(1) + .layoutPriority(1) + Image(systemName: item.isExpanded ? "chevron.up" : "chevron.down") + Toggle(isOn: $isSelected) {}.toggleStyle(iOSCheckboxToggleStyle()) + } + } +} + +/// A UITableViewCell subclass that displays a section in the table view. +class DescriptorTableViewCell: UITableViewCell { + private var hostingController: UIHostingController? + + /// Configures the cell with the specified data. + /// - Parameters: + /// - data: The NettestStatus object. + /// - onToggleChange: A closure that is called when the toggle is changed. + func configure(with data: OONIDescriptorStatus, onToggleChange: @escaping (Bool) -> Void) { + // Create a binding to pass the data to the SwiftUI view + let binding = Binding( + get: { data.isSelected }, + set: { newValue in + data.isSelected = newValue + onToggleChange(newValue) + } + ) + + let toggleCellView = DescriptorTableCell(item: data, isSelected: binding) + + if let hostingController = hostingController { + hostingController.rootView = toggleCellView + } else { + hostingController = UIHostingController(rootView: toggleCellView) + if let hostingView = hostingController?.view { + hostingView.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(hostingView) + NSLayoutConstraint.activate([ + hostingView.topAnchor.constraint(equalTo: contentView.topAnchor), + hostingView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), + hostingView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + hostingView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + ]) + } + } + } +} diff --git a/ooniprobe/View/RunTest/Rows/InputTableViewCell.swift b/ooniprobe/View/RunTest/Rows/InputTableViewCell.swift new file mode 100644 index 00000000..ba2d2593 --- /dev/null +++ b/ooniprobe/View/RunTest/Rows/InputTableViewCell.swift @@ -0,0 +1,45 @@ +import SwiftUI + +// MARK: - Input views and TableCell + +/// A SwiftUI view that represents an input in the table view. +struct InputTableView: View { + var item: String + + var body: some View { + HStack { + Text(item) + .font(.custom("FiraSans-Regular", size: 14.0)) + .foregroundColor(Color("color_gray9")) + Spacer() + } + } +} + +/// A UITableViewCell subclass that displays an input in the table view. +class InputTableViewCell: UITableViewCell { + private var hostingController: UIHostingController? + + /// Configures the cell with the specified data. + /// - Parameter data: The input string. + func configure(with data: String) { + + let toggleCellView = InputTableView(item:data) + + if let hostingController = hostingController { + hostingController.rootView = toggleCellView + } else { + hostingController = UIHostingController(rootView: toggleCellView) + if let hostingView = hostingController?.view { + hostingView.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(hostingView) + NSLayoutConstraint.activate([ + hostingView.topAnchor.constraint(equalTo: contentView.topAnchor), + hostingView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), + hostingView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + hostingView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + ]) + } + } + } +} diff --git a/ooniprobe/View/RunTest/Rows/NettestStatus.swift b/ooniprobe/View/RunTest/Rows/NettestStatus.swift new file mode 100644 index 00000000..865133ad --- /dev/null +++ b/ooniprobe/View/RunTest/Rows/NettestStatus.swift @@ -0,0 +1,12 @@ +// MARK: - NettestStatus + +/// A struct that represents the status of a Nettest. +class NettestStatus : ObservableObject { + var nettest: Nettest + @Published var isSelected: Bool = false + @Published var isExpanded: Bool = false + + init(nettest: Nettest) { + self.nettest = nettest + } +} diff --git a/ooniprobe/View/RunTest/Rows/NettestTableViewCell.swift b/ooniprobe/View/RunTest/Rows/NettestTableViewCell.swift new file mode 100644 index 00000000..93be9c52 --- /dev/null +++ b/ooniprobe/View/RunTest/Rows/NettestTableViewCell.swift @@ -0,0 +1,83 @@ +import SwiftUI + +/// A SwiftUI toggle style that uses a checkbox. +struct iOSCheckboxToggleStyle: ToggleStyle { + func makeBody(configuration: Configuration) -> some View { + Button(action: { + configuration.isOn.toggle() + }, label: { + HStack { + configuration.label + Spacer() + Image(systemName: configuration.isOn ? "checkmark.square" : "square") + .padding() + } + }).foregroundColor(.black) + } +} + +// MARK: - Nettests views and TableCell + +/// A SwiftUI view that represents a section in the table view. +struct NettestTableCell: View { + var item: NettestStatus + var isChild: Bool + @Binding var isSelected: Bool + + var body: some View { + HStack { + Text(LocalizationUtility.getNameForTest(item.nettest.name)) + .font(.custom("FiraSans-Regular", size: 14.0)) + .foregroundColor(Color("color_gray9")) + .lineLimit(1) + .layoutPriority(1) + .padding(EdgeInsets(top: 0, leading: isChild ? 16 : 0, bottom: 0, trailing: 0)) + if(!isChild) { + if let inputs = item.nettest.inputs, !inputs.isEmpty { + Image(systemName: item.isExpanded ? "chevron.up" : "chevron.down") + } else { + Spacer() + } + } + Toggle(isOn: $isSelected) {}.toggleStyle(iOSCheckboxToggleStyle()) + } + } +} + +/// A UITableViewCell subclass that displays a section in the table view. +class NettestTableViewCell: UITableViewCell { + private var hostingController: UIHostingController? + + /// Configures the cell with the specified data. + /// - Parameters: + /// - data: The NettestStatus object. + /// - onToggleChange: A closure that is called when the toggle is changed. + func configure(with data: NettestStatus, isChild: Bool = false, onToggleChange: @escaping (Bool) -> Void) { + // Create a binding to pass the data to the SwiftUI view + let binding = Binding( + get: { data.isSelected }, + set: { newValue in + data.isSelected = newValue + onToggleChange(newValue) + } + ) + + let toggleCellView = NettestTableCell(item: data, isChild: isChild, isSelected: binding) + + if let hostingController = hostingController { + hostingController.rootView = toggleCellView + } else { + hostingController = UIHostingController(rootView: toggleCellView) + if let hostingView = hostingController?.view { + hostingView.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(hostingView) + NSLayoutConstraint.activate([ + hostingView.topAnchor.constraint(equalTo: contentView.topAnchor), + hostingView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), + hostingView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + hostingView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + ]) + } + } + } +} diff --git a/ooniprobe/View/RunTest/TestOverviewViewController+TableView.swift b/ooniprobe/View/RunTest/TestOverviewViewController+TableView.swift new file mode 100644 index 00000000..8296c400 --- /dev/null +++ b/ooniprobe/View/RunTest/TestOverviewViewController+TableView.swift @@ -0,0 +1,186 @@ +import Foundation +import SwiftUI + + +extension TestOverviewViewController { + @objc private func setupDescriptorViews() { + + guard let descriptor = self.descriptor as? OONIDescriptor else { + return + } + + let contentView = OverviewContentView( + descriptor: descriptor, + nettests: descriptor.nettest.map { nettest in NettestStatus(nettest: nettest) } + ) + + let hostingController = UIHostingController(rootView: contentView) + + addChild(hostingController) + + if let hostingView = hostingController.view { + hostingView.translatesAutoresizingMaskIntoConstraints = false + self.scrollView.addSubview(hostingView) + NSLayoutConstraint.activate([ + hostingView.leadingAnchor.constraint(equalTo: self.scrollView.leadingAnchor, constant: 20), + hostingView.trailingAnchor.constraint(equalTo: self.scrollView.trailingAnchor, constant: -20), + hostingView.topAnchor.constraint(equalTo: self.scrollView.topAnchor), + ]) + } + + } +} + +struct OverviewContentView: View { + let descriptor:OONIDescriptor + @State var runTestsAutomatically:Bool = false + + @State var installUpdatesAutomatically:Bool = false + + @State var nettests: [NettestStatus] + + var body: some View { + + let runTestsAutomaticallyBinding = Binding( + get: { self.runTestsAutomatically }, + set: { + runTestsAutomaticallyChanged($0) + } + ) + + VStack(alignment: .leading, spacing: 8) { + Text(LocalizedStringKey(descriptor.longDescription)) + .padding(.top) + .font(.custom("FiraSans-Regular", size: 14.0)) + .foregroundColor(Color("color_gray9")) + Text("Test Settings") + .bold() + .padding(.top) + .padding(.bottom) + Toggle("Install updates automatically", isOn: $installUpdatesAutomatically) + Toggle("Run tests automatically", isOn: runTestsAutomaticallyBinding).toggleStyle(iOSCheckboxToggleStyle()) + UITableViewWrapper( + nettests: $nettests, + didSelectRow: { indexPath in + let allEnabled = nettests.allSatisfy({ nettest in + nettest.isSelected + }) + runTestsAutomatically = allEnabled + }) + .padding(.bottom) + .frame(height: 1000) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + + func runTestsAutomaticallyChanged(_ newState: Bool) { + nettests.forEach({ nettest in + // TODO: save to database + nettest.isSelected = newState + }) + self.runTestsAutomatically = newState + } +} + +// MARK: - UITableViewWrapper + +/// A SwiftUI view that wraps a UITableView. +struct UITableViewWrapper: UIViewRepresentable { + @Binding var nettests: [NettestStatus] + var didSelectRow: ((IndexPath) -> Void) // Event listener closure + + + func makeUIView(context: Context) -> UITableView { + let tableView = UITableView() + tableView.dataSource = context.coordinator + tableView.delegate = context.coordinator + tableView.register(NettestTableViewCell.self, forCellReuseIdentifier: "nettests_cell") + tableView.register(InputTableViewCell.self, forCellReuseIdentifier: "inputs_cell") + return tableView + } + + func updateUIView(_ uiView: UITableView, context: Context) { + uiView.reloadData() + } + + func makeCoordinator() -> Coordinator { + Coordinator(self) + } + + /// A class that conforms to the UITableViewDataSource and UITableViewDelegate protocols. + class Coordinator: NSObject, UITableViewDataSource, UITableViewDelegate { + var parent: UITableViewWrapper + + init(_ parent: UITableViewWrapper) { + self.parent = parent + } + + func numberOfSections(in tableView: UITableView) -> Int { + parent.nettests.count + } + + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + + let section = parent.nettests[section] + if section.isExpanded { + + if let inputs = section.nettest.inputs, !inputs.isEmpty { // Check if the section(`nettest`) has inputs + return inputs.count + 1 // Return the number of inputs plus 1 (for the section header) + } else { + return 1 // Return 1 if there are no inputs (only the section header) + } + } else { + return 1 // Return 1 if the section is not expanded (only the section header) + } + } + + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + if indexPath.row == 0 { + let cell = tableView.dequeueReusableCell(withIdentifier: "nettests_cell") as! NettestTableViewCell + + cell.configure( + with: parent.nettests[indexPath.section], + onToggleChange: { [weak self] newValue in + //TODO: Save preference change to database + // Update the isSelected property of the NettestStatus object for the current section to the new value of the toggle. + self?.parent.nettests[indexPath.section].isSelected = newValue + // Invoke the didSelectRow closure with the selected indexPath + self?.parent.didSelectRow(indexPath) + tableView.reloadData() + } + ) + return cell + } else { + let cell = tableView.dequeueReusableCell(withIdentifier: "inputs_cell") as! InputTableViewCell + + if let inputs = parent.nettests[indexPath.section].nettest.inputs, !inputs.isEmpty { + + cell.configure(with: inputs[indexPath.row - 1]) + return cell + } else { + return cell + } + + } + } + + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + tableView.deselectRow(at: indexPath, animated: true) + + if indexPath.row == 0{ + parent.nettests[indexPath.section].isExpanded = !parent.nettests[indexPath.section].isExpanded + } + + UIView.transition( + with: tableView, + duration: 0.35, + options: .transitionCrossDissolve, + animations: { + tableView.reloadData() + } + ) + + } + } +} diff --git a/ooniprobe/View/RunTest/TestOverviewViewController.h b/ooniprobe/View/RunTest/TestOverviewViewController.h index adbaa793..eb807219 100644 --- a/ooniprobe/View/RunTest/TestOverviewViewController.h +++ b/ooniprobe/View/RunTest/TestOverviewViewController.h @@ -12,8 +12,12 @@ UIColor *defaultColor; } ++ (void)setupDescriptorViews; + @property (nonatomic, strong) id descriptor; +@property (weak, nonatomic) IBOutlet UIScrollView *scrollView; +@property (weak, nonatomic) IBOutlet UITableView *tableView; @property (strong, nonatomic) IBOutlet UIImageView *testImage; @property (strong, nonatomic) IBOutlet UILabel *testNameLabel; @property (strong, nonatomic) IBOutlet ConfigureButton *websitesButton; @@ -25,6 +29,5 @@ @property (strong, nonatomic) IBOutlet RHMarkdownLabel *testDescriptionLabel; @property (strong, nonatomic) IBOutlet UIView *backgroundView; @property (strong, nonatomic) IBOutlet NSLayoutConstraint *tableFooterConstraint; -@property (strong, nonatomic) IBOutlet UIScrollView *scrollView; @end diff --git a/ooniprobe/View/RunTest/TestOverviewViewController.m b/ooniprobe/View/RunTest/TestOverviewViewController.m index 36f78813..8db4546b 100644 --- a/ooniprobe/View/RunTest/TestOverviewViewController.m +++ b/ooniprobe/View/RunTest/TestOverviewViewController.m @@ -17,26 +17,9 @@ - (void)viewDidLoad { [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(changeConstraints) name:@"networkTestEndedUI" object:nil]; [self.testNameLabel setText:[LocalizationUtility getNameForTest:[descriptor performSelector:@selector(name)]]]; - NSString *testLongDesc = [LocalizationUtility getLongDescriptionForTest:[descriptor performSelector:@selector(name)]]; - [self.testDescriptionLabel setFont:[UIFont fontWithName:@"FiraSans-Regular" size:14]]; - [self.testDescriptionLabel setTextColor:[UIColor colorNamed:@"color_gray9"]]; NSMutableDictionary *linkAttributes = [NSMutableDictionary dictionary]; linkAttributes[(NSString *) kCTUnderlineStyleAttributeName] = @YES; linkAttributes[(NSString *) kCTForegroundColorAttributeName] = [UIColor colorNamed:@"color_base"]; - self.testDescriptionLabel.linkAttributes = [NSDictionary dictionaryWithDictionary:linkAttributes]; - [self.testDescriptionLabel setMarkdown:testLongDesc]; - if ([[UIDevice currentDevice].systemVersion floatValue] >= 9.0) { - if ([UIView userInterfaceLayoutDirectionForSemanticContentAttribute:self.view.semanticContentAttribute] == UIUserInterfaceLayoutDirectionRightToLeft) { - self.testDescriptionLabel.textAlignment = NSTextAlignmentRight; - } - } else { - if ([NSLocale characterDirectionForLanguage:[NSLocale preferredLanguages][0]] == NSLocaleLanguageDirectionRightToLeft) { - self.testDescriptionLabel.textAlignment = NSTextAlignmentRight; - } - } - [self.testDescriptionLabel setDidSelectLinkWithURLBlock:^(RHMarkdownLabel *label, NSURL *url) { - [[UIApplication sharedApplication] openURL:url]; - }]; [self.runButton setTitle:[NSString stringWithFormat:@"%@", NSLocalizedString(@"Dashboard.Overview.Run", nil)] forState:UIControlStateNormal]; if ([[descriptor performSelector:@selector(name)] isEqualToString:@"websites"]) [self.websitesButton setTitle:[NSString stringWithFormat:@"%@", NSLocalizedString(@"Dashboard.Overview.ChooseWebsites", nil)] forState:UIControlStateNormal]; @@ -50,8 +33,12 @@ - (void)viewDidLoad { [self.backgroundView setBackgroundColor:defaultColor]; [NavigationBarUtility setNavigationBar:self.navigationController.navigationBar color:defaultColor]; self.navigationController.navigationBar.topItem.title = @""; + + [self setupDescriptorViews]; } +- (void)setupDescriptorViews{} + -(void)viewWillAppear:(BOOL)animated{ [super viewWillAppear:animated]; [NavigationBarUtility setBarTintColor:self.navigationController.navigationBar @@ -63,13 +50,11 @@ -(void)changeConstraints{ dispatch_async(dispatch_get_main_queue(), ^{ if ([RunningTest currentTest].isTestRunning){ self.tableFooterConstraint.constant = 64; - [self.scrollView setNeedsUpdateConstraints]; } else { //If this number is > 0 there are still test running if ([[RunningTest currentTest].testSuites count] == 0){ self.tableFooterConstraint.constant = 0; - [self.scrollView setNeedsUpdateConstraints]; } } }); diff --git a/ooniprobe/ooniprobe-Bridging-Header.h b/ooniprobe/ooniprobe-Bridging-Header.h index 99a55785..1ac62cbd 100644 --- a/ooniprobe/ooniprobe-Bridging-Header.h +++ b/ooniprobe/ooniprobe-Bridging-Header.h @@ -7,3 +7,5 @@ #import "AbstractSuite.h" #import "Tests.h" #import "UIView+Toast.h" +#import "LocalizationUtility.h" +#import "RunningTest.h"