diff --git a/ooniprobe.xcodeproj/project.pbxproj b/ooniprobe.xcodeproj/project.pbxproj index 3ee01452..db260ce3 100644 --- a/ooniprobe.xcodeproj/project.pbxproj +++ b/ooniprobe.xcodeproj/project.pbxproj @@ -22,6 +22,7 @@ 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 */; }; @@ -240,6 +241,7 @@ 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 = ""; }; @@ -767,6 +769,7 @@ ED0A61FF200B52A300235E70 /* OONIRun */, EDDB0B561FFF32B900EFD9C8 /* Settings */, EDDB0B631FFF6C8B00EFD9C8 /* Onboarding */, + 79BC19302C53C24E0017B7EC /* DashboardTableViewController+Actions.swift */, ); path = View; sourceTree = ""; @@ -1605,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 */, diff --git a/ooniprobe/OONIDescriptor.swift b/ooniprobe/OONIDescriptor.swift index 98d23a7d..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, 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 index 828ace86..ba2d2593 100644 --- a/ooniprobe/View/RunTest/Rows/InputTableViewCell.swift +++ b/ooniprobe/View/RunTest/Rows/InputTableViewCell.swift @@ -8,7 +8,9 @@ struct InputTableView: View { var body: some View { HStack { - Text(item).font(.callout) + Text(item) + .font(.custom("FiraSans-Regular", size: 14.0)) + .foregroundColor(Color("color_gray9")) Spacer() } } diff --git a/ooniprobe/View/RunTest/Rows/NettestTableViewCell.swift b/ooniprobe/View/RunTest/Rows/NettestTableViewCell.swift index da6a7839..93be9c52 100644 --- a/ooniprobe/View/RunTest/Rows/NettestTableViewCell.swift +++ b/ooniprobe/View/RunTest/Rows/NettestTableViewCell.swift @@ -21,18 +21,23 @@ struct iOSCheckboxToggleStyle: ToggleStyle { /// 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(.callout) + .font(.custom("FiraSans-Regular", size: 14.0)) + .foregroundColor(Color("color_gray9")) .lineLimit(1) .layoutPriority(1) - if let inputs = item.nettest.inputs, !inputs.isEmpty { - Image(systemName: item.isExpanded ? "chevron.up" : "chevron.down") - } else { - Spacer() + .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()) } @@ -47,7 +52,7 @@ class NettestTableViewCell: UITableViewCell { /// - Parameters: /// - data: The NettestStatus object. /// - onToggleChange: A closure that is called when the toggle is changed. - func configure(with data: NettestStatus, onToggleChange: @escaping (Bool) -> Void) { + 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 }, @@ -57,7 +62,7 @@ class NettestTableViewCell: UITableViewCell { } ) - let toggleCellView = NettestTableCell(item: data, isSelected: binding) + let toggleCellView = NettestTableCell(item: data, isChild: isChild, isSelected: binding) if let hostingController = hostingController { hostingController.rootView = toggleCellView diff --git a/ooniprobe/ooniprobe-Bridging-Header.h b/ooniprobe/ooniprobe-Bridging-Header.h index 8d2eb529..1ac62cbd 100644 --- a/ooniprobe/ooniprobe-Bridging-Header.h +++ b/ooniprobe/ooniprobe-Bridging-Header.h @@ -8,3 +8,4 @@ #import "Tests.h" #import "UIView+Toast.h" #import "LocalizationUtility.h" +#import "RunningTest.h"