diff --git a/.gitignore b/.gitignore index 2e9d231..ce0c66c 100644 --- a/.gitignore +++ b/.gitignore @@ -22,6 +22,7 @@ xcuserdata/ *.moved-aside *.xccheckout *.xcscmblueprint +*.DS_Store ## Obj-C/Swift specific *.hmap diff --git a/Android/.gitignore b/Android/.gitignore deleted file mode 100644 index 70acd67..0000000 --- a/Android/.gitignore +++ /dev/null @@ -1,7 +0,0 @@ -*.iml -.gradle -/local.properties -/.idea -/build -/captures -.externalNativeBuild diff --git a/Android/app/.gitignore b/Android/app/.gitignore deleted file mode 100644 index 81cda7b..0000000 --- a/Android/app/.gitignore +++ /dev/null @@ -1,4 +0,0 @@ -/build -/src/main/jniLibs -/src/main/swift/.build -/src/main/swift/Package.resolved \ No newline at end of file diff --git a/Android/app/build.gradle b/Android/app/build.gradle deleted file mode 100644 index 78595eb..0000000 --- a/Android/app/build.gradle +++ /dev/null @@ -1,35 +0,0 @@ -apply plugin: 'com.android.application' - -apply plugin: 'kotlin-android' - -apply plugin: 'kotlin-android-extensions' - -apply plugin: 'net.zhuoweizhang.swiftandroid' - -android { - compileSdkVersion 28 - defaultConfig { - applicationId "com.millertech.bluetoothexplorer" - minSdkVersion 21 - targetSdkVersion 28 - versionCode 1 - versionName "1.0" - testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner" - } - buildTypes { - release { - minifyEnabled false - proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' - } - } -} - -dependencies { - implementation fileTree(dir: 'libs', include: ['*.jar']) - implementation"org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" - - implementation 'com.github.PureSwift:SwiftAndroidSupport:0.2.4' - testImplementation 'junit:junit:4.12' - androidTestImplementation 'com.android.support.test:runner:1.0.2' - androidTestImplementation 'com.android.support.test.espresso:espresso-core:3.0.2' -} diff --git a/Android/app/src/main/java/com/millertech/bluetoothexplorer/Application.kt b/Android/app/src/main/java/com/millertech/bluetoothexplorer/Application.kt deleted file mode 100644 index ea86e6a..0000000 --- a/Android/app/src/main/java/com/millertech/bluetoothexplorer/Application.kt +++ /dev/null @@ -1,13 +0,0 @@ -package com.millertech.bluetoothexplorer - -import org.pureswift.swiftandroidsupport.app.SwiftApplication - -class Application: SwiftApplication() { - - companion object { - - init { - System.loadLibrary("BluetoothExplorer") - } - } -} \ No newline at end of file diff --git a/Android/app/src/main/java/com/millertech/bluetoothexplorer/MainActivity.kt b/Android/app/src/main/java/com/millertech/bluetoothexplorer/MainActivity.kt deleted file mode 100644 index 0664887..0000000 --- a/Android/app/src/main/java/com/millertech/bluetoothexplorer/MainActivity.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.millertech.bluetoothexplorer - -import org.pureswift.swiftandroidsupport.app.SwiftAppCompatActivity - -class MainActivity : SwiftAppCompatActivity() diff --git a/Android/app/src/main/java/com/millertech/bluetoothexplorer/swiftbindings/MainActivityBindings.java b/Android/app/src/main/java/com/millertech/bluetoothexplorer/swiftbindings/MainActivityBindings.java deleted file mode 100644 index ceec0ee..0000000 --- a/Android/app/src/main/java/com/millertech/bluetoothexplorer/swiftbindings/MainActivityBindings.java +++ /dev/null @@ -1,16 +0,0 @@ -package com.millertech.bluetoothexplorer.swiftbindings; - -public interface MainActivityBindings { - - // Messages from Java to Swift - interface Listener { - - void hi(); - - } - - // Messages from Swift back to Java Activity - interface Responder{ - - } -} diff --git a/Android/app/src/main/java/org/swiftjava/com_millertech/MainActivityBindings_ListenerProxy.java b/Android/app/src/main/java/org/swiftjava/com_millertech/MainActivityBindings_ListenerProxy.java deleted file mode 100644 index 5392ae8..0000000 --- a/Android/app/src/main/java/org/swiftjava/com_millertech/MainActivityBindings_ListenerProxy.java +++ /dev/null @@ -1,32 +0,0 @@ - -/// generated by: genswift.java 'java/lang|java/util|java/sql' 'Sources' '../java' /// - -/// interface com.millertech.bluetoothexplorer.swiftbindings.MainActivityBindings$Listener /// - -package org.swiftjava.com_millertech; - -@SuppressWarnings("JniMissingFunction") -public class MainActivityBindings_ListenerProxy implements com.millertech.bluetoothexplorer.swiftbindings.MainActivityBindings.Listener { - - // address of proxy object - long __swiftObject; - - MainActivityBindings_ListenerProxy( long __swiftObject ) { - this.__swiftObject = __swiftObject; - } - - /// public abstract void com.millertech.bluetoothexplorer.swiftbindings.MainActivityBindings$Listener.hi() - - public native void __hi( long __swiftObject ); - - public void hi() { - __hi( __swiftObject ); - } - - public native void __finalize( long __swiftObject ); - - public void finalize() { - __finalize( __swiftObject ); - } - -} diff --git a/Android/app/src/main/res/drawable-v24/ic_launcher_foreground.xml b/Android/app/src/main/res/drawable-v24/ic_launcher_foreground.xml deleted file mode 100644 index c7bd21d..0000000 --- a/Android/app/src/main/res/drawable-v24/ic_launcher_foreground.xml +++ /dev/null @@ -1,34 +0,0 @@ - - - - - - - - - - - diff --git a/Android/app/src/main/res/drawable/ic_arrow_back.xml b/Android/app/src/main/res/drawable/ic_arrow_back.xml deleted file mode 100644 index d426473..0000000 --- a/Android/app/src/main/res/drawable/ic_arrow_back.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - diff --git a/Android/app/src/main/res/drawable/ic_bluetooth.xml b/Android/app/src/main/res/drawable/ic_bluetooth.xml deleted file mode 100644 index cb32298..0000000 --- a/Android/app/src/main/res/drawable/ic_bluetooth.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/Android/app/src/main/res/layout/list_cell.xml b/Android/app/src/main/res/layout/list_cell.xml deleted file mode 100644 index 9c176dd..0000000 --- a/Android/app/src/main/res/layout/list_cell.xml +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/Android/app/src/main/res/layout/list_header.xml b/Android/app/src/main/res/layout/list_header.xml deleted file mode 100644 index 0877334..0000000 --- a/Android/app/src/main/res/layout/list_header.xml +++ /dev/null @@ -1,19 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/Android/app/src/main/res/layout/peripheral_item.xml b/Android/app/src/main/res/layout/peripheral_item.xml deleted file mode 100644 index 835b636..0000000 --- a/Android/app/src/main/res/layout/peripheral_item.xml +++ /dev/null @@ -1,50 +0,0 @@ - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/Android/app/src/main/res/layout/peripheral_item_2.xml b/Android/app/src/main/res/layout/peripheral_item_2.xml deleted file mode 100644 index c155d47..0000000 --- a/Android/app/src/main/res/layout/peripheral_item_2.xml +++ /dev/null @@ -1,43 +0,0 @@ - - - - - - - - - - - - - \ No newline at end of file diff --git a/Android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/Android/app/src/main/res/mipmap-hdpi/ic_launcher.png deleted file mode 100755 index 93690f9..0000000 Binary files a/Android/app/src/main/res/mipmap-hdpi/ic_launcher.png and /dev/null differ diff --git a/Android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/Android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png deleted file mode 100755 index 6e2ae02..0000000 Binary files a/Android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png and /dev/null differ diff --git a/Android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/Android/app/src/main/res/mipmap-mdpi/ic_launcher.png deleted file mode 100755 index a039e07..0000000 Binary files a/Android/app/src/main/res/mipmap-mdpi/ic_launcher.png and /dev/null differ diff --git a/Android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/Android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png deleted file mode 100755 index e52115f..0000000 Binary files a/Android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png and /dev/null differ diff --git a/Android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/Android/app/src/main/res/mipmap-xhdpi/ic_launcher.png deleted file mode 100755 index 15dea2a..0000000 Binary files a/Android/app/src/main/res/mipmap-xhdpi/ic_launcher.png and /dev/null differ diff --git a/Android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/Android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png deleted file mode 100755 index a9a8610..0000000 Binary files a/Android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png and /dev/null differ diff --git a/Android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/Android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png deleted file mode 100755 index 2175529..0000000 Binary files a/Android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png and /dev/null differ diff --git a/Android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/Android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png deleted file mode 100755 index e4fd6c1..0000000 Binary files a/Android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png and /dev/null differ diff --git a/Android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/Android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png deleted file mode 100755 index bb63a5e..0000000 Binary files a/Android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png and /dev/null differ diff --git a/Android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/Android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png deleted file mode 100755 index 4a6e4a8..0000000 Binary files a/Android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png and /dev/null differ diff --git a/Android/app/src/main/res/values/colors.xml b/Android/app/src/main/res/values/colors.xml deleted file mode 100644 index 3ab3e9c..0000000 --- a/Android/app/src/main/res/values/colors.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - #3F51B5 - #303F9F - #FF4081 - diff --git a/Android/app/src/main/res/values/strings.xml b/Android/app/src/main/res/values/strings.xml deleted file mode 100644 index 1c0f157..0000000 --- a/Android/app/src/main/res/values/strings.xml +++ /dev/null @@ -1,3 +0,0 @@ - - Bluetooth Explorer - diff --git a/Android/app/src/main/res/values/styles.xml b/Android/app/src/main/res/values/styles.xml deleted file mode 100644 index c34b820..0000000 --- a/Android/app/src/main/res/values/styles.xml +++ /dev/null @@ -1,37 +0,0 @@ - - - - - - - - - - - - - - diff --git a/Android/app/src/main/swift/Package.resolved b/Android/app/src/main/swift/Package.resolved deleted file mode 100644 index 67e1c7c..0000000 --- a/Android/app/src/main/swift/Package.resolved +++ /dev/null @@ -1,97 +0,0 @@ -{ - "object": { - "pins": [ - { - "package": "Android", - "repositoryURL": "https://github.com/PureSwift/Android.git", - "state": { - "branch": "master", - "revision": "937413a8f7f741466850eb792f4f162378bb7b65", - "version": null - } - }, - { - "package": "AndroidBluetooth", - "repositoryURL": "https://github.com/PureSwift/AndroidBluetooth.git", - "state": { - "branch": "master", - "revision": "fcc0c03c440cf6855b95936b7260497cbbbb532a", - "version": null - } - }, - { - "package": "AndroidUIKit", - "repositoryURL": "https://github.com/PureSwift/AndroidUIKit.git", - "state": { - "branch": "master", - "revision": "d23b4fe083d0a0f1f1767cb46e747efd69a8b96f", - "version": null - } - }, - { - "package": "Bluetooth", - "repositoryURL": "https://github.com/PureSwift/Bluetooth.git", - "state": { - "branch": "master", - "revision": "b03524c7a0c7eca048133a9df9dc4bbe727b38e0", - "version": null - } - }, - { - "package": "CJavaVM", - "repositoryURL": "https://github.com/PureSwift/CJavaVM.git", - "state": { - "branch": "master", - "revision": "3b19893aa50a724c467d341c482087c30323b190", - "version": null - } - }, - { - "package": "GATT", - "repositoryURL": "https://github.com/PureSwift/GATT.git", - "state": { - "branch": "master", - "revision": "955a19b2030e08666154f2ee52d7955f56bb6ff5", - "version": null - } - }, - { - "package": "java_lang", - "repositoryURL": "https://github.com/PureSwift/java_lang.git", - "state": { - "branch": "master", - "revision": "e308632de721a79119b4a76e3b62faf5893d4cad", - "version": null - } - }, - { - "package": "java_swift", - "repositoryURL": "https://github.com/PureSwift/java_swift.git", - "state": { - "branch": "master", - "revision": "65deecc81042d0839a7496450109cafd2a0e1741", - "version": null - } - }, - { - "package": "java_util", - "repositoryURL": "https://github.com/PureSwift/java_util.git", - "state": { - "branch": "master", - "revision": "5cad067437a2567ffcf0ba9ea1c2f19d8b414d61", - "version": null - } - }, - { - "package": "JNI", - "repositoryURL": "https://github.com/PureSwift/JNI.git", - "state": { - "branch": "master", - "revision": "29239efbdd3370dc17f06f9df5380ecd2997370c", - "version": null - } - } - ] - }, - "version": 1 -} diff --git a/Android/app/src/main/swift/Package.swift b/Android/app/src/main/swift/Package.swift deleted file mode 100644 index 4469a59..0000000 --- a/Android/app/src/main/swift/Package.swift +++ /dev/null @@ -1,33 +0,0 @@ -// swift-tools-version:5.0 -import PackageDescription - -let package = Package( - name: "BluetoothExplorer", - products: [ - .library( - name: "BluetoothExplorer", - type: .dynamic, - targets: ["BluetoothExplorerAndroid"] - ) - ], - dependencies: [ - .package( - url: "https://github.com/PureSwift/AndroidBluetooth.git", - .branch("master") - ), - .package( - url: "https://github.com/PureSwift/AndroidUIKit.git", - .branch("master") - ) - ], - targets: [ - .target( - name: "BluetoothExplorerAndroid", - dependencies: [ - "AndroidBluetooth", - "AndroidUIKit" - ], - path: "Sources" - ) - ] -) diff --git a/Android/app/src/main/swift/Sources/ActivityIndicatorViewController.swift b/Android/app/src/main/swift/Sources/ActivityIndicatorViewController.swift deleted file mode 100755 index dec5903..0000000 --- a/Android/app/src/main/swift/Sources/ActivityIndicatorViewController.swift +++ /dev/null @@ -1,151 +0,0 @@ -// -// ActivityIndicatorViewController.swift -// BluetoothExplorer -// -// Created by Alsey Coleman Miller on 6/19/18. -// Copyright © 2018 PureSwift. All rights reserved. -// - -import Foundation - -#if os(iOS) -import UIKit -#elseif os(Android) || os(macOS) -import Android -import AndroidUIKit -#endif - -protocol ActivityIndicatorViewController: class { - - var view: UIView! { get } - - var navigationItem: UINavigationItem { get } - - var navigationController: UINavigationController? { get } - - func showActivity() - - func hideActivity(animated: Bool) -} - -extension ActivityIndicatorViewController { - - func performActivity (showActivity: Bool = true, - _ asyncOperation: @escaping () throws -> T, - completion: ((Self, T) -> ())? = nil) { - - if showActivity { self.showActivity() } - - async { - - do { - - let value = try asyncOperation() - - mainQueue { [weak self] in - - guard let controller = self - else { return } - - if showActivity { controller.hideActivity(animated: true) } - - // success - completion?(controller, value) - } - } - - catch { - - mainQueue { [weak self] in - - guard let controller = self as? (UIViewController & ActivityIndicatorViewController) - else { return } - - if showActivity { controller.hideActivity(animated: false) } - - // show error - - log("⚠️ Error: \(error)") - - if (controller as UIViewController).view.window != nil { - - controller.showErrorAlert(error.localizedDescription) - - } else { - - NativeAppDelegate.shared.window?.rootViewController?.showErrorAlert(error.localizedDescription) - } - } - } - } - } -} - -protocol TableViewActivityIndicatorViewController: ActivityIndicatorViewController { - - var tableView: UITableView! { get } - - var refreshControl: UIRefreshControl? { get } - - #if os(iOS) - var activityIndicator: UIActivityIndicatorView { get } - #elseif os(Android) || os(macOS) - var progressDialog: AndroidProgressDialog { get } - #endif -} - -extension TableViewActivityIndicatorViewController { - - func showActivity() { - - self.view.isUserInteractionEnabled = false - - if refreshControl?.isRefreshing ?? false { - - // refresh control animating - } else { - - #if os(iOS) - activityIndicator.startAnimating() - #else - progressDialog.show() - #endif - } - } - - func hideActivity(animated: Bool = true) { - - self.view.isUserInteractionEnabled = true - - if refreshControl?.isRefreshing ?? false { - - refreshControl?.endRefreshing() - } else { - #if os(iOS) - activityIndicator.stopAnimating() - #else - progressDialog.dismiss() - #endif - - } - } -} - -internal extension ActivityIndicatorViewController { - - #if os(iOS) - func loadActivityIndicatorView() -> UIActivityIndicatorView { - - let activityIndicator = UIActivityIndicatorView(activityIndicatorStyle: .gray) - activityIndicator.frame.origin = CGPoint(x: 6.5, y: 15) - - let view = UIView(frame: CGRect(x: 0, y: 0, width: 33, height: 44)) - view.backgroundColor = .clear - view.addSubview(activityIndicator) - - let barButtonItem = UIBarButtonItem(customView: view) - self.navigationItem.rightBarButtonItem = barButtonItem - return activityIndicator - } - #endif -} diff --git a/Android/app/src/main/swift/Sources/AndroidTableViewCell.swift b/Android/app/src/main/swift/Sources/AndroidTableViewCell.swift deleted file mode 100644 index b49d554..0000000 --- a/Android/app/src/main/swift/Sources/AndroidTableViewCell.swift +++ /dev/null @@ -1,59 +0,0 @@ -// -// AndroidTableViewCell.swift -// BluetoothExplorer -// -// Created by Alsey Coleman Miller on 4/30/19. -// Copyright © 2019 PureSwift. All rights reserved. -// - -import Foundation - -#if os(Android) || os(macOS) - -import Foundation -import Android -import AndroidUIKit -import java_lang - -protocol AndroidTableViewCell: ReusableTableViewCell { - - static var layout: String { get } - - func awakeFromLayout() -} - -extension AndroidTableViewCell { - - func awakeFromLayout() { } -} - -extension AndroidTableViewCell where Self: AndroidUIKit.UITableViewCell { - - private func inflate() { - - let layout = Self.layout - - if self.layoutName != layout { - inflateAndroidLayout(layout) - awakeFromLayout() - } - } - - func view(for identifier: String) -> Android.View.View? { - - inflate() - let activity = UIApplication.shared.androidActivity - let androidIdentifier = activity.getIdentifier(name: identifier, type: "id") - return androidView.findViewById(androidIdentifier) - } - - subscript (identifier: String) -> Android.View.View { - - guard let view = self.view(for: identifier) - else { fatalError("No view for \(identifier)") } - - return view - } -} - -#endif diff --git a/Android/app/src/main/swift/Sources/AppDelegate.swift b/Android/app/src/main/swift/Sources/AppDelegate.swift deleted file mode 100644 index 44921a2..0000000 --- a/Android/app/src/main/swift/Sources/AppDelegate.swift +++ /dev/null @@ -1,239 +0,0 @@ -// -// AppDelegate.swift -// BluetoothExplorer -// -// Created by Alsey Coleman Miller on 9/7/18. -// Copyright © 2018 PureSwift. All rights reserved. -// - -import Foundation - -#if os(iOS) -import UIKit -#elseif os(Android) || os(macOS) -import Android -import AndroidUIKit -#endif - -import Bluetooth -import GATT - -class AppDelegate: UIResponder, UIApplicationDelegate { - - final var window: UIWindow? - - func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool { - - #if os(iOS) - log("Launching Bluetooth Explorer v\(AppVersion) Build \(AppBuild)") - #elseif os(Android) - log("Launching Bluetooth Explorer") - #endif - - #if os(Android) || os(macOS) - NSLog("UIScreen scale: \(UIScreen.main.scale)") - NSLog("UIScreen native scale: \(UIScreen.main.nativeScale)") - NSLog("UIScreen size: \(UIScreen.main.bounds.size)") - NSLog("UIScreen native size: \(UIScreen.main.nativeBounds.size)") - #endif - - // initalize BLE - NativeCentral.shared.log = { log("Central: \($0)") } - - // load window and view controller - let viewController = CentralViewController() - - // setup UI theme - #if os(iOS) - configureAppearance() - #endif - - let navigationController = UINavigationController(rootViewController: viewController) - - self.window = UIWindow(frame: UIScreen.main.bounds) - self.window?.rootViewController = navigationController - self.window?.makeKeyAndVisible() - - return true - } - - func applicationWillResignActive(_ application: UIApplication) { - - // Sent when the application is about to move from active to inactive state. This can occur for certain types of temporary interruptions (such as an incoming phone call or SMS message) or when the user quits the application and it begins the transition to the background state. - // Use this method to pause ongoing tasks, disable timers, and invalidate graphics rendering callbacks. Games should use this method to pause the game. - - NSLog("\(#function)") - } - - func applicationDidEnterBackground(_ application: UIApplication) { - // Use this method to release shared resources, save user data, invalidate timers, and store enough application state information to restore your application to its current state in case it is terminated later. - // If your application supports background execution, this method is called instead of applicationWillTerminate: when the user quits. - - NSLog("\(#function)") - } - - func applicationWillEnterForeground(_ application: UIApplication) { - // Called as part of the transition from the background to the active state; here you can undo many of the changes made on entering the background. - - NSLog("\(#function)") - } - - func applicationDidBecomeActive(_ application: UIApplication) { - // Restart any tasks that were paused (or not yet started) while the application was inactive. If the application was previously in the background, optionally refresh the user interface. - - NSLog("\(#function)") - } - - func applicationWillTerminate(_ application: UIApplication) { - // Called when the application is about to terminate. Save data if appropriate. See also applicationDidEnterBackground:. - - NSLog("\(#function)") - } -} - -// MARK: - iOS - -#if os(iOS) - -typealias NativeAppDelegate = iOSAppDelegate - -@UIApplicationMain -final class iOSAppDelegate: AppDelegate { - - static var shared: iOSAppDelegate { return UIApplication.shared.delegate as! iOSAppDelegate } -} - -// MARK: - Android - -#elseif os(Android) || os(macOS) - -typealias NativeAppDelegate = AndroidAppDelegate - -final class AndroidAppDelegate: AppDelegate { - - static var shared: AndroidAppDelegate { return UIApplication.shared.delegate as! AndroidAppDelegate } - - var bluetoothEnabled: (() -> ())? - - override func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool { - - guard super.application(application, didFinishLaunchingWithOptions: launchOptions) - else { return false } - - NSLog("UIScreen scale: \(UIScreen.main.scale)") - NSLog("UIScreen native scale: \(UIScreen.main.nativeScale)") - NSLog("UIScreen size: \(UIScreen.main.bounds.size)") - NSLog("UIScreen native size: \(UIScreen.main.nativeBounds.size)") - - enableBluetooth() - - return true - } -} -#endif - -// MARK: - iOS Info Plist - -#if os(iOS) -/// Version of the app. -public let AppVersion = Bundle.main.infoDictionary!["CFBundleShortVersionString"] as! String - -/// Build of the app. -public let AppBuild = Int(Bundle.main.infoDictionary!["CFBundleVersion"] as! String)! -#endif - -// MARK: - Android Permissions - -#if os(Android) || os(macOS) - -extension AndroidAppDelegate { - - internal enum AndroidPermissionRequest { - - static let enableBluetooth = 1000 - static let gpsAndWriteStorage = 2000 - } -} - -extension AndroidAppDelegate { - - func application(_ application: UIApplication, activityResult requestCode: Int, resultCode: Int, data: Android.Content.Intent?) { - - log("\(type(of: self)) \(#function) - requestCode = \(requestCode) - resultCode = \(resultCode)") - - if resultCode == AndroidPermissionRequest.enableBluetooth, - resultCode == SwiftSupportAppCompatActivity.RESULT_OK { - - // no need to request permissions - if requestLocationPermissions() { - - // - bluetoothEnabled?() - } - } - } - - func application(_ application: UIApplication, requestPermissionsResult requestCode: Int, permissions: [String], grantResults: [Int]) { - - log("\(type(of: self)) \(#function)") - - if requestCode == AndroidPermissionRequest.gpsAndWriteStorage { - - if grantResults[0] == Android.Content.PM.PackageManager.Permission.granted.rawValue { - - // permission granted, now we can scan - bluetoothEnabled?() - - } else { - - log("\(type(of: self)) \(#function) GPS Permission is required") - } - } - } -} - -extension AndroidAppDelegate { - - /// Checks if permissions are needed. - @discardableResult - func enableBluetooth(hostController: Android.Bluetooth.Adapter = Android.Bluetooth.Adapter.default!) -> Bool { - - guard hostController.isEnabled() == false - else { return requestLocationPermissions() } - - let enableBluetoothIntent = Android.Content.Intent(action: Android.Bluetooth.Adapter.Action.requestEnable.rawValue) - - UIApplication.shared.androidActivity.startActivityForResult(intent: enableBluetoothIntent, - requestCode: AndroidPermissionRequest.enableBluetooth) - - log("\(type(of: self)) \(#function) enable Bluetooth") - - return false - } - - @discardableResult - func requestLocationPermissions() -> Bool { - - let activity = UIApplication.shared.androidActivity - - if Android.OS.Build.Version.Sdk.sdkInt.rawValue >= Android.OS.Build.VersionCodes.M, - activity.checkSelfPermission(permission: Android.ManifestPermission.accessCoarseLocation.rawValue) != Android.Content.PM.PackageManager.Permission.granted.rawValue { - - log("\(type(of: self)) \(#function) request permission") - - let permissions = [Android.ManifestPermission.accessCoarseLocation.rawValue, Android.ManifestPermission.writeExternalStorage.rawValue] - - activity.requestPermissions(permissions: permissions, requestCode: AndroidPermissionRequest.gpsAndWriteStorage) - - return false - - } else { - - log("\(type(of: self)) \(#function) dont need to request permissions") - - return true - } - } -} - -#endif diff --git a/Android/app/src/main/swift/Sources/Appearance.swift b/Android/app/src/main/swift/Sources/Appearance.swift deleted file mode 100644 index 3692129..0000000 --- a/Android/app/src/main/swift/Sources/Appearance.swift +++ /dev/null @@ -1,31 +0,0 @@ -// -// Appearance.swift -// BluetoothExplorer -// -// Created by Alsey Coleman Miller on 9/23/18. -// Copyright © 2018 PureSwift. All rights reserved. -// - -#if os(iOS) -import UIKit -#elseif os(Android) || os(macOS) -import Android -import AndroidUIKit -#endif - -/// Configure the application's UI appearance -func configureAppearance() { - - #if os(iOS) - - if #available(iOS 11.0, *) { - UINavigationBar.appearance().prefersLargeTitles = true - UINavigationBar.appearance().largeTitleTextAttributes = [.foregroundColor: UIColor.white] - } - - UINavigationBar.appearance().tintColor = .white - UINavigationBar.appearance().titleTextAttributes = [.foregroundColor: UIColor.white] - UINavigationBar.appearance().barTintColor = UIColor(red: 0.386, green: 0.707, blue: 1.0, alpha: 1.0) - - #endif -} diff --git a/Android/app/src/main/swift/Sources/Async.swift b/Android/app/src/main/swift/Sources/Async.swift deleted file mode 100755 index dd9d8d7..0000000 --- a/Android/app/src/main/swift/Sources/Async.swift +++ /dev/null @@ -1,32 +0,0 @@ -// -// Async.swift -// BluetoothExplorer -// -// Created by Alsey Coleman Miller on 6/19/18. -// Copyright © 2018 PureSwift. All rights reserved. -// - -import Foundation -import Dispatch - -#if os(Android) || os(macOS) -import Android -import AndroidUIKit -#endif - -func mainQueue(_ block: @escaping () -> ()) { - - #if os(iOS) - DispatchQueue.main.async(execute: block) - #elseif os(Android) || os(macOS) - UIApplication.shared.androidActivity.runOnMainThread(block) - #endif -} - -/// Perform a task on the internal queue. -func async(_ block: @escaping () -> ()) { - - appQueue.async(execute: block) -} - -let appQueue = DispatchQueue(label: "App Queue") diff --git a/Android/app/src/main/swift/Sources/Central.swift b/Android/app/src/main/swift/Sources/Central.swift deleted file mode 100644 index eea1e4d..0000000 --- a/Android/app/src/main/swift/Sources/Central.swift +++ /dev/null @@ -1,55 +0,0 @@ -// -// Central.swift -// BluetoothExplorer -// -// Created by Alsey Coleman Miller on 9/7/18. -// Copyright © 2018 PureSwift. All rights reserved. -// - -import Foundation -import Bluetooth -import GATT - -typealias NativeScanData = ScanData - -#if os(iOS) - -import DarwinGATT - -typealias NativeCentral = DarwinCentral - -private struct CentralCache { - - static let options = DarwinCentral.Options(showPowerAlert: false, restoreIdentifier: nil) - - static let central = DarwinCentral(options: options) -} - -#elseif os(Android) || os(macOS) - -import Android -import AndroidBluetooth -import AndroidUIKit - -typealias NativeCentral = AndroidCentral - -private struct CentralCache { - - static let hostController = Android.Bluetooth.Adapter.default! - - static let context = UIApplication.shared.androidActivity - - static let options = AndroidCentral.Options() - - static let central = AndroidCentral(hostController: hostController, context: context, options: options) -} - -#endif - -internal extension NativeCentral { - - static var shared: NativeCentral { - - return CentralCache.central - } -} diff --git a/Android/app/src/main/swift/Sources/CentralViewController.swift b/Android/app/src/main/swift/Sources/CentralViewController.swift deleted file mode 100644 index cdd6b63..0000000 --- a/Android/app/src/main/swift/Sources/CentralViewController.swift +++ /dev/null @@ -1,265 +0,0 @@ -// -// CentralViewController.swift -// BluetoothExplorer -// -// Created by Alsey Coleman Miller on 9/7/18. -// Copyright © 2018 PureSwift. All rights reserved. -// - -import Foundation -import Bluetooth -import GATT - -#if os(iOS) -import UIKit -#elseif os(Android) || os(macOS) -import Android -import AndroidBluetooth -import AndroidUIKit -#endif - -/// Scans for nearby BLE devices. -final class CentralViewController: UITableViewController { - - // MARK: - Properties - - #if os(iOS) - lazy var activityIndicator: UIActivityIndicatorView = self.loadActivityIndicatorView() - #else - lazy var progressDialog: AndroidProgressDialog = { - let progressDialog = AndroidProgressDialog(context: UIApplication.shared.androidActivity) - progressDialog.setIndeterminate(true) - progressDialog.setTitle("Wait") - progressDialog.setMessage("Scanning...") - return progressDialog - }() - #endif - - private(set) var items = [NativeScanData]() - - let scanDuration: TimeInterval = 5.0 - - let filterDuplicates: Bool = false - - // MARK: - Loading - - override func viewDidLoad() { - super.viewDidLoad() - - // set title - self.title = "Central" - - // setup table view - self.tableView.estimatedRowHeight = 44 - self.tableView.rowHeight = UITableViewAutomaticDimension - self.tableView.register(ScanDataTableViewCell.self) - - let refreshControl = UIRefreshControl(frame: .zero) - - #if os(Android) || os(macOS) - refreshControl.addTarget(action: { [unowned self] in self.reloadData() }, for: .valueChanged) - #else - refreshControl.addTarget(self, action: #selector(pullToRefresh), for: .valueChanged) - #endif - - self.refreshControl = refreshControl - - #if os(Android) || os(macOS) - AndroidAppDelegate.shared.bluetoothEnabled = { [weak self] in self?.reloadData() } - reloadData() - #endif - } - - override func viewDidAppear(_ animated: Bool) { - super.viewDidAppear(animated) - - reloadData() - } - - // MARK: - Actions - - #if os(iOS) - @objc func pullToRefresh(_ sender: UIRefreshControl) { - - DispatchQueue.main.asyncAfter(deadline: .now() + 1.0, execute: { [weak self] in - - self?.reloadData() - }) - } - #endif - - - // MARK: - Methods - - private subscript (indexPath: IndexPath) -> NativeScanData { - - @inline(__always) - get { return self.items[indexPath.row] } - } - - private final func endRefreshing() { - - if let refreshControl = self.refreshControl, - refreshControl.isRefreshing{ - - refreshControl.endRefreshing() - } - } - - private func reloadData() { - - log("\(type(of: self)) \(#function)") - - // clear table data - self.items.removeAll() - tableView.reloadData() - - // make sure its enabled - #if os(Android) - guard AndroidCentral.shared.hostController.isEnabled() - else { return } // wait until enabled - #endif - - // scan - let scanDuration = self.scanDuration - let filterDuplicates = self.filterDuplicates - - performActivity({ - try NativeCentral.shared.scan(duration: scanDuration, filterDuplicates: filterDuplicates) { [weak self] (device) in mainQueue { self?.foundDevice(device) } } - }) - } - - private func foundDevice(_ scanData: NativeScanData) { - - // remove old value - if let index = self.items.index(where: { $0.peripheral == scanData.peripheral }) { - self.items.remove(at: index) - } - - // add item - self.items.append(scanData) - - // sort - self.items.sort(by: { $0.peripheral.description < $1.peripheral.description }) - - // update table view - self.tableView.reloadData() - } - - private func configure(cell: UITableViewCell, at indexPath: IndexPath) { - - let item = self[indexPath] - - #if os(iOS) - - cell.textLabel?.text = item.advertisementData.localName ?? item.peripheral.identifier.description - cell.textLabel?.numberOfLines = 0 - - #elseif os(Android) || os(macOS) - - if let localName = item.advertisementData.localName { - - let layoutName = "peripheral_item" - - if cell.layoutName != layoutName { - cell.inflateAndroidLayout(layoutName) - } - - let itemView = cell.androidView - - let tvNameId = UIApplication.shared.androidActivity.getIdentifier(name: "tvName", type: "id") - let tvAddressId = UIApplication.shared.androidActivity.getIdentifier(name: "tvAddress", type: "id") - - guard let tvNameObject = itemView.findViewById(tvNameId) - else { fatalError("No view for \(tvNameId)") } - - guard let tvAddressObject = itemView.findViewById(tvAddressId) - else { fatalError("No view for \(tvAddressId)") } - - let tvName = Android.Widget.TextView(casting: tvNameObject) - let tvAddress = Android.Widget.TextView(casting: tvAddressObject) - - tvName?.text = localName - tvAddress?.text = item.peripheral.description - - } else { - - let layoutName = "peripheral_item_2" - - if cell.layoutName != layoutName { - cell.inflateAndroidLayout(layoutName) - } - - let itemView = cell.androidView - - let tvAddressId = UIApplication.shared.androidActivity.getIdentifier(name: "tvAddress", type: "id") - - guard let tvAddressObject = itemView.findViewById(tvAddressId) - else { fatalError("No view for \(tvAddressId)") } - - let tvAddress = Android.Widget.TextView(casting: tvAddressObject) - - tvAddress?.text = item.peripheral.description - } - - #endif - } - - #if os(iOS) - private func loadActivityIndicatorView() -> UIActivityIndicatorView { - - let activityIndicator = UIActivityIndicatorView(activityIndicatorStyle: .gray) - activityIndicator.frame.origin = CGPoint(x: 6.5, y: 15) - - let view = UIView(frame: CGRect(x: 0, y: 0, width: 33, height: 44)) - view.backgroundColor = .clear - view.addSubview(activityIndicator) - - let barButtonItem = UIBarButtonItem(customView: view) - self.navigationItem.rightBarButtonItem = barButtonItem - return activityIndicator - } - #endif - - // MARK: - UITableViewDataSource - - override func numberOfSections(in tableView: UITableView) -> Int { - - return 1 - } - - override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - - return items.count - } - - override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - - let cell = tableView.dequeueReusableCell(ScanDataTableViewCell.self, for: indexPath) - configure(cell: cell, at: indexPath) - return cell - } - - // MARK: - UITableViewDelegate - - override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - - #if os(iOS) - defer { tableView.deselectRow(at: indexPath, animated: true) } - #endif - - let item = self[indexPath] - - self.endRefreshing() - - log("Selected \(item.peripheral) \(item.advertisementData.localName ?? "")") - - let viewController = ServicesViewController(scanData: item) - - self.show(viewController, sender: self) - } -} - -// MARK: - ActivityIndicatorViewController - -extension CentralViewController: TableViewActivityIndicatorViewController { } diff --git a/Android/app/src/main/swift/Sources/CharacteristicViewController.swift b/Android/app/src/main/swift/Sources/CharacteristicViewController.swift deleted file mode 100644 index 91e6bd3..0000000 --- a/Android/app/src/main/swift/Sources/CharacteristicViewController.swift +++ /dev/null @@ -1,470 +0,0 @@ -// -// CharacteristicViewController.swift -// BluetoothExplorerAndroid -// -// Created by Marco Estrella on 9/21/18. -// - -import Foundation -import Bluetooth -import GATT - -#if os(iOS) -import UIKit -#elseif os(Android) || os(macOS) -import Android -import AndroidUIKit -#endif - -/// Characteristic -final class CharacteristicViewController: UITableViewController { - - typealias NativeService = Service - typealias NativeCharacteristic = Characteristic - - // MARK: - Properties - - #if os(iOS) - lazy var activityIndicator: UIActivityIndicatorView = self.loadActivityIndicatorView() - #else - lazy var progressDialog: AndroidProgressDialog = { - let progressDialog = AndroidProgressDialog(context: UIApplication.shared.androidActivity) - progressDialog.setIndeterminate(true) - progressDialog.setTitle("Wait") - progressDialog.setMessage("Loading...") - return progressDialog - }() - #endif - - let service: NativeService - let characteristic: NativeCharacteristic - - private var sections = [Section]() - - private(set) var characteristicValue = [Data]() { - didSet { configureView() } - } - - private var isNotifying = false { - didSet { configureView() } - } - - private let timeout: TimeInterval = .gattDefaultTimeout - - // MARK: - Initialization - - init(service: NativeService, characteristic: NativeCharacteristic) { - - self.characteristic = characteristic - self.service = service - - super.init(style: .grouped) - } - - deinit { - - // just in case we didnt stop notifications - NativeCentral.shared.disconnect(peripheral: characteristic.peripheral) - } - - #if os(iOS) - required init?(coder aDecoder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - #endif - - private let cellReuseIdentifier = "Cell" - - // MARK: - Loading - - override func viewDidLoad() { - super.viewDidLoad() - - NSLog("\(type(of: self)) \(#function)") - - - // setup table view - self.tableView.estimatedRowHeight = 44 - self.tableView.rowHeight = UITableViewAutomaticDimension - #if os(iOS) - self.tableView.register(UITableViewCell.self, forCellReuseIdentifier: CellIdentifier.uuid.rawValue) - self.tableView.register(UITableViewCell.self, forCellReuseIdentifier: CellIdentifier.name.rawValue) - self.tableView.register(UITableViewCell.self, forCellReuseIdentifier: CellIdentifier.value.rawValue) - self.tableView.register(UITableViewCell.self, forCellReuseIdentifier: CellIdentifier.property.rawValue) - #else - self.tableView.register(UITableViewCell.self, forCellReuseIdentifier: self.cellReuseIdentifier) - #endif - - // update UI - self.configureView() - - // attempt to read - self.readValue() - } - - // MARK: - Methods - - private subscript (indexPath: IndexPath) -> Item { - - @inline(__always) - get { return self.sections[indexPath.section].items[indexPath.row] } - } - - private func configureView() { - - title = self.characteristic.uuid.description - - // configure table view - - sections = [] - - do { - - var items = [Item]() - - if let name = characteristic.uuid.name { - - items.append(.name(name)) - } - - items.append(.uuid(characteristic.uuid)) - - sections.append(Section(title: "Information", items: items)) - } - - if characteristic.properties.isEmpty == false { - - sections.append(Section(title: "Properties", items: characteristic.properties.map { Item.property($0) })) - } - - if characteristicValue.isEmpty == false { - - sections.append(Section(title: "Value", items: characteristicValue.reversed().map { Item.value($0) })) - } - - // update UI - tableView.reloadData() - } - - private func readValue() { - NSLog("\(type(of: self)) \(#function)") - let timeout = self.timeout - let service = self.service - let characteristic = self.characteristic - let peripheral = self.service.peripheral - - guard characteristic.properties.contains(.read) - else { return } - - performActivity({ - try NativeCentral.shared.connect(to: peripheral, timeout: timeout) - defer { NativeCentral.shared.disconnect(peripheral: peripheral) } - let _ = try NativeCentral.shared.discoverServices(for: peripheral, timeout: timeout) - let _ = try NativeCentral.shared.discoverCharacteristics(for: service, timeout: timeout) - return try NativeCentral.shared.readValue(for: characteristic, timeout: timeout) - }, completion: { - $0.characteristicValue.append($1) - }) - } - - private func writeValue(_ newValue: Data, withResponse: Bool = true) { - - let timeout = self.timeout - let service = self.service - let characteristic = self.characteristic - let peripheral = self.service.peripheral - - performActivity({ - try NativeCentral.shared.connect(to: peripheral, timeout: timeout) - defer { NativeCentral.shared.disconnect(peripheral: peripheral) } - let _ = try NativeCentral.shared.discoverServices(for: peripheral, timeout: timeout) - let _ = try NativeCentral.shared.discoverCharacteristics(for: service, timeout: timeout) - try NativeCentral.shared.writeValue(newValue, for: characteristic, withResponse: withResponse, timeout: timeout) - }, completion: { (viewController: CharacteristicViewController, _) in - viewController.characteristicValue.append(newValue) - }) - } - - private func startNotifications() { - - let timeout = self.timeout - let service = self.service - let characteristic = self.characteristic - let peripheral = self.service.peripheral - - performActivity({ - try NativeCentral.shared.connect(to: peripheral, timeout: timeout) - let _ = try NativeCentral.shared.discoverServices(for: peripheral, timeout: timeout) - let _ = try NativeCentral.shared.discoverCharacteristics(for: service, timeout: timeout) - try NativeCentral.shared.notify({ [weak self] (newValue) in mainQueue { self?.notification(newValue) } }, for: characteristic) - }, completion: { (viewController: CharacteristicViewController, _) in - viewController.isNotifying = true - }) - } - - private func notification(_ newValue: Data) { - - NSLog("\(#function) new characteristicValue: \(newValue)") - self.characteristicValue.append(newValue) - } - - private func stopNotifications() { - - let timeout = self.timeout - let service = self.service - let characteristic = self.characteristic - let peripheral = self.service.peripheral - - performActivity({ - // should already be connected - defer { NativeCentral.shared.disconnect(peripheral: peripheral) } - let _ = try NativeCentral.shared.discoverServices(for: peripheral, timeout: timeout) - let _ = try NativeCentral.shared.discoverCharacteristics(for: service, timeout: timeout) - try NativeCentral.shared.notify(nil, for: characteristic) - }, completion: { (viewController: CharacteristicViewController, _) in - viewController.isNotifying = false - }) - } - - private func configure(cell: UITableViewCell, with value: String) { - - cell.textLabel?.text = value - - #if os(iOS) - cell.textLabel?.numberOfLines = 0 - #endif - } - - private func configure(cell: UITableViewCell, with data: Data) { - - cell.textLabel?.text = data.isEmpty ? "No value" : "0x" + data.reduce("", { $0 + String($1, radix: 16) }) - - #if os(iOS) - cell.textLabel?.numberOfLines = 0 - #endif - } - - // MARK: - UITableViewDataSource - - override func numberOfSections(in tableView: UITableView) -> Int { - NSLog("count: \(sections.count)") - return sections.count - } - - override func tableView(_ tableView: UITableView, numberOfRowsInSection sectionIndex: Int) -> Int { - - let section = self.sections[sectionIndex] - - return section.items.count - } - - override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - - #if os(iOS) - let item = self[indexPath] - - switch item { - case let .uuid(uuid): - let cell = tableView.dequeueReusableCell(withIdentifier: CellIdentifier.uuid.rawValue, for: indexPath) - configure(cell: cell, with: uuid.rawValue) - return cell - case let .name(name): - let cell = tableView.dequeueReusableCell(withIdentifier: CellIdentifier.name.rawValue, for: indexPath) - configure(cell: cell, with: name) - return cell - case let .value(data): - let cell = tableView.dequeueReusableCell(withIdentifier: CellIdentifier.value.rawValue, for: indexPath) - configure(cell: cell, with: data) - return cell - case let .property(property): - let cell = tableView.dequeueReusableCell(withIdentifier: CellIdentifier.property.rawValue, for: indexPath) - configure(cell: cell, with: property.description) - return cell - } - #else - - let cell = tableView.dequeueReusableCell(withIdentifier: cellReuseIdentifier, for: indexPath) - - let layoutName = "list_cell" - - if cell.layoutName != layoutName { - cell.inflateAndroidLayout(layoutName) - } - - let itemView = cell.androidView - - let tvItemId = UIApplication.shared.androidActivity.getIdentifier(name: "text_label", type: "id") - - guard let tvItemObject = itemView.findViewById(tvItemId) - else { fatalError("No view for \(tvItemId)") } - - let tvItem = Android.Widget.TextView(casting: tvItemObject) - - let item = self.sections[indexPath.section].items[indexPath.row] - NSLog("tableView \(indexPath.row) : \(item)") - switch item { - case let .uuid(uuid): - tvItem?.text = uuid.rawValue - case let .name(name): - tvItem?.text = name - case let .value(data): - tvItem?.text = data.isEmpty ? "No value" : "0x" + data.reduce("", { $0 + String($1, radix: 16) }).uppercased() - case let .property(property): - tvItem?.text = property.description - } - return cell - #endif - } - - func openAlertForWrite(item: NativeCharacteristic){ - - let alertController = UIAlertController.init(title: "Write Characteristic", message: nil, preferredStyle: UIAlertControllerStyle.alert) - - alertController.addTextField(configurationHandler: { text in text.placeholder = "New Value" }) - - let action1 = UIAlertAction.init(title: "Write", style: UIAlertActionStyle.default) { action in - - let newValue = alertController.textFields![0].text - - guard let value = newValue, let data = Data(hexString: value) else { - - NSLog("Value is required") - //AndroidToast.makeText(context: UIApplication.shared.androidActivity, text: "Value is required", duration: AndroidToast.Dutation.short).show() - return - } - - self.writeValue(data) - } - - let action2 = UIAlertAction.init(title: "Cancel", style: UIAlertActionStyle.cancel) - - alertController.addAction(action1) - alertController.addAction(action2) - - present(alertController, animated: false) - } - - #if os(iOS) - override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { - - return self.sections[section].title - } - #else - override func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { - - let peripheralViewLayoutId = UIApplication.shared.androidActivity.getIdentifier(name: "list_header", type: "layout") - - let layoutInflarer = Android.View.LayoutInflater.from(context: UIApplication.shared.androidActivity) - - let itemView = layoutInflarer.inflate(resource: Android.R.Layout(rawValue: peripheralViewLayoutId), root: nil, attachToRoot: false) - - let tvHeaderId = UIApplication.shared.androidActivity.getIdentifier(name: "text_label", type: "id") - - guard let tvHeaderObject = itemView.findViewById(tvHeaderId) - else { fatalError("No view for \(tvHeaderId)") } - - let tvHeader = Android.Widget.TextView(casting: tvHeaderObject) - - tvHeader?.text = self.sections[section].title - - let uiView = UIView.init(androidViewChild: itemView) - - uiView.androidView.layoutParams = Android.Widget.FrameLayout.FLayoutParams(width: Android.Widget.FrameLayout.FLayoutParams.MATCH_PARENT, height: Android.Widget.FrameLayout.FLayoutParams.WRAP_CONTENT) - - return uiView - } - #endif - - // MARK: - UITableViewDelegate - - override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - - #if os(iOS) - defer { tableView.deselectRow(at: indexPath, animated: true) } - #endif - - let item = self[indexPath] - - switch item { - - case .uuid, - .name: - break - - case let .value(data): - break // TODO: Show expanded data - - case let .property(property): - - switch property { - case .broadcast, - .extendedProperties, - .signedWrite: - break // cant handle - case .read: - readValue() - case .write: - openAlertForWrite(item: characteristic) - break - case .writeWithoutResponse: - // TODO: show UI to type new value - writeValue(Data(), withResponse: false) - break - case .notify, .indicate: - NSLog("notify") - isNotifying ? stopNotifications() : startNotifications() - } - } - } -} - -// MARK: - ActivityIndicatorViewController - -extension CharacteristicViewController: TableViewActivityIndicatorViewController { } - -// MARK: - Supporting Types - -private extension CharacteristicViewController { - - struct Section { - - let title: String? - let items: [Item] - } - - enum Item { - - case uuid(BluetoothUUID) - case name(String) - case value(Data) - case property(GATT.CharacteristicProperty) - } - - enum CellIdentifier: String { - - case uuid - case name - case value - case property - } -} - - -//FIXME: -extension Data { - init?(hexString: String) { - let length = hexString.count / 2 - var data = Data(capacity: length) - for i in 0 ..< length { - let j = hexString.index(hexString.startIndex, offsetBy: i * 2) - let k = hexString.index(j, offsetBy: 2) - let bytes = hexString[j.. - typealias NativeCharacteristic = Characteristic - - // MARK: - Properties - - #if os(iOS) - lazy var activityIndicator: UIActivityIndicatorView = self.loadActivityIndicatorView() - #else - lazy var progressDialog: AndroidProgressDialog = { - let progressDialog = AndroidProgressDialog(context: UIApplication.shared.androidActivity) - progressDialog.setIndeterminate(true) - progressDialog.setTitle("Wait") - progressDialog.setMessage("Loading Characteristics...") - return progressDialog - }() - #endif - - let service: NativeService - - private let cellReuseIdentifier = "Cell" - - private let timeout: TimeInterval = .gattDefaultTimeout - - private(set) var items = [NativeCharacteristic]() { - - didSet { self.tableView.reloadData() } - } - - // MARK: - Initialization - - init(service: NativeService) { - - self.service = service - - super.init(style: .plain) - } - - #if os(iOS) - required init?(coder aDecoder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - #endif - - // MARK: - Loading - - override func viewDidLoad() { - super.viewDidLoad() - - // setup table view - self.tableView.estimatedRowHeight = 44 - self.tableView.rowHeight = UITableViewAutomaticDimension - self.tableView.register(UITableViewCell.self, forCellReuseIdentifier: self.cellReuseIdentifier) - - // add refresh control - let refreshControl = UIRefreshControl(frame: .zero) - - #if os(Android) || os(macOS) - refreshControl.addTarget(action: { [unowned self] in self.reloadData() }, for: .valueChanged) - #else - refreshControl.addTarget(self, action: #selector(pullToRefresh), for: .valueChanged) - #endif - - self.refreshControl = refreshControl - - self.configureView() - self.reloadData() - } - - // MARK: - Methods - - private subscript (indexPath: IndexPath) -> NativeCharacteristic { - - @inline(__always) - get { return self.items[indexPath.row] } - } - - #if os(iOS) || os(macOS) - @objc func pullToRefresh() { - - DispatchQueue.main.asyncAfter(deadline: .now() + 1.0, execute: { [weak self] in - - self?.reloadData() - }) - } - #endif - - private func endRefreshing() { - - if let refreshControl = self.refreshControl, - refreshControl.isRefreshing == true { - - refreshControl.endRefreshing() - } - } - - private func configureView() { - - self.title = self.service.uuid.description - } - - private func reloadData() { - - let timeout = self.timeout - - let service = self.service - let peripheral = self.service.peripheral - - performActivity({ - try NativeCentral.shared.connect(to: peripheral) - defer { NativeCentral.shared.disconnect(peripheral: peripheral) } - let _ = try NativeCentral.shared.discoverServices(for: peripheral, timeout: timeout) - return try NativeCentral.shared.discoverCharacteristics(for: service, timeout: timeout) - }, completion: { - $0.items = $1 - }) - } - - private func configure(cell: UITableViewCell, at indexPath: IndexPath) { - - let item = self[indexPath] - - cell.textLabel?.text = item.uuid.description - - #if os(iOS) - cell.textLabel?.numberOfLines = 0 - #endif - } - - // MARK: - UITableViewDataSource - - override func numberOfSections(in tableView: UITableView) -> Int { - - return 1 - } - - override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - - return items.count - } - - override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - - let cell = tableView.dequeueReusableCell(withIdentifier: cellReuseIdentifier, for: indexPath) - - configure(cell: cell, at: indexPath) - - return cell - } - - // MARK: - UITableViewDelegate - - override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - - #if os(iOS) - defer { tableView.deselectRow(at: indexPath, animated: true) } - #endif - - let characteristic = self[indexPath] - - log("Selected \(characteristic.uuid.description)") - - let viewController = CharacteristicViewController(service: service, characteristic: characteristic) - - self.show(viewController, sender: self) - } -} - -// MARK: - ActivityIndicatorViewController - -extension CharacteristicsViewController: TableViewActivityIndicatorViewController { } diff --git a/Android/app/src/main/swift/Sources/ErrorAlert.swift b/Android/app/src/main/swift/Sources/ErrorAlert.swift deleted file mode 100755 index 08ca46e..0000000 --- a/Android/app/src/main/swift/Sources/ErrorAlert.swift +++ /dev/null @@ -1,50 +0,0 @@ -// -// ErrorAlert.swift -// BluetoothExplorer -// -// Created by Alsey Coleman Miller on 6/19/18. -// Copyright © 2018 PureSwift. All rights reserved. -// - -import Foundation -#if os(iOS) -import UIKit -#elseif os(Android) || os(macOS) -import Android -import AndroidUIKit -#endif - -public extension UIViewController { - - /// Presents an error alert controller with the specified completion handlers. - func showErrorAlert(_ localizedText: String, - okHandler: (() -> ())? = nil, - retryHandler: (()-> ())? = nil) { - - let alert = UIAlertController(title: NSLocalizedString("Error", comment: "Error"), - message: localizedText, - preferredStyle: .alert) - - alert.addAction(UIAlertAction(title: NSLocalizedString("OK", comment: "OK"), style: UIAlertActionStyle.`default`, handler: { (UIAlertAction) in - - okHandler?() - - alert.presentingViewController?.dismiss(animated: true, completion: nil) - })) - - // optionally add retry button - - if retryHandler != nil { - - alert.addAction(UIAlertAction(title: NSLocalizedString("Retry", comment: "Retry"), style: UIAlertActionStyle.`default`, handler: { (UIAlertAction) in - - retryHandler!() - - alert.presentingViewController?.dismiss(animated: true, completion: nil) - })) - } - - self.present(alert, animated: true, completion: nil) - } -} - diff --git a/Android/app/src/main/swift/Sources/Log.swift b/Android/app/src/main/swift/Sources/Log.swift deleted file mode 100644 index 8e9cdad..0000000 --- a/Android/app/src/main/swift/Sources/Log.swift +++ /dev/null @@ -1,19 +0,0 @@ -// -// Log.swift -// BluetoothExplorer -// -// Created by Alsey Coleman Miller on 9/7/18. -// Copyright © 2018 PureSwift. All rights reserved. -// - -import Foundation - -/// app logger function -func log(_ message: String) { - - #if os(Android) - NSLog(message) - #else - print(message) - #endif -} diff --git a/Android/app/src/main/swift/Sources/MainActivity.swift b/Android/app/src/main/swift/Sources/MainActivity.swift deleted file mode 100644 index c6cde61..0000000 --- a/Android/app/src/main/swift/Sources/MainActivity.swift +++ /dev/null @@ -1,37 +0,0 @@ -// -// main.swift -// AndroidUIKit -// -// Created by Marco Estrella on 9/6/18. -// - -#if os(Android) || os(macOS) - -import Foundation -import Android -import AndroidUIKit - -/// Needs to be implemented by app. -@_silgen_name("SwiftAndroidMainApplication") -public func SwiftAndroidMainApplication() -> SwiftApplication.Type { - - NSLog("\(#function)") - - // initialize singleton App Delegate - UIApplication.shared.delegate = AndroidAppDelegate() - - // return specialized Android Application - return AndroidUIKitApplication.self -} - -/// Needs to be implemented by app. -@_silgen_name("SwiftAndroidMainActivity") -public func SwiftAndroidMainActivity() -> SwiftSupportAppCompatActivity.Type { - - NSLog("\(#function)") - - // return specialized Android Activity - return AndroidUIKitMainActivity.self -} - -#endif diff --git a/Android/app/src/main/swift/Sources/MainActivityBindings.swift b/Android/app/src/main/swift/Sources/MainActivityBindings.swift deleted file mode 100644 index 502eb65..0000000 --- a/Android/app/src/main/swift/Sources/MainActivityBindings.swift +++ /dev/null @@ -1,18 +0,0 @@ - -import java_swift - -/// generated by: genswift.java 'java/lang|java/util|java/sql' 'Sources' '../java' /// - -/// interface com.millertech.bluetoothexplorer.swiftbindings.MainActivityBindings /// - -public protocol MainActivityBindings: JavaProtocol { - -} - - -open class MainActivityBindingsForward: JNIObjectForward, MainActivityBindings { - - private static var MainActivityBindingsJNIClass: jclass? - -} - diff --git a/Android/app/src/main/swift/Sources/MainActivityBindings_Listener.swift b/Android/app/src/main/swift/Sources/MainActivityBindings_Listener.swift deleted file mode 100644 index 7b43869..0000000 --- a/Android/app/src/main/swift/Sources/MainActivityBindings_Listener.swift +++ /dev/null @@ -1,85 +0,0 @@ - -import java_swift - -/// generated by: genswift.java 'java/lang|java/util|java/sql' 'Sources' '../java' /// - -/// interface com.millertech.bluetoothexplorer.swiftbindings.MainActivityBindings$Listener /// - -public protocol MainActivityBindings_Listener: JavaProtocol { - - /// public abstract void com.millertech.bluetoothexplorer.swiftbindings.MainActivityBindings$Listener.hi() - - func hi() - -} - - -open class MainActivityBindings_ListenerForward: JNIObjectForward, MainActivityBindings_Listener { - - private static var MainActivityBindings_ListenerJNIClass: jclass? - - /// public abstract void com.millertech.bluetoothexplorer.swiftbindings.MainActivityBindings$Listener.hi() - - private static var hi_MethodID_2: jmethodID? - - open func hi() { - var __locals = [jobject]() - var __args = [jvalue]( repeating: jvalue(), count: 1 ) - JNIMethod.CallVoidMethod( object: javaObject, methodName: "hi", methodSig: "()V", methodCache: &MainActivityBindings_ListenerForward.hi_MethodID_2, args: &__args, locals: &__locals ) - } - - -} - -private typealias MainActivityBindings_Listener_hi_0_type = @convention(c) ( _: UnsafeMutablePointer, _: jobject?, _: jlong ) -> () - -private func MainActivityBindings_Listener_hi_0( _ __env: UnsafeMutablePointer, _ __this: jobject?, _ __swiftObject: jlong ) -> () { - MainActivityBindings_ListenerLocal_.swiftObject( jniEnv: __env, javaObject: __this, swiftObject: __swiftObject ).hi( ) -} - -fileprivate class MainActivityBindings_ListenerLocal_: JNILocalProxy { - - fileprivate static let _proxyClass: jclass = { - var natives = [JNINativeMethod]() - - let MainActivityBindings_Listener_hi_0_thunk: MainActivityBindings_Listener_hi_0_type = MainActivityBindings_Listener_hi_0 - natives.append( JNINativeMethod( name: strdup("__hi"), signature: strdup("(J)V"), fnPtr: unsafeBitCast( MainActivityBindings_Listener_hi_0_thunk, to: UnsafeMutableRawPointer.self ) ) ) - - natives.append( JNINativeMethod( name: strdup("__finalize"), signature: strdup("(J)V"), fnPtr: unsafeBitCast( JNIReleasableProxy__finalize_thunk, to: UnsafeMutableRawPointer.self ) ) ) - - let clazz = JNI.FindClass( proxyClassName() ) - natives.withUnsafeBufferPointer { - nativesPtr in - if JNI.api.RegisterNatives( JNI.env, clazz, nativesPtr.baseAddress, jint(nativesPtr.count) ) != jint(JNI_OK) { - JNI.report( "Unable to register java natives" ) - } - } - - defer { JNI.DeleteLocalRef( clazz ) } - return JNI.api.NewGlobalRef( JNI.env, clazz )! - }() - - override open class func proxyClassName() -> String { return "org/swiftjava/com_millertech/MainActivityBindings_ListenerProxy" } - override open class func proxyClass() -> jclass? { return _proxyClass } - -} - -extension MainActivityBindings_Listener { - - public func localJavaObject( _ locals: UnsafeMutablePointer<[jobject]> ) -> jobject? { - return MainActivityBindings_ListenerLocal_( owned: self, proto: self ).localJavaObject( locals ) - } - -} - -open class MainActivityBindings_ListenerBase: MainActivityBindings_Listener { - - public init() {} - - /// public abstract void com.millertech.bluetoothexplorer.swiftbindings.MainActivityBindings$Listener.hi() - - open func hi() /**/ { - } - - -} diff --git a/Android/app/src/main/swift/Sources/MainActivityBindings_Responder.swift b/Android/app/src/main/swift/Sources/MainActivityBindings_Responder.swift deleted file mode 100644 index 10402a5..0000000 --- a/Android/app/src/main/swift/Sources/MainActivityBindings_Responder.swift +++ /dev/null @@ -1,18 +0,0 @@ - -import java_swift - -/// generated by: genswift.java 'java/lang|java/util|java/sql' 'Sources' '../java' /// - -/// interface com.millertech.bluetoothexplorer.swiftbindings.MainActivityBindings$Responder /// - -public protocol MainActivityBindings_Responder: JavaProtocol { - -} - - -open class MainActivityBindings_ResponderForward: JNIObjectForward, MainActivityBindings_Responder { - - private static var MainActivityBindings_ResponderJNIClass: jclass? - -} - diff --git a/Android/app/src/main/swift/Sources/NibTableViewCell.swift b/Android/app/src/main/swift/Sources/NibTableViewCell.swift deleted file mode 100644 index f742bb2..0000000 --- a/Android/app/src/main/swift/Sources/NibTableViewCell.swift +++ /dev/null @@ -1,37 +0,0 @@ -// -// NibTableViewCell.swift -// BluetoothExplorer -// -// Created by Alsey Coleman Miller on 4/30/19. -// Copyright © 2019 PureSwift. All rights reserved. -// - -#if os(iOS) -import Foundation -import UIKit - -/// NIB loading table view cell -protocol NibTableViewCell: ReusableTableViewCell { - - static var nibName: String { get } -} - -extension NibTableViewCell { - - static var nibName: String { - - return reuseIdentifier - } -} - -extension NibTableViewCell { - - static func register(tableView: UITableView) { - - let bundle = Bundle(for: self) - let nib = UINib(nibName: nibName, bundle: bundle) - tableView.register(nib, forCellReuseIdentifier: reuseIdentifier) - } -} - -#endif diff --git a/Android/app/src/main/swift/Sources/ReusableTableViewCell.swift b/Android/app/src/main/swift/Sources/ReusableTableViewCell.swift deleted file mode 100644 index 23c8c27..0000000 --- a/Android/app/src/main/swift/Sources/ReusableTableViewCell.swift +++ /dev/null @@ -1,66 +0,0 @@ -// -// ReusableTableViewCell.swift -// BluetoothExplorer -// -// Created by Alsey Coleman Miller on 4/30/19. -// Copyright © 2019 PureSwift. All rights reserved. -// - -import Foundation - -#if os(iOS) -import UIKit -#elseif os(Android) || os(macOS) -import Android -import AndroidUIKit -import java_lang -#endif - -/// Reusable Table View Cell -protocol ReusableTableViewCell: class { - - /// Reusable Cell Identifier - static var reuseIdentifier: String { get } - - /// Register table view cell for reuse. - static func register(tableView: UITableView) -} - -#if os(iOS) -extension ReusableTableViewCell where Self: UITableViewCell { - - static func register(tableView: UITableView) { - - tableView.register(self, forCellReuseIdentifier: reuseIdentifier) - } -} -#else -extension ReusableTableViewCell { - - static func register(tableView: UITableView) { - - tableView.register(self as? UITableViewCell.Type, forCellReuseIdentifier: reuseIdentifier) - } -} -#endif - -extension UITableView { - - func dequeueReusableCell (_ cell: T.Type, for indexPath: IndexPath) -> T { - - let cell = dequeueReusableCell(withIdentifier: T.reuseIdentifier, for: indexPath) - - guard let reusableCell = cell as? T - else { fatalError("Invalid cell \(cell) for \(T.reuseIdentifier)") } - - return reusableCell - } -} - -extension UITableView { - - func register (_ cell: T.Type) { - - T.register(tableView: self) - } -} diff --git a/Android/app/src/main/swift/Sources/ScanDataTableViewCell.swift b/Android/app/src/main/swift/Sources/ScanDataTableViewCell.swift deleted file mode 100644 index 69e6ba7..0000000 --- a/Android/app/src/main/swift/Sources/ScanDataTableViewCell.swift +++ /dev/null @@ -1,64 +0,0 @@ -// -// ScanDataTableViewCell.swift -// BluetoothExplorer -// -// Created by Alsey Coleman Miller on 4/30/19. -// Copyright © 2019 PureSwift. All rights reserved. -// - -import Foundation - -#if os(iOS) -import UIKit -#elseif os(Android) || os(macOS) -import Android -import AndroidUIKit -import java_lang -#endif - -/// Table View Cell -final class ScanDataTableViewCell: UITableViewCell { - - #if os(iOS) - - #elseif os(Android) || os(macOS) - - var titleLabel: Android.Widget.TextView! { - - return Android.Widget.TextView(casting: self["textLabel"]) - } - - var detailLabel: Android.Widget.TextView! { - - return Android.Widget.TextView(casting: self["detailTextLabel"]) - } - - var accessoryImageView: Android.Widget.ImageView! { - - return Android.Widget.ImageView(casting: self["accessoryImageView"]) - } - - var isAccessoryVisible: Bool = false { - didSet { accessoryImageView.visibility = isAccessoryVisible ? .visible : .invisible } - } - - #endif -} - -// MARK: - ReusableTableViewCell - -extension ScanDataTableViewCell: ReusableTableViewCell { - static let reuseIdentifier = "ScanDataTableViewCell" -} - -#if os(iOS) -extension ScanDataTableViewCell: NibTableViewCell { } -#elseif os(Android) || os(macOS) -extension ScanDataTableViewCell: AndroidTableViewCell { - static let layout = "cell_right_detail" - - func awakeFromLayout() { - - } -} -#endif diff --git a/Android/app/src/main/swift/Sources/ServicesViewController.swift b/Android/app/src/main/swift/Sources/ServicesViewController.swift deleted file mode 100644 index 6db6dff..0000000 --- a/Android/app/src/main/swift/Sources/ServicesViewController.swift +++ /dev/null @@ -1,196 +0,0 @@ -// -// ServicesViewController.swift -// BluetoothExplorer -// -// Created by Alsey Coleman Miller on 9/7/18. -// Copyright © 2018 PureSwift. All rights reserved. -// - -import Foundation -import Bluetooth -import GATT - -#if os(iOS) -import UIKit -#elseif os(Android) || os(macOS) -import Android -import AndroidUIKit -#endif - -/// Services -final class ServicesViewController: UITableViewController { - - typealias NativeScanData = ScanData - - typealias NativeService = Service - - // MARK: - Properties - - let scanData: NativeScanData - - private(set) var items = [NativeService]() { - - didSet { self.tableView.reloadData() } - } - - private let cellReuseIdentifier = "Cell" - - private let timeout: TimeInterval = .gattDefaultTimeout - - #if os(iOS) - lazy var activityIndicator: UIActivityIndicatorView = self.loadActivityIndicatorView() - #else - lazy var progressDialog: AndroidProgressDialog = { - let progressDialog = AndroidProgressDialog(context: UIApplication.shared.androidActivity) - progressDialog.setIndeterminate(true) - progressDialog.setTitle("Wait") - progressDialog.setMessage("Loading Services...") - return progressDialog - }() - #endif - - // MARK: - Loading - - init(scanData: NativeScanData) { - - self.scanData = scanData - - super.init(style: .plain) - } - - #if os(iOS) - required init?(coder aDecoder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - #endif - - override func viewDidLoad() { - super.viewDidLoad() - - // setup table view - self.tableView.estimatedRowHeight = 44 - self.tableView.rowHeight = UITableViewAutomaticDimension - self.tableView.register(UITableViewCell.self, forCellReuseIdentifier: self.cellReuseIdentifier) - - // add refresh control - - let actionRefresh: () -> () = { - - self.reloadData() - } - - let refreshControl = UIRefreshControl(frame: .zero) - - #if os(Android) || os(macOS) - refreshControl.addTarget(action: actionRefresh, for: .valueChanged) - #else - refreshControl.addTarget(self, action: #selector(pullToRefresh), for: UIControlEvents.valueChanged) - #endif - - self.refreshControl = refreshControl - - self.configureView() - self.reloadData() - } - - // MARK: - Actions - - #if os(iOS) || os(macOS) - @objc func pullToRefresh() { - - DispatchQueue.main.asyncAfter(deadline: .now() + 1.0, execute: { [weak self] in - - self?.reloadData() - }) - } - #endif - - // MARK: - Methods - - private subscript (indexPath: IndexPath) -> NativeService { - - @inline(__always) - get { return self.items[indexPath.row] } - } - - private func configureView() { - - self.title = self.scanData.advertisementData.localName ?? self.scanData.peripheral.identifier.description - } - - private func reloadData() { - - let peripheral = self.scanData.peripheral - let timeout = self.timeout - - performActivity({ - try NativeCentral.shared.connect(to: peripheral) - defer { NativeCentral.shared.disconnect(peripheral: peripheral) } - return try NativeCentral.shared.discoverServices([], for: peripheral, timeout: timeout) - }, completion: { - $0.items = $1 - }) - } - - private func endRefreshing() { - - if let refreshControl = self.refreshControl, - refreshControl.isRefreshing == true { - - refreshControl.endRefreshing() - } - } - - private func configure(cell: UITableViewCell, at indexPath: IndexPath) { - - let item = self[indexPath] - - cell.textLabel?.text = item.uuid.description - - #if os(iOS) - cell.textLabel?.numberOfLines = 0 - #endif - } - - // MARK: - UITableViewDataSource - - override func numberOfSections(in tableView: UITableView) -> Int { - - return 1 - } - - override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - - return items.count - } - - override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - - let cell = tableView.dequeueReusableCell(withIdentifier: cellReuseIdentifier, for: indexPath) - - configure(cell: cell, at: indexPath) - - return cell - } - - // MARK: - UITableViewDelegate - - override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - - #if os(iOS) - defer { tableView.deselectRow(at: indexPath, animated: true) } - #endif - - let item = self[indexPath] - - log("Selected \(item.uuid.description)") - - let viewController = CharacteristicsViewController(service: item) - - self.show(viewController, sender: self) - } -} - -// MARK: - ActivityIndicatorViewController - -extension ServicesViewController: TableViewActivityIndicatorViewController { } diff --git a/Android/build.gradle b/Android/build.gradle deleted file mode 100644 index fadcee6..0000000 --- a/Android/build.gradle +++ /dev/null @@ -1,32 +0,0 @@ -// Top-level build file where you can add configuration options common to all sub-projects/modules. - -buildscript { - ext.kotlin_version = '1.2.51' - repositories { - google() - jcenter() - mavenLocal() - mavenCentral() - maven { url 'https://jitpack.io' } - } - dependencies { - classpath 'net.zhuoweizhang:swiftandroid:1.0.0' - classpath 'com.android.tools.build:gradle:3.1.4' - classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" - - // NOTE: Do not place your application dependencies here; they belong - // in the individual module build.gradle files - } -} - -allprojects { - repositories { - google() - jcenter() - maven { url 'https://jitpack.io' } - } -} - -task clean(type: Delete) { - delete rootProject.buildDir -} diff --git a/Android/gradle.properties b/Android/gradle.properties deleted file mode 100644 index 743d692..0000000 --- a/Android/gradle.properties +++ /dev/null @@ -1,13 +0,0 @@ -# Project-wide Gradle settings. -# IDE (e.g. Android Studio) users: -# Gradle settings configured through the IDE *will override* -# any settings specified in this file. -# For more details on how to configure your build environment visit -# http://www.gradle.org/docs/current/userguide/build_environment.html -# Specifies the JVM arguments used for the daemon process. -# The setting is particularly useful for tweaking memory settings. -org.gradle.jvmargs=-Xmx1536m -# When configured, Gradle will run in incubating parallel mode. -# This option should only be used with decoupled projects. More details, visit -# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects -# org.gradle.parallel=true diff --git a/Android/gradle/wrapper/gradle-wrapper.jar b/Android/gradle/wrapper/gradle-wrapper.jar deleted file mode 100644 index 7a3265e..0000000 Binary files a/Android/gradle/wrapper/gradle-wrapper.jar and /dev/null differ diff --git a/Android/settings.gradle b/Android/settings.gradle deleted file mode 100644 index e7b4def..0000000 --- a/Android/settings.gradle +++ /dev/null @@ -1 +0,0 @@ -include ':app' diff --git a/BluetoothExplorer.swiftpm/App.swift b/BluetoothExplorer.swiftpm/App.swift index eae5d24..dae42ba 100644 --- a/BluetoothExplorer.swiftpm/App.swift +++ b/BluetoothExplorer.swiftpm/App.swift @@ -12,7 +12,7 @@ import SwiftUI @main struct BluetoothExplorerApp: App { - let store = Store.shared + static let store = Store() var body: some Scene { WindowGroup { @@ -20,7 +20,7 @@ struct BluetoothExplorerApp: App { CentralList() Text("Scan for devices") } - .environmentObject(Store.shared) + .environment(Self.store) } } } diff --git a/BluetoothExplorer.swiftpm/AppIntents/Entity/CharacteristicEntity.swift b/BluetoothExplorer.swiftpm/AppIntents/Entity/CharacteristicEntity.swift index adc5b9a..d22c50e 100644 --- a/BluetoothExplorer.swiftpm/AppIntents/Entity/CharacteristicEntity.swift +++ b/BluetoothExplorer.swiftpm/AppIntents/Entity/CharacteristicEntity.swift @@ -5,6 +5,7 @@ // Created by Alsey Coleman Miller on 11/22/22. // +#if canImport(AppIntents) import AppIntents import Bluetooth import GATT @@ -25,18 +26,20 @@ extension CharacteristicEntity { struct ID: Equatable, Hashable, EntityIdentifierConvertible, Sendable { - let peripheral: UUID + typealias Peripheral = NativeCentral.Peripheral.ID + + let peripheral: Peripheral let attributeID: Int var entityIdentifierString: String { - return peripheral.uuidString + "/" + attributeID.description + return peripheral.description + "/" + attributeID.description } static func entityIdentifier(for string: String) -> ID? { let components = string.components(separatedBy: "/") guard components.count == 2, - let peripheral = UUID(uuidString: components[0]), + let peripheral = Peripheral(components[0]), let attributeID = Int(components[1]) else { return nil } @@ -80,7 +83,7 @@ struct CharacteristicQuery: EntityQuery { @MainActor func entities(for identifiers: [CharacteristicEntity.ID]) -> [CharacteristicEntity] { - let allServices = Store.shared.characteristics.values.lazy.reduce([], { $0 + $1 }) + let allServices = BluetoothExplorerApp.store.characteristics.values.lazy.reduce([], { $0 + $1 }) return identifiers.compactMap { id in allServices .first(where: { $0.peripheral.id == id.peripheral && $0.id.hashValue == id.attributeID }) @@ -90,3 +93,4 @@ struct CharacteristicQuery: EntityQuery { func suggestedEntities() -> [CharacteristicEntity] { [] } } +#endif diff --git a/BluetoothExplorer.swiftpm/AppIntents/Entity/ManufacturerDataEntity.swift b/BluetoothExplorer.swiftpm/AppIntents/Entity/ManufacturerDataEntity.swift index 7083458..206f035 100644 --- a/BluetoothExplorer.swiftpm/AppIntents/Entity/ManufacturerDataEntity.swift +++ b/BluetoothExplorer.swiftpm/AppIntents/Entity/ManufacturerDataEntity.swift @@ -5,6 +5,7 @@ // Created by Alsey Coleman Miller on 11/20/22. // +#if canImport(AppIntents) import AppIntents import Bluetooth import GATT @@ -59,10 +60,11 @@ struct ManufacturerDataQuery: EntityQuery { @MainActor func suggestedEntities() throws -> [ManufacturerDataEntity] { - return Store.shared.scanResults + return BluetoothExplorerApp.store.scanResults .values .lazy .compactMap { $0.manufacturerData } .map { .init($0) } } } +#endif diff --git a/BluetoothExplorer.swiftpm/AppIntents/Entity/PeripheralEntity.swift b/BluetoothExplorer.swiftpm/AppIntents/Entity/PeripheralEntity.swift index 724e7df..60c5d6f 100644 --- a/BluetoothExplorer.swiftpm/AppIntents/Entity/PeripheralEntity.swift +++ b/BluetoothExplorer.swiftpm/AppIntents/Entity/PeripheralEntity.swift @@ -5,6 +5,7 @@ // Created by Alsey Coleman Miller on 11/20/22. // +#if canImport(AppIntents) import AppIntents import Bluetooth import GATT @@ -12,7 +13,7 @@ import GATT @available(macOS 13, iOS 16, watchOS 9, tvOS 16, *) struct PeripheralEntity: AppEntity, Identifiable { - let id: UUID + let id: NativeCentral.Peripheral.ID /// Timestamp for when device was scanned. let date: Date @@ -73,8 +74,8 @@ extension PeripheralEntity { struct PeripheralQuery: EntityQuery { @MainActor - func entities(for identifiers: [UUID]) -> [PeripheralEntity] { - let scanResults = Store.shared.scanResults.values + func entities(for identifiers: [PeripheralEntity.ID]) -> [PeripheralEntity] { + let scanResults = BluetoothExplorerApp.store.scanResults.values return identifiers.compactMap { id in scanResults.first(where: { $0.id == id }) .map { PeripheralEntity($0.scanData) } @@ -83,9 +84,23 @@ struct PeripheralQuery: EntityQuery { @MainActor func suggestedEntities() throws -> [PeripheralEntity] { - Store.shared.scanResults + BluetoothExplorerApp.store.scanResults .values .sorted(by: { ($0.name ?? $0.id.description) < ($1.name ?? $1.id.description) }) .map { .init($0.scanData) } } } + +// MARK: - EntityIdentifierConvertible + +extension BluetoothAddress: @retroactive EntityIdentifierConvertible { + + public var entityIdentifierString: String { + rawValue + } + + public static func entityIdentifier(for entityIdentifierString: String) -> Bluetooth.BluetoothAddress? { + .init(rawValue: entityIdentifierString) + } +} +#endif diff --git a/BluetoothExplorer.swiftpm/AppIntents/Entity/ServiceEntity.swift b/BluetoothExplorer.swiftpm/AppIntents/Entity/ServiceEntity.swift index d46c8fa..9872cc3 100644 --- a/BluetoothExplorer.swiftpm/AppIntents/Entity/ServiceEntity.swift +++ b/BluetoothExplorer.swiftpm/AppIntents/Entity/ServiceEntity.swift @@ -5,6 +5,7 @@ // Created by Alsey Coleman Miller on 11/22/22. // +#if canImport(AppIntents) import AppIntents import Bluetooth import GATT @@ -24,18 +25,20 @@ extension ServiceEntity { struct ID: Equatable, Hashable, EntityIdentifierConvertible, Sendable { - let peripheral: UUID + let peripheral: PeripheralEntity.ID let attributeID: Int var entityIdentifierString: String { - return peripheral.uuidString + "/" + attributeID.description + return peripheral.description + "/" + attributeID.description } /// Identifiers should be able to initialize via a `String` format. static func entityIdentifier(for string: String) -> ID? { let components = string.components(separatedBy: "/") - guard components.count == 2, let peripheral = UUID(uuidString: components[0]), let attributeID = Int(components[1]) else { + guard components.count == 2, + let peripheral = PeripheralEntity.ID(components[0]), + let attributeID = Int(components[1]) else { return nil } return ServiceEntity.ID.init(peripheral: peripheral, attributeID: attributeID) @@ -75,7 +78,7 @@ struct ServiceQuery: EntityQuery { @MainActor func entities(for identifiers: [ServiceEntity.ID]) -> [ServiceEntity] { - let allServices = Store.shared.services.values.lazy.reduce([], { $0 + $1 }) + let allServices = BluetoothExplorerApp.store.services.values.lazy.reduce([], { $0 + $1 }) return identifiers.compactMap { id in allServices .first(where: { $0.peripheral.id == id.peripheral && $0.id.hashValue == id.attributeID }) @@ -85,3 +88,4 @@ struct ServiceQuery: EntityQuery { func suggestedEntities() -> [ServiceEntity] { [] } } +#endif diff --git a/BluetoothExplorer.swiftpm/AppIntents/Intent/DiscoverCharacteristicsIntent.swift b/BluetoothExplorer.swiftpm/AppIntents/Intent/DiscoverCharacteristicsIntent.swift index 83b0016..a379272 100644 --- a/BluetoothExplorer.swiftpm/AppIntents/Intent/DiscoverCharacteristicsIntent.swift +++ b/BluetoothExplorer.swiftpm/AppIntents/Intent/DiscoverCharacteristicsIntent.swift @@ -5,6 +5,7 @@ // Created by Alsey Coleman Miller on 11/22/22. // +#if canImport(AppIntents) && canImport(SwiftUI) import AppIntents import SwiftUI import Bluetooth @@ -31,15 +32,15 @@ struct DiscoverCharacteristicsIntent: AppIntent { @MainActor func perform() async throws -> some IntentResult { - let store = Store.shared + let store = BluetoothExplorerApp.store guard let peripheral = store.scanResults.keys.first(where: { $0.id == service.id.peripheral }) else { throw CentralError.unknownPeripheral } guard let service = store.services[peripheral, default: []].first(where: { $0.id.hashValue == service.id.attributeID && $0.peripheral.id == service.id.peripheral }) else { throw CentralError.invalidAttribute(BluetoothUUID(rawValue: service.uuid) ?? .bit128(.zero)) } - try await store.central.wait(for: .poweredOn, warning: 1, timeout: 2) - if store.connected.contains(peripheral) == false { + try await store.central.wait(warning: 1, timeout: 2) + if await store.connected.contains(peripheral) == false { try await store.connect(to: peripheral) } try await store.discoverCharacteristics(for: service) @@ -52,3 +53,4 @@ struct DiscoverCharacteristicsIntent: AppIntent { ) } } +#endif diff --git a/BluetoothExplorer.swiftpm/AppIntents/Intent/DiscoverServicesIntent.swift b/BluetoothExplorer.swiftpm/AppIntents/Intent/DiscoverServicesIntent.swift index 7bd4aa8..fd1287e 100644 --- a/BluetoothExplorer.swiftpm/AppIntents/Intent/DiscoverServicesIntent.swift +++ b/BluetoothExplorer.swiftpm/AppIntents/Intent/DiscoverServicesIntent.swift @@ -5,6 +5,7 @@ // Created by Alsey Coleman Miller on 11/22/22. // +#if canImport(AppIntents) && canImport(SwiftUI) import AppIntents import SwiftUI import Bluetooth @@ -31,12 +32,12 @@ struct DiscoverServicesIntent: AppIntent { @MainActor func perform() async throws -> some IntentResult { - let store = Store.shared + let store = BluetoothExplorerApp.store guard let peripheral = store.scanResults.keys.first(where: { $0.id == device.id }) else { throw CentralError.unknownPeripheral } - try await store.central.wait(for: .poweredOn, warning: 1, timeout: 2) - if store.connected.contains(peripheral) == false { + try await store.central.wait(warning: 1, timeout: 2) + if await store.connected.contains(peripheral) == false { try await store.connect(to: peripheral) } try await store.discoverServices(for: peripheral) @@ -49,3 +50,4 @@ struct DiscoverServicesIntent: AppIntent { ) } } +#endif diff --git a/BluetoothExplorer.swiftpm/AppIntents/Intent/ScanIntent.swift b/BluetoothExplorer.swiftpm/AppIntents/Intent/ScanIntent.swift index 4db5d3b..da58cb9 100644 --- a/BluetoothExplorer.swiftpm/AppIntents/Intent/ScanIntent.swift +++ b/BluetoothExplorer.swiftpm/AppIntents/Intent/ScanIntent.swift @@ -5,6 +5,7 @@ // Created by Alsey Coleman Miller on 11/20/22. // +#if canImport(AppIntents) && canImport(SwiftUI) import AppIntents import SwiftUI import Bluetooth @@ -51,8 +52,8 @@ struct ScanIntent: AppIntent { @MainActor func perform() async throws -> some IntentResult { let services = Set(self.services.compactMap({ BluetoothUUID(rawValue: $0) })) - let store = Store.shared - try await store.central.wait(for: .poweredOn, warning: 1, timeout: 2) + let store = BluetoothExplorerApp.store + try await store.central.wait(warning: 1, timeout: 2) try await store.scan( with: services, filterDuplicates: filterDuplicates @@ -115,3 +116,4 @@ private extension ScanIntent { }.padding() } } +#endif diff --git a/BluetoothExplorer.swiftpm/AppIntents/Shortcuts.swift b/BluetoothExplorer.swiftpm/AppIntents/Shortcuts.swift index b822e3c..5a2190a 100644 --- a/BluetoothExplorer.swiftpm/AppIntents/Shortcuts.swift +++ b/BluetoothExplorer.swiftpm/AppIntents/Shortcuts.swift @@ -5,6 +5,7 @@ // Created by Alsey Coleman Miller on 11/20/22. // +#if canImport(AppIntents) import AppIntents @available(macOS 13, iOS 16, watchOS 9, tvOS 16, *) @@ -26,3 +27,4 @@ struct AppShortcuts: AppShortcutsProvider { ) } } +#endif diff --git a/BluetoothExplorer.swiftpm/Model/AttributeValue.swift b/BluetoothExplorer.swiftpm/Model/AttributeValue.swift index 69b15bd..aa37a6d 100644 --- a/BluetoothExplorer.swiftpm/Model/AttributeValue.swift +++ b/BluetoothExplorer.swiftpm/Model/AttributeValue.swift @@ -7,25 +7,35 @@ import Foundation -enum AttributeValueType: Equatable, Hashable { +public enum AttributeValueType: Equatable, Hashable { case read case write case notification } -struct AttributeValue: Equatable, Hashable { +public struct AttributeValue: Equatable, Hashable { - let date: Date + public let date: Date - let type: AttributeValueType + public let type: AttributeValueType - let data: Data + public let data: Data + + public init( + date: Date, + type: AttributeValueType, + data: Data + ) { + self.date = date + self.type = type + self.data = data + } } extension AttributeValue: Identifiable { - var id: Date { + public var id: Date { date } } diff --git a/BluetoothExplorer.swiftpm/Model/Mock/MockAdvertisement.swift b/BluetoothExplorer.swiftpm/Model/Mock/MockAdvertisement.swift index e2f1671..be3c573 100644 --- a/BluetoothExplorer.swiftpm/Model/Mock/MockAdvertisement.swift +++ b/BluetoothExplorer.swiftpm/Model/Mock/MockAdvertisement.swift @@ -12,34 +12,35 @@ import Bluetooth import GATT /// Mock Advertisement Data -struct MockAdvertisementData: AdvertisementData { +internal struct MockAdvertisementData: AdvertisementData { /// The local name of a peripheral. - let localName: String? + internal let localName: String? /// The Manufacturer data of a peripheral. - let manufacturerData: ManufacturerSpecificData? + internal let manufacturerData: ManufacturerSpecificData? /// This value is available if the broadcaster (peripheral) provides its Tx power level in its advertising packet. /// Using the RSSI value and the Tx power level, it is possible to calculate path loss. - let txPowerLevel: Double? + internal let txPowerLevel: Double? /// Service-specific advertisement data. - let serviceData: [BluetoothUUID: Data]? + internal let serviceData: [BluetoothUUID: Data]? /// An array of service UUIDs - let serviceUUIDs: [BluetoothUUID]? + internal let serviceUUIDs: [BluetoothUUID]? /// An array of one or more `BluetoothUUID`, representing Service UUIDs. - let solicitedServiceUUIDs: [BluetoothUUID]? + internal let solicitedServiceUUIDs: [BluetoothUUID]? - init(localName: String? = nil, - manufacturerData: ManufacturerSpecificData? = nil, - txPowerLevel: Double? = nil, - serviceData: [BluetoothUUID : Data]? = nil, - serviceUUIDs: [BluetoothUUID]? = nil, - solicitedServiceUUIDs: [BluetoothUUID]? = nil) { - + internal init( + localName: String? = nil, + manufacturerData: ManufacturerSpecificData? = nil, + txPowerLevel: Double? = nil, + serviceData: [BluetoothUUID : Data]? = nil, + serviceUUIDs: [BluetoothUUID]? = nil, + solicitedServiceUUIDs: [BluetoothUUID]? = nil + ) { self.localName = localName self.manufacturerData = manufacturerData self.txPowerLevel = txPowerLevel @@ -51,7 +52,7 @@ struct MockAdvertisementData: AdvertisementData { extension MockAdvertisementData { - static let beacon = MockAdvertisementData( + internal static let beacon = MockAdvertisementData( localName: nil, manufacturerData: ManufacturerSpecificData(data: Data([0x4c, 0x00, 0x02, 0x15, 0xb9, 0x40, 0x7f, 0x30, 0xf5, 0xf8, 0x46, 0x6e, 0xaf, 0xf9, 0x25, 0x55, 0x6b, 0x57, 0xfe, 0x6d, 0x29, 0x4c, 0x90, 0x39, 0x74])), txPowerLevel: nil, @@ -60,7 +61,7 @@ extension MockAdvertisementData { solicitedServiceUUIDs: nil ) - static let smartThermostat = MockAdvertisementData( + internal static let smartThermostat = MockAdvertisementData( localName: "CLI-W200", manufacturerData: ManufacturerSpecificData(data: Data([0xd9, 0x01, 0x01, 0x02, 0x00, 0x00, 0x8c, 0x85, 0x90, 0xcb, 0x31, 0x74, 0x00, 0x60])), txPowerLevel: nil, diff --git a/BluetoothExplorer.swiftpm/Model/Mock/MockAttributes.swift b/BluetoothExplorer.swiftpm/Model/Mock/MockAttributes.swift index a62aa01..3edfba1 100644 --- a/BluetoothExplorer.swiftpm/Model/Mock/MockAttributes.swift +++ b/BluetoothExplorer.swiftpm/Model/Mock/MockAttributes.swift @@ -6,15 +6,14 @@ // #if DEBUG -import SwiftUI import Bluetooth import GATT -typealias MockService = GATT.Service -typealias MockCharacteristic = GATT.Characteristic -typealias MockDescriptor = GATT.Descriptor +internal typealias MockService = GATT.Service +internal typealias MockCharacteristic = GATT.Characteristic +internal typealias MockDescriptor = GATT.Descriptor -extension MockService { +internal extension MockService { static var deviceInformation: MockService { Service( @@ -41,7 +40,7 @@ extension MockService { } } -extension MockCharacteristic { +internal extension MockCharacteristic { static var deviceName: MockCharacteristic { Characteristic( @@ -96,7 +95,7 @@ extension MockCharacteristic { ) } -extension MockDescriptor { +internal extension MockDescriptor { static func clientCharacteristicConfiguration(_ peripheral: Peripheral) -> MockDescriptor { Descriptor( diff --git a/BluetoothExplorer.swiftpm/Model/Mock/MockCentral.swift b/BluetoothExplorer.swiftpm/Model/Mock/MockCentral.swift index 19cd8bb..1a0242e 100644 --- a/BluetoothExplorer.swiftpm/Model/Mock/MockCentral.swift +++ b/BluetoothExplorer.swiftpm/Model/Mock/MockCentral.swift @@ -9,77 +9,96 @@ import Foundation import Bluetooth import GATT -import DarwinGATT -internal final class MockCentral: CentralManager, @unchecked Sendable { +@MainActor +final class MockCentral: CentralManager, @unchecked Sendable { /// Central Peripheral Type typealias Peripheral = GATT.Peripheral /// Central Advertisement Type - typealias Advertisement = MockAdvertisementData + internal typealias Advertisement = MockAdvertisementData /// Central Attribute ID (Handle) - typealias AttributeID = UInt16 + internal typealias AttributeID = UInt16 + + internal nonisolated(unsafe) var log: (@Sendable (String) -> ())? - lazy var state = AsyncStream { [unowned self] in - $0.yield(.poweredOn) - } - - var log: (@Sendable (String) -> ())? - - var peripherals: [GATT.Peripheral : Bool] { + internal var peripherals: [GATT.Peripheral : Bool] { get async { var peripherals = [Peripheral: Bool]() - for scanData in _state.scanData { - peripherals[scanData.peripheral] = _state.connected.contains(scanData.peripheral) + for scanData in state.scanData { + peripherals[scanData.peripheral] = state.connected.contains(scanData.peripheral) } return peripherals } } - var _state = State() + internal var isEnabled: Bool { + get async { + state.isEnabled + } + } + + private var state = State() private var continuation = Continuation() - init() { } + internal init() { + Task { + try await Task.sleep(for: .seconds(1)) + updateState { + $0.isEnabled = true + } + } + } /// Scans for peripherals that are advertising services. - func scan(filterDuplicates: Bool) -> AsyncCentralScan { + internal func scan( + with services: Set, + filterDuplicates: Bool + ) -> AsyncCentralScan { return AsyncCentralScan { continuation in - self._state.scanData.forEach { - continuation($0) + for scanData in await self.state.scanData { + try await Task.sleep(for: .seconds(1)) + continuation(scanData) } } } + internal func scan( + filterDuplicates: Bool + ) -> AsyncCentralScan { + scan(with: [], filterDuplicates: filterDuplicates) + } + /// Connect to the specified device - func connect(to peripheral: Peripheral) async throws { - _state.connected.insert(peripheral) + internal func connect(to peripheral: Peripheral) async throws { + state.connected.insert(peripheral) } /// Disconnect the specified device. - func disconnect(_ peripheral: Peripheral) { - _state.connected.remove(peripheral) + internal func disconnect(_ peripheral: Peripheral) async { + state.connected.remove(peripheral) } /// Disconnect all connected devices. - func disconnectAll() { - _state.connected.removeAll() + internal func disconnectAll() { + state.connected.removeAll() } /// Discover Services - func discoverServices( + internal func discoverServices( _ services: Set = [], for peripheral: Peripheral ) async throws -> [Service] { - return _state.characteristics + return state.characteristics .keys .filter { $0.peripheral == peripheral } .sorted(by: { $0.id < $1.id }) } - public func discoverIncludedServices( + internal func discoverIncludedServices( _ services: Set = [], for service: Service ) async throws -> [Service] { @@ -87,14 +106,14 @@ internal final class MockCentral: CentralManager, @unchecked Sendable { } /// Discover Characteristics for service - func discoverCharacteristics( + internal nonisolated func discoverCharacteristics( _ characteristics: Set = [], for service: Service ) async throws -> [Characteristic] { - guard _state.connected.contains(service.peripheral) else { + guard await state.connected.contains(service.peripheral) else { throw CentralError.disconnected } - guard let characteristics = _state.characteristics[service] else { + guard let characteristics = await state.characteristics[service] else { throw CentralError.invalidAttribute(service.uuid) } return characteristics @@ -102,22 +121,22 @@ internal final class MockCentral: CentralManager, @unchecked Sendable { } /// Read Characteristic Value - func readValue( + internal nonisolated func readValue( for characteristic: Characteristic ) async throws -> Data { - guard _state.connected.contains(characteristic.peripheral) else { + guard await state.connected.contains(characteristic.peripheral) else { throw CentralError.disconnected } - return _state.characteristicValues[characteristic] ?? Data() + return await state.characteristicValues[characteristic] ?? Data() } /// Write Characteristic Value - func writeValue( + internal nonisolated func writeValue( _ data: Data, for characteristic: Characteristic, withResponse: Bool = true ) async throws { - guard _state.connected.contains(characteristic.peripheral) else { + guard await state.connected.contains(characteristic.peripheral) else { throw CentralError.disconnected } if withResponse { @@ -130,50 +149,56 @@ internal final class MockCentral: CentralManager, @unchecked Sendable { } } // write - _state.characteristicValues[characteristic] = data + await updateState { state in + state.characteristicValues[characteristic] = data + } } /// Discover descriptors - func discoverDescriptors( + internal nonisolated func discoverDescriptors( for characteristic: Characteristic ) async throws -> [Descriptor] { - guard _state.connected.contains(characteristic.peripheral) else { + guard await state.connected.contains(characteristic.peripheral) else { throw CentralError.disconnected } - return _state.descriptors[characteristic] ?? [] + return await state.descriptors[characteristic] ?? [] } /// Read descriptor - func readValue( + internal nonisolated func readValue( for descriptor: Descriptor ) async throws -> Data { - guard _state.connected.contains(descriptor.peripheral) else { + guard await state.connected.contains(descriptor.peripheral) else { throw CentralError.disconnected } - return _state.descriptorValues[descriptor] ?? Data() + return await state.descriptorValues[descriptor] ?? Data() } /// Write descriptor - func writeValue( + internal nonisolated func writeValue( _ data: Data, for descriptor: Descriptor ) async throws { - guard _state.connected.contains(descriptor.peripheral) else { + guard await state.connected.contains(descriptor.peripheral) else { throw CentralError.disconnected } - _state.descriptorValues[descriptor] = data + await updateState { state in + state.descriptorValues[descriptor] = data + } } - func notify( + internal nonisolated func notify( for characteristic: GATT.Characteristic ) async throws -> AsyncCentralNotifications { - guard _state.connected.contains(characteristic.peripheral) else { + guard await state.connected.contains(characteristic.peripheral) else { throw CentralError.disconnected } return AsyncCentralNotifications { [unowned self] continuation in - if let notifications = self._state.notifications[characteristic] { + if let notifications = await self.state.notifications[characteristic] { for notification in notifications { - try await Task.sleep(nanoseconds: 1_000_000_000) + if #available(iOS 16.0, *) { + try await Task.sleep(for: .seconds(1)) + } continuation(notification) } } @@ -181,22 +206,30 @@ internal final class MockCentral: CentralManager, @unchecked Sendable { } /// Read MTU - func maximumTransmissionUnit(for peripheral: Peripheral) async throws -> MaximumTransmissionUnit { - guard _state.connected.contains(peripheral) else { + internal func maximumTransmissionUnit(for peripheral: Peripheral) async throws -> MaximumTransmissionUnit { + guard state.connected.contains(peripheral) else { throw CentralError.disconnected } return .default } // Read RSSI - func rssi(for peripheral: Peripheral) async throws -> RSSI { + internal func rssi(for peripheral: Peripheral) async throws -> RSSI { return .init(rawValue: 127)! } } +private extension MockCentral { + + func updateState(_ body: (inout State) -> ()) { + body(&state) + } +} + internal extension MockCentral { struct State { + var isEnabled = false var isScanning = false var scanData: [MockScanData] = [.beacon, .smartThermostat] var connected = Set() @@ -253,7 +286,6 @@ internal extension MockCentral { struct Continuation { var scan: AsyncThrowingStream, Error>.Continuation? - var isScanning: AsyncStream.Continuation? } } #endif diff --git a/BluetoothExplorer.swiftpm/Model/Mock/MockScanData.swift b/BluetoothExplorer.swiftpm/Model/Mock/MockScanData.swift index 981808a..28c6925 100644 --- a/BluetoothExplorer.swiftpm/Model/Mock/MockScanData.swift +++ b/BluetoothExplorer.swiftpm/Model/Mock/MockScanData.swift @@ -11,9 +11,9 @@ import Foundation import Bluetooth import GATT -typealias MockScanData = ScanData +internal typealias MockScanData = ScanData -extension MockScanData { +internal extension MockScanData { static let beacon = MockScanData( peripheral: .beacon, @@ -32,7 +32,7 @@ extension MockScanData { ) } -extension Peripheral { +internal extension Peripheral { static var beacon: Peripheral { Peripheral(id: BluetoothAddress(rawValue: "00:AA:AB:03:10:01")!) diff --git a/BluetoothExplorer.swiftpm/Model/NativeCentral.swift b/BluetoothExplorer.swiftpm/Model/NativeCentral.swift index 387e126..9c79a08 100644 --- a/BluetoothExplorer.swiftpm/Model/NativeCentral.swift +++ b/BluetoothExplorer.swiftpm/Model/NativeCentral.swift @@ -7,73 +7,44 @@ // import Foundation -import SwiftUI import Bluetooth import GATT +#if canImport(DarwinGATT) import DarwinGATT +#endif -#if os(Android) || os(iOS) && targetEnvironment(simulator) +#if os(Android) +typealias NativeCentral = MockCentral +#elseif os(iOS) && targetEnvironment(simulator) typealias NativeCentral = MockCentral - -extension NativeCentral { - - private struct Cache { - static let central = MockCentral() - } - - static var shared: NativeCentral { - return Cache.central - } -} - #elseif canImport(Darwin) typealias NativeCentral = DarwinCentral - -extension NativeCentral { - - private struct Cache { - static let central = DarwinCentral( - options: .init(showPowerAlert: true) - ) - } - - static var shared: NativeCentral { - return Cache.central - } -} #else #error("Platform not supported") #endif -#if canImport(Darwin) -public extension NativeCentral { +extension NativeCentral { /// Wait for CoreBluetooth to be ready. func wait( - for state: DarwinBluetoothState, warning: Int = 3, timeout: Int = 10 ) async throws { var powerOnWait = 0 - var currentState: DarwinBluetoothState + var currentState: Bool repeat { - currentState = await self.state + currentState = await self.isEnabled // inform user after 3 seconds if powerOnWait == warning { - NSLog("Waiting for CoreBluetooth to be ready, please turn on Bluetooth") + NSLog("Waiting for Bluetooth to be ready, please turn on Bluetooth") } // sleep for 1s - if #available(macOS 13, iOS 16, watchOS 9, tvOS 16, *) { - try await Task.sleep(for: .seconds(1)) - } else { - try await Task.sleep(nanoseconds: 1_000_000_000) - } + try await Task.sleep(for: .seconds(1)) powerOnWait += 1 guard powerOnWait < timeout else { - throw DarwinCentralError.invalidState(currentState) + throw CocoaError(.featureUnsupported) // TODO: Update error } - } while currentState != state + } while currentState != true } } -#endif diff --git a/BluetoothExplorer.swiftpm/Model/Store.swift b/BluetoothExplorer.swiftpm/Model/Store.swift index 80ac143..8486882 100644 --- a/BluetoothExplorer.swiftpm/Model/Store.swift +++ b/BluetoothExplorer.swiftpm/Model/Store.swift @@ -7,15 +7,14 @@ // import Foundation -@preconcurrency import Combine -import SwiftUI +import Observation import Bluetooth import GATT -import DarwinGATT /// Store @MainActor -final class Store: ObservableObject, @unchecked Sendable { +@Observable +public final class Store: @unchecked Sendable { typealias Central = NativeCentral @@ -33,64 +32,49 @@ final class Store: ObservableObject, @unchecked Sendable { // MARK: - Properties - @Published private(set) var activity = [Peripheral: Bool]() - @Published - private(set) var state: DarwinBluetoothState = .unknown + private(set) var isEnabled = false - @Published private(set) var scanResults = [Peripheral: ScanResult]() var isScanning: Bool { self.scanStream?.isScanning ?? false } - @Published - private(set) var connected = Set() + private(set) var connected: Set = [] - @Published private(set) var services = [Peripheral: [Service]]() - @Published private(set) var characteristics = [Service: [Characteristic]]() - @Published private(set) var includedServices = [Service: [Service]]() - - @Published + private(set) var descriptors = [Characteristic: [Descriptor]]() - - @Published + private(set) var characteristicValues = [Characteristic: Cache]() - @Published private(set) var descriptorValues = [Descriptor: Cache]() - - @Published + private(set) var isNotifying = [Characteristic: Bool]() internal let central: Central - @Published private var scanStream: AsyncCentralScan? - - private var centralObserver: AnyCancellable? - + // MARK: - Initialization - deinit { - centralObserver?.cancel() + public convenience init() { + let central = Central() + self.init(central: central) } init(central: Central) { self.central = central - observeValues() setupLog() + observeValues() } - - static let shared = Store(central: .shared) - + // MARK: - Methods private func setupLog() { @@ -98,29 +82,30 @@ final class Store: ObservableObject, @unchecked Sendable { } private func observeValues() { - centralObserver = central.objectWillChange - .receive(on: DispatchQueue.main) - .sink { [unowned self] _ in - self.objectWillChange.send() - Task { [unowned self] in + Task { [weak self] in + do { + while let self { + try await Task.sleep(for: .seconds(1)) await self.updateState() - await self.updateConnected() } } - + catch { + + } + } } private func updateState() async { assert(Thread.isMainThread) - let oldValue = self.state - let newValue = await self.central.state + let oldValue = self.isEnabled + let newValue = await self.central.isEnabled guard newValue != oldValue else { return } // update value - self.state = newValue + self.isEnabled = newValue // start scanning when powered on - guard newValue == .poweredOn else { + guard newValue else { return } do { try await self.scan() } @@ -137,6 +122,14 @@ final class Store: ObservableObject, @unchecked Sendable { self.connected = newValue } + public func log(_ message: String) { + print(message) + } + + func log(error: any Error) { + print("Error", error.localizedDescription) + } + func scan( with services: Set = [], filterDuplicates: Bool = true @@ -152,6 +145,7 @@ final class Store: ObservableObject, @unchecked Sendable { for try await scanData in stream { await found(scanData: scanData) } + self.scanStream = nil } } @@ -159,7 +153,11 @@ final class Store: ObservableObject, @unchecked Sendable { private func found(scanData: ScanData) async { var cache = scanResults[scanData.peripheral] ?? ScanDataCache(scanData: scanData) cache += scanData - #if canImport(CoreBluetooth) + #if os(Android) + + #elseif os(iOS) && targetEnvironment(simulator) + + #elseif canImport(CoreBluetooth) cache.name = try? await central.name(for: scanData.peripheral) for serviceUUID in scanData.advertisementData.overflowServiceUUIDs ?? [] { cache.overflowServiceUUIDs.insert(serviceUUID) @@ -180,8 +178,6 @@ final class Store: ObservableObject, @unchecked Sendable { scanStream?.stop() } try await central.connect(to: peripheral) - assert(Thread.isMainThread) - connected.insert(peripheral) } func disconnect(_ peripheral: Central.Peripheral) async { diff --git a/BluetoothExplorer.swiftpm/Package.resolved b/BluetoothExplorer.swiftpm/Package.resolved deleted file mode 100644 index a9b0dae..0000000 --- a/BluetoothExplorer.swiftpm/Package.resolved +++ /dev/null @@ -1,33 +0,0 @@ -{ - "originHash" : "6f441ddd41cfb68cb844b72f1d636b1c13c69022a658e28afd7edb91e9200e78", - "pins" : [ - { - "identity" : "bluetooth", - "kind" : "remoteSourceControl", - "location" : "https://github.com/PureSwift/Bluetooth.git", - "state" : { - "revision" : "52f228b819e8ef920e4232cc37c9a84ffbbc5707", - "version" : "7.2.3" - } - }, - { - "identity" : "gatt", - "kind" : "remoteSourceControl", - "location" : "https://github.com/PureSwift/GATT.git", - "state" : { - "branch" : "master", - "revision" : "aacc05662e4fec5fdf22c666d14c0a79763f40f6" - } - }, - { - "identity" : "swift-syntax", - "kind" : "remoteSourceControl", - "location" : "https://github.com/swiftlang/swift-syntax.git", - "state" : { - "revision" : "f99ae8aa18f0cf0d53481901f88a0991dc3bd4a2", - "version" : "601.0.1" - } - } - ], - "version" : 3 -} diff --git a/BluetoothExplorer.swiftpm/Package.swift b/BluetoothExplorer.swiftpm/Package.swift index 7cc0fec..a54f614 100644 --- a/BluetoothExplorer.swiftpm/Package.swift +++ b/BluetoothExplorer.swiftpm/Package.swift @@ -10,14 +10,14 @@ import AppleProductTypes let package = Package( name: "BluetoothExplorer", platforms: [ - .iOS("15.2"), - .macOS("12.0") + .iOS("17.0"), + .macOS("15.0") ], products: [ .iOSApplication( name: "BluetoothExplorer", targets: ["BluetoothExplorer"], - bundleIdentifier: "com.pureswift.bluetooth-explorer", + bundleIdentifier: "org.pureswift.bluetoothexplorer", teamIdentifier: "4W79SG34MW", displayVersion: "1.0", bundleVersion: "5", @@ -59,5 +59,5 @@ let package = Package( // Xcode only settings #if os(macOS) package.dependencies[0] = .package(url: "https://github.com/PureSwift/GATT.git", branch: "master") -package.platforms = [.iOS("15.0")] +package.platforms = [.iOS("17.0")] #endif diff --git a/BluetoothExplorer.swiftpm/ViewModel/CentralListViewModel.swift b/BluetoothExplorer.swiftpm/ViewModel/CentralListViewModel.swift new file mode 100644 index 0000000..2a421f2 --- /dev/null +++ b/BluetoothExplorer.swiftpm/ViewModel/CentralListViewModel.swift @@ -0,0 +1,196 @@ +// +// CentralListViewModel.swift +// BluetoothExplorer +// +// Created by Alsey Coleman Miller on 7/11/25. +// + +import Foundation +import Observation +import Bluetooth +import GATT + +@MainActor +@Observable +public final class CentralListViewModel { + + let store: Store + + var scanToggleTask: Task? + + public init(store: Store) { + self.store = store + } + + var state: State { + State( + .init( + store: store, + didToggle: scanToggleTask != nil + ) + ) + } + + public var scanResults: [ScanResult] { + state.scanResults + } + + public var isEnabled: Bool { + state.isEnabled + } + + public var isScanning: Bool { + state.isScanning + } + + public var canToggleScan: Bool { + state.canToggleScan + } + + public func scanToggle() { + scanToggleTask = Task { + defer { scanToggleTask = nil } + if store.isScanning { + await store.stopScan() + } else { + do { + try await store.scan() + } + catch { + store.log(error: error) + } + } + } + } +} + +public extension CentralListViewModel { + + struct State: Equatable, Hashable, Sendable { + + let input: Input + + init(_ input: Input) { + self.input = input + } + + public var scanResults: [ScanResult] { + input.scanResults + .values + .lazy + .sorted(by: { $0.id.description < $1.id.description }) + .sorted(by: { ($0.name ?? "") < ($1.name ?? "") }) + .sorted(by: { $0.name != nil && $1.name == nil }) + .sorted(by: { $0.beacon != nil && $1.beacon == nil }) + .map { ScanResult($0) } + } + + public var isEnabled: Bool { + input.isEnabled + } + + public var isScanning: Bool { + input.isScanning + } + + public var canToggleScan: Bool { + input.didToggle == false && input.isEnabled + } + } +} + +public extension CentralListViewModel.State { + + struct Input: Equatable, Hashable, Sendable { + + let scanResults: [Peripheral: Store.ScanResult] + + let isEnabled: Bool + + let isScanning: Bool + + let didToggle: Bool + + init(scanResults: [Peripheral : Store.ScanResult], isEnabled: Bool, isScanning: Bool, didToggle: Bool) { + self.scanResults = scanResults + self.isEnabled = isEnabled + self.isScanning = isScanning + self.didToggle = didToggle + } + + @MainActor + init(store: Store, didToggle: Bool) { + self.scanResults = store.scanResults + self.isEnabled = store.isEnabled + self.isScanning = store.isScanning + self.didToggle = didToggle + } + } +} + +public extension CentralListViewModel { + + struct ScanResult: Equatable, Hashable, Sendable, Identifiable { + + typealias ScanData = ScanDataCache + + let scanData: ScanData + + init(_ scanData: ScanData) { + self.scanData = scanData + } + + public var id: String { + scanData.id.description + } + + public var name: String { + scanData.name ?? (beacon != nil ? "iBeacon" : "Unknown") + } + + public var company: String? { + scanData.manufacturerData?.companyIdentifier.name + } + + public var services: String? { + let services = scanData.serviceUUIDs + .sorted(by: { $0.description < $1.description }) + .map { $0.metadata?.name ?? $0.rawValue } + guard services.isEmpty == false + else { return nil } + return "Services: " + services.reduce("", { ($0.isEmpty ? "" : ", ") + $1 }) + } + + public var beacon: CentralListViewModel.Beacon? { + scanData.beacon.flatMap(Beacon.init) + } + } +} + +public extension CentralListViewModel { + + struct Beacon: Equatable, Hashable, Sendable { + + let beacon: AppleBeacon + + init(_ beacon: AppleBeacon) { + self.beacon = beacon + } + + public var uuid: String { + beacon.uuid.uuidString + } + + public var major: String { + "Major: 0x\(beacon.major.toHexadecimal())" + } + + public var minor: String { + "Minor: 0x\(beacon.minor.toHexadecimal())" + } + + public var rssi: String { + "RSSI: \(beacon.rssi)" + } + } +} diff --git a/BluetoothExplorer.swiftpm/ViewModel/PeripheralViewModel.swift b/BluetoothExplorer.swiftpm/ViewModel/PeripheralViewModel.swift new file mode 100644 index 0000000..edd950a --- /dev/null +++ b/BluetoothExplorer.swiftpm/ViewModel/PeripheralViewModel.swift @@ -0,0 +1,56 @@ +// +// PeripheralViewModel.swift +// bluetooth-explorer +// +// Created by Alsey Coleman Miller on 7/12/25. +// + +import Foundation +import Observation +import Bluetooth +import GATT + +@MainActor +@Observable +public final class PeripheralViewModel { + + let store: Store + + let peripheral: Store.Peripheral + + init(store: Store, peripheral: Peripheral) { + self.store = store + self.peripheral = peripheral + } + + public convenience init(store: Store, peripheral: String) { + guard let peripheral = store.scanResults.first(where: { $0.key.description == peripheral })?.key else { + fatalError("Invalid peripheral: \(peripheral)") + } + self.init(store: store, peripheral: peripheral) + } + + var title: String { + store.scanResults[peripheral]?.name ?? "Device" + } + + var isConnected: Bool { + store.connected.contains(peripheral) + } + + var services: [Store.Service] { + store.services[peripheral] ?? [] + } + + var showActivity: Bool { + store.activity[peripheral] ?? false + } + + public func connect() { + + } + + public func reload() { + + } +} diff --git a/BluetoothExplorer.swiftpm/Views/AsyncButton.swift b/BluetoothExplorer.swiftpm/Views/AsyncButton.swift index c635adb..9ac3a3a 100644 --- a/BluetoothExplorer.swiftpm/Views/AsyncButton.swift +++ b/BluetoothExplorer.swiftpm/Views/AsyncButton.swift @@ -5,16 +5,17 @@ // Created by Alsey Coleman Miller on 23/12/21. // +#if canImport(SwiftUI) import SwiftUI +enum ActionOption: CaseIterable { + case disableButton + case showProgressView +} + // https://www.swiftbysundell.com/articles/building-an-async-swiftui-button/ struct AsyncButton: View { - enum ActionOption: CaseIterable { - case disableButton - case showProgressView - } - var action: () async -> Void var actionOptions = Set(ActionOption.allCases) @@ -60,3 +61,4 @@ struct AsyncButton: View { .disabled(isDisabled) } } +#endif diff --git a/BluetoothExplorer.swiftpm/Views/AttributeCell.swift b/BluetoothExplorer.swiftpm/Views/AttributeCell.swift index c074d8b..92ac40e 100644 --- a/BluetoothExplorer.swiftpm/Views/AttributeCell.swift +++ b/BluetoothExplorer.swiftpm/Views/AttributeCell.swift @@ -5,6 +5,7 @@ // Created by Alsey Coleman Miller on 19/12/21. // +#if canImport(SwiftUI) import SwiftUI import Bluetooth import GATT @@ -47,3 +48,4 @@ struct AttributeCell_Preview: PreviewProvider { } } #endif +#endif diff --git a/BluetoothExplorer.swiftpm/Views/AttributeValueCell.swift b/BluetoothExplorer.swiftpm/Views/AttributeValueCell.swift index 6bd45ca..3f825bf 100644 --- a/BluetoothExplorer.swiftpm/Views/AttributeValueCell.swift +++ b/BluetoothExplorer.swiftpm/Views/AttributeValueCell.swift @@ -5,6 +5,7 @@ // Created by Alsey Coleman Miller on 22/12/21. // +#if canImport(SwiftUI) import Foundation import Bluetooth import SwiftUI @@ -114,3 +115,4 @@ extension AttributeValueCell { ) } #endif +#endif diff --git a/BluetoothExplorer.swiftpm/Views/AttributeValuesSection.swift b/BluetoothExplorer.swiftpm/Views/AttributeValuesSection.swift index 74f1a32..591bb2e 100644 --- a/BluetoothExplorer.swiftpm/Views/AttributeValuesSection.swift +++ b/BluetoothExplorer.swiftpm/Views/AttributeValuesSection.swift @@ -5,6 +5,7 @@ // Created by Alsey Coleman Miller on 22/12/21. // +#if canImport(SwiftUI) import Foundation import Bluetooth import SwiftUI @@ -100,3 +101,4 @@ struct AttributeValuesSection_Preview: PreviewProvider { } #endif */ +#endif diff --git a/BluetoothExplorer.swiftpm/Views/CentralCell.swift b/BluetoothExplorer.swiftpm/Views/CentralCell.swift index 20ebd26..5fed982 100644 --- a/BluetoothExplorer.swiftpm/Views/CentralCell.swift +++ b/BluetoothExplorer.swiftpm/Views/CentralCell.swift @@ -5,45 +5,48 @@ // Created by Alsey Coleman Miller on 18/12/21. // +#if canImport(SwiftUI) import SwiftUI -import Bluetooth -import GATT -struct CentralCell : View { +public struct CentralCell: View { - let scanData: ScanDataCache + let item: CentralListViewModel.ScanResult - var body: some View { + public init(_ item: CentralListViewModel.ScanResult) { + self.item = item + } + + public var body: some View { VStack(alignment: .leading, spacing: 2.0) { - nameText + Text(verbatim: item.name) .font(.title3) .foregroundColor(.primary) #if DEBUG - Text(verbatim: "\(scanData.id)") + Text(verbatim: item.id) .font(.footnote) .foregroundColor(.secondary) #endif - if let beacon = self.beacon { - Text("\(beacon.uuid)") + if let beacon = item.beacon { + Text(verbatim: beacon.uuid) .font(.subheadline) .foregroundColor(.primary) - Text("Major: 0x\(beacon.major.toHexadecimal())") + Text(verbatim: beacon.major) .font(.subheadline) .foregroundColor(.primary) - Text("Minor: 0x\(beacon.minor.toHexadecimal())") + Text(verbatim: beacon.minor) .font(.subheadline) .foregroundColor(.primary) - Text("RSSI: \(beacon.rssi)") + Text(verbatim: beacon.rssi) .font(.subheadline) .foregroundColor(.primary) } - if let company = self.company, beacon == nil { + if let company = item.company, item.beacon == nil { Text(verbatim: company) .font(.subheadline) .foregroundColor(.primary) } - if let services = services { - Text("Services: \(services)") + if let services = item.services { + Text(verbatim: services) .font(.subheadline) .foregroundColor(.primary) } @@ -51,46 +54,4 @@ struct CentralCell : View { .padding() } } - -private struct CentralCellCache { - static var listFormatter: ListFormatter { ListFormatter() } -} - -extension CentralCell { - - var nameText: Text { - scanData.name.flatMap { Text(verbatim: $0) } ?? (beacon != nil ? Text("iBeacon") : Text("Unknown")) - } - - var company: String? { - scanData.manufacturerData?.companyIdentifier.name - } - - var services: String? { - let services = scanData.serviceUUIDs - .sorted(by: { $0.description < $1.description }) - .map { $0.metadata?.name ?? $0.rawValue } - guard services.isEmpty == false - else { return nil } - return CentralCellCache.listFormatter.string(from: services) - } - - var beacon: AppleBeacon? { - return scanData.beacon - } -} - -#if DEBUG -struct CentralCell_Preview: PreviewProvider { - static var previews: some View { - NavigationView { - List { - CentralCell(scanData: .init(scanData: MockScanData.beacon)) - CentralCell(scanData: .init(scanData: MockScanData.beacon)) - CentralCell(scanData: .init(scanData: MockScanData.beacon)) - CentralCell(scanData: .init(scanData: MockScanData.smartThermostat)) - } - } - } -} #endif diff --git a/BluetoothExplorer.swiftpm/Views/CentralList.swift b/BluetoothExplorer.swiftpm/Views/CentralList.swift index d28fea0..677882d 100644 --- a/BluetoothExplorer.swiftpm/Views/CentralList.swift +++ b/BluetoothExplorer.swiftpm/Views/CentralList.swift @@ -6,89 +6,90 @@ // Copyright © 2019 Alsey Coleman Miller. All rights reserved. // +#if canImport(SwiftUI) import SwiftUI -import Bluetooth -import GATT -struct CentralList: View { +public struct CentralList: View { - @EnvironmentObject + typealias ViewModel = CentralListViewModel + + @Environment(Store.self) var store: Store - var scanResults: [Store.ScanResult] { - return store.scanResults - .values - .sorted(by: { $0.id.description < $1.id.description }) - .sorted(by: { ($0.name ?? "") < ($1.name ?? "") }) - .sorted(by: { $0.name != nil && $1.name == nil }) - .sorted(by: { $0.beacon != nil && $1.beacon == nil }) - } + public init() { } - var body: some View { - list - .navigationTitle(Text("Central")) - .toolbar { leftBarButtonItem } + public var body: some View { + ContentView(store) } -} - -extension CentralList { - var list: some View { - ScrollView { - LazyVStack(alignment: .leading) { - ForEach(scanResults) { item in - NavigationLink( - destination: { PeripheralView(peripheral: item.scanData.peripheral) }, - label: { CentralCell(scanData: item) } - ) + struct ContentView: View { + + @State + var viewModel: ViewModel + + init(_ store: Store) { + _viewModel = State(initialValue: CentralListViewModel(store: store)) + } + + var body: some View { + ListView(scanResults: viewModel.scanResults) + .navigationTitle(Text("Central")) + .toolbar { + leftBarButtonItem } - } + } + + var leftBarButtonItem: CentralList.LeftBarButtonItem { + LeftBarButtonItem( + isEnabled: viewModel.isEnabled, + isScanning: viewModel.isScanning, + canToggleScan: viewModel.canToggleScan, + toggle: { viewModel.scanToggle() } + ) } } - var leftBarButtonItem: some View { - switch store.state { - case .unknown: - return AnyView(EmptyView()) - case .poweredOff, - .resetting, - .unauthorized, - .unsupported: - return AnyView( - Image(systemName: "exclamationmark.triangle.fill") - .foregroundColor(.red) - ) - case .poweredOn: - if store.isScanning { - return AnyView(Button(action: { - Task { - await self.store.stopScan() + struct ListView: View { + + let scanResults: [CentralListViewModel.ScanResult] + + var body: some View { + ScrollView { + LazyVStack(alignment: .leading) { + ForEach(scanResults) { item in + NavigationLink(destination: { + EmptyView() + }, label: { + CentralCell(item) + }) } - }) { - Text("Stop") - }) - } else { - return AnyView(Button(action: { - Task { - do { try await self.store.scan() } - catch { print("Error scanning:", error) } - } - }) { - Text("Scan") - }) + } } } } -} - -#if DEBUG -struct CentralList_Preview: PreviewProvider { - static var previews: some View { - Group { - NavigationView { - CentralList() + + struct LeftBarButtonItem: View { + + let isEnabled: Bool + + let isScanning: Bool + + let canToggleScan: Bool + + let toggle: () -> () + + var body: some View { + switch isEnabled { + case false: + return AnyView( + Image(systemName: "exclamationmark.triangle.fill") + .foregroundColor(.red) + ) + case true: + return AnyView(Button(action: toggle) { + isScanning ? Text("Stop") : Text("Scan") + }.disabled(canToggleScan == false)) } - .environmentObject(Store.shared) } } } diff --git a/BluetoothExplorer.swiftpm/Views/CharacteristicView.swift b/BluetoothExplorer.swiftpm/Views/CharacteristicView.swift index 7506aa3..a9bbbd5 100644 --- a/BluetoothExplorer.swiftpm/Views/CharacteristicView.swift +++ b/BluetoothExplorer.swiftpm/Views/CharacteristicView.swift @@ -5,13 +5,14 @@ // Created by Alsey Coleman Miller on 22/12/21. // +#if canImport(SwiftUI) import SwiftUI import Bluetooth import GATT struct CharacteristicView: View { - @EnvironmentObject + @Environment(Store.self) var store: Store let characteristic: Store.Characteristic @@ -153,7 +154,8 @@ extension CharacteristicView { } var isConnected: Bool { - store.connected.contains(peripheral) + //store.connected.contains(peripheral) + true } var descriptors: [Store.Descriptor] { @@ -251,17 +253,19 @@ struct CharacteristicView_Preview: PreviewProvider { Group { NavigationView { CharacteristicView( - store: .shared, characteristic: .deviceName ) } + .environment(Store()) + NavigationView { CharacteristicView( - store: .shared, characteristic: .batteryLevel ) } + .environment(Store()) } } } #endif +#endif diff --git a/BluetoothExplorer.swiftpm/Views/DescriptorView.swift b/BluetoothExplorer.swiftpm/Views/DescriptorView.swift index 7df7207..26ebcf5 100644 --- a/BluetoothExplorer.swiftpm/Views/DescriptorView.swift +++ b/BluetoothExplorer.swiftpm/Views/DescriptorView.swift @@ -5,13 +5,14 @@ // Created by Alsey Coleman Miller on 23/12/21. // +#if canImport(SwiftUI) import SwiftUI import Bluetooth import GATT struct DescriptorView: View { - @EnvironmentObject + @Environment(Store.self) var store: Store let descriptor: Store.Descriptor @@ -128,7 +129,9 @@ extension DescriptorView { } var isConnected: Bool { - store.connected.contains(peripheral) + // FIXME + //store.connected.contains(peripheral) + false } var showActivity: Bool { @@ -191,12 +194,13 @@ struct DescriptorView_Preview: PreviewProvider { Group { NavigationView { DescriptorView( - store: .shared, descriptor: .clientCharacteristicConfiguration(.beacon) ) + .environment(Store()) } } } } #endif +#endif diff --git a/BluetoothExplorer.swiftpm/Views/PeripheralView.swift b/BluetoothExplorer.swiftpm/Views/PeripheralView.swift index 3f409ed..fe1ce7b 100644 --- a/BluetoothExplorer.swiftpm/Views/PeripheralView.swift +++ b/BluetoothExplorer.swiftpm/Views/PeripheralView.swift @@ -6,13 +6,14 @@ // Copyright © 2021 Alsey Coleman Miller. All rights reserved. // +#if canImport(SwiftUI) import SwiftUI import Bluetooth import GATT struct PeripheralView: View { - @EnvironmentObject + @Environment(Store.self) var store: Store let peripheral: Store.Peripheral @@ -84,7 +85,8 @@ extension PeripheralView { } var isConnected: Bool { - store.connected.contains(peripheral) + //store.connected.contains(peripheral) + true } var services: [Store.Service] { @@ -152,11 +154,12 @@ struct PeripheralView_Preview: PreviewProvider { Group { NavigationView { PeripheralView( - store: .shared, peripheral: .beacon ) } + .environment(Store()) } } } #endif +#endif diff --git a/BluetoothExplorer.swiftpm/Views/ServiceView.swift b/BluetoothExplorer.swiftpm/Views/ServiceView.swift index fb1c8e9..6c759eb 100644 --- a/BluetoothExplorer.swiftpm/Views/ServiceView.swift +++ b/BluetoothExplorer.swiftpm/Views/ServiceView.swift @@ -5,13 +5,14 @@ // Created by Alsey Coleman Miller on 22/12/21. // +#if canImport(SwiftUI) import SwiftUI import Bluetooth import GATT struct ServiceView: View { - @EnvironmentObject + @Environment(Store.self) var store: Store let service: Store.Service @@ -88,7 +89,8 @@ extension ServiceView { } var isConnected: Bool { - store.connected.contains(peripheral) + //store.connected.contains(peripheral) + true } var characteristics: [Store.Characteristic] { @@ -134,11 +136,12 @@ struct ServiceView_Preview: PreviewProvider { Group { NavigationView { ServiceView( - store: .shared, service: .deviceInformation ) } + .environment(Store()) } } } #endif +#endif diff --git a/BluetoothExplorer.swiftpm/Views/WriteAttributeView.swift b/BluetoothExplorer.swiftpm/Views/WriteAttributeView.swift index 3d7a15b..d8963b0 100644 --- a/BluetoothExplorer.swiftpm/Views/WriteAttributeView.swift +++ b/BluetoothExplorer.swiftpm/Views/WriteAttributeView.swift @@ -5,6 +5,7 @@ // Created by Alsey Coleman Miller on 23/12/21. // +#if canImport(SwiftUI) import Foundation import SwiftUI import Bluetooth @@ -66,3 +67,4 @@ internal extension WriteAttributeView { return Data(hexadecimal: text) } } +#endif diff --git a/Cartfile b/Cartfile deleted file mode 100644 index 522eae6..0000000 --- a/Cartfile +++ /dev/null @@ -1 +0,0 @@ -github "PureSwift/GATT" "master" \ No newline at end of file diff --git a/Cartfile.resolved b/Cartfile.resolved deleted file mode 100644 index 85cfa95..0000000 --- a/Cartfile.resolved +++ /dev/null @@ -1,2 +0,0 @@ -github "PureSwift/Bluetooth" "cd5d3fa8a243d405c024a907481a6179c46a9cd6" -github "PureSwift/GATT" "e6ac711ce0ebafd30d8d8ba11b9015a9f1d0a5fb" diff --git a/bluetooth-explorer-android/.gitignore b/bluetooth-explorer-android/.gitignore new file mode 100644 index 0000000..10cf596 --- /dev/null +++ b/bluetooth-explorer-android/.gitignore @@ -0,0 +1,6 @@ +.build +.swiftpm +.idea +.gradle +*.so +*.jar diff --git a/bluetooth-explorer-android/Package.swift b/bluetooth-explorer-android/Package.swift new file mode 100644 index 0000000..e4352a4 --- /dev/null +++ b/bluetooth-explorer-android/Package.swift @@ -0,0 +1,65 @@ +// swift-tools-version: 6.1 +import PackageDescription + +let package = Package( + name: "bluetooth-explorer-android", + platforms: [ + .macOS(.v15), + ], + products: [ + .library( + name: "BluetoothExplorerApp", + type: .dynamic, + targets: ["BluetoothExplorerApp"] + ), + ], + dependencies: [ + .package( + url: "https://github.com/PureSwift/swift-java.git", + branch: "feature/android" + ), + .package( + url: "https://github.com/PureSwift/Android.git", + branch: "master" + ), + .package( + url: "https://github.com/PureSwift/GATT.git", + branch: "master" + ), + ], + targets: [ + .target( + name: "BluetoothExplorerApp", + dependencies: [ + .product( + name: "AndroidKit", + package: "Android" + ), + "BluetoothExplorerModel" + ], + path: "./app/src/main/swift/app", + swiftSettings: [ + .swiftLanguageMode(.v5) + ] + ), + .target( + name: "BluetoothExplorerModel", + dependencies: [ + .product( + name: "GATT", + package: "GATT" + ), + .product( + name: "JavaKit", + package: "swift-java" + ) + ], + swiftSettings: [ + .swiftLanguageMode(.v5) + ], + plugins: [ + .plugin(name: "JExtractSwiftPlugin", package: "swift-java") + ] + ) + ] +) diff --git a/bluetooth-explorer-android/Sources/BluetoothExplorerModel/Extensions/DataExtensions.swift b/bluetooth-explorer-android/Sources/BluetoothExplorerModel/Extensions/DataExtensions.swift new file mode 100644 index 0000000..1af1e7b --- /dev/null +++ b/bluetooth-explorer-android/Sources/BluetoothExplorerModel/Extensions/DataExtensions.swift @@ -0,0 +1,74 @@ +// +// Data.swift +// Bluetooth +// +// Created by Alsey Coleman Miller on 5/30/18. +// Copyright © 2018 PureSwift. All rights reserved. +// + +import Foundation + +internal extension Data { + + @usableFromInline + func subdataNoCopy(in range: Range) -> Data { + + // stored in heap, can reuse buffer + if count > Data.inlineBufferSize { + + return withUnsafeBytes { (buffer: UnsafeRawBufferPointer) in + Data(bytesNoCopy: UnsafeMutableRawPointer(mutating: buffer.baseAddress!.advanced(by: range.lowerBound)), + count: range.count, + deallocator: .none) + } + + } else { + + // stored in stack, must copy + return subdata(in: range) + } + } + + @usableFromInline + func suffixNoCopy(from index: Int) -> Data { + return subdataNoCopy(in: index ..< count) + } + + @usableFromInline + func suffixCheckingBounds(from start: Int) -> Data { + + if count > start { + return Data(suffix(from: start)) + } else { + return Data() + } + } +} + +internal extension Data { + + /// Size of the inline buffer for `Foundation.Data` used in Swift 5. + /// + /// Used to determine wheather data is stored on stack or in heap. + @usableFromInline + static var inlineBufferSize: Int { + + // Keep up to date + // https://github.com/apple/swift/blob/aa3e5904f8ba8bf9ae06d96946774d171074f6e5/stdlib/public/Darwin/Foundation/Data.swift#L656 + // https://github.com/apple/swift-corelibs-foundation/blob/76068b8caf54f250a7be5336a7c6bb97f55469f8/Sources/Foundation/Data.swift#L649 + #if arch(x86_64) || arch(arm64) || arch(s390x) || arch(powerpc64) || arch(powerpc64le) + typealias Buffer = (UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, + UInt8, UInt8, UInt8, UInt8, UInt8, UInt8) //len //enum + #elseif arch(i386) || arch(arm) + typealias Buffer = (UInt8, UInt8, UInt8, UInt8, + UInt8, UInt8) //len //enum + #elseif os(watchOS) // arm64_32 + typealias Buffer = (UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, + UInt8, UInt8, UInt8, UInt8, UInt8, UInt8) //len //enum + #else + #error("This architecture isn't known. Add it to the 32-bit or 64-bit line.") + #endif + + return MemoryLayout.size + } +} diff --git a/bluetooth-explorer-android/Sources/BluetoothExplorerModel/Extensions/Hexadecimal.swift b/bluetooth-explorer-android/Sources/BluetoothExplorerModel/Extensions/Hexadecimal.swift new file mode 100644 index 0000000..0bfa5b0 --- /dev/null +++ b/bluetooth-explorer-android/Sources/BluetoothExplorerModel/Extensions/Hexadecimal.swift @@ -0,0 +1,67 @@ +// +// Hexadecimal.swift +// Bluetooth +// +// Created by Alsey Coleman Miller on 3/2/16. +// Copyright © 2016 PureSwift. All rights reserved. +// + +import Foundation + +internal extension FixedWidthInteger { + + func toHexadecimal() -> String { + + var string = String(self, radix: 16) + while string.utf8.count < (MemoryLayout.size * 2) { + string = "0" + string + } + assert(string.utf8.count == MemoryLayout.size * 2) + return string.uppercased() + } + + init?(hexadecimal string: T) { + self.init(string, radix: 16) + } +} + +internal extension Collection where Element: FixedWidthInteger { + + func toHexadecimal() -> String { + let length = count * MemoryLayout.size * 2 + var string = "" + string.reserveCapacity(length) + string = reduce(into: string) { $0 += $1.toHexadecimal() } + assert(string.count == length) + return string + } +} + +internal extension Data { + + init?(hexadecimal string: String) { + let elementStringSize = MemoryLayout.size * 2 // 2 for UInt8 + guard string.isEmpty == false else { + self.init() + return + } + guard string.count % elementStringSize == 0 else { + return nil + } + let elementsCount = string.count / elementStringSize + let elements = (0 ..< elementsCount) + .lazy + .map { ($0 * elementStringSize, ($0+1) * elementStringSize) } + .map { string.index(string.startIndex, offsetBy: $0.0) ..< string.index(string.startIndex, offsetBy: $0.1) } + .map { string[$0] } + self.init() + self.reserveCapacity(elementsCount) + for substring in elements { + guard let element = Element.init(hexadecimal: substring) else { + return nil + } + self.append(element) + } + assert(self.count == elementsCount) + } +} diff --git a/bluetooth-explorer-android/Sources/BluetoothExplorerModel/Extensions/Integer.swift b/bluetooth-explorer-android/Sources/BluetoothExplorerModel/Extensions/Integer.swift new file mode 100644 index 0000000..63ac05c --- /dev/null +++ b/bluetooth-explorer-android/Sources/BluetoothExplorerModel/Extensions/Integer.swift @@ -0,0 +1,54 @@ +// +// Integer.swift +// Bluetooth +// +// Created by Alsey Coleman Miller on 8/24/15. +// Copyright © 2015 PureSwift. All rights reserved. +// + +import Foundation + +internal extension UInt16 { + + /// Initializes value from two bytes. + init(bytes: (UInt8, UInt8)) { + + self = unsafeBitCast(bytes, to: UInt16.self) + } + + /// Converts to two bytes. + var bytes: (UInt8, UInt8) { + + return unsafeBitCast(self, to: (UInt8, UInt8).self) + } +} + +internal extension UInt32 { + + /// Initializes value from four bytes. + init(bytes: (UInt8, UInt8, UInt8, UInt8)) { + + self = unsafeBitCast(bytes, to: UInt32.self) + } + + /// Converts to four bytes. + var bytes: (UInt8, UInt8, UInt8, UInt8) { + + return unsafeBitCast(self, to: (UInt8, UInt8, UInt8, UInt8).self) + } +} + +internal extension UInt64 { + + /// Initializes value from four bytes. + init(bytes: (UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8)) { + + self = unsafeBitCast(bytes, to: UInt64.self) + } + + /// Converts to eight bytes. + var bytes: (UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8) { + + return unsafeBitCast(self, to: (UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8).self) + } +} diff --git a/bluetooth-explorer-android/Sources/BluetoothExplorerModel/Extensions/UUID.swift b/bluetooth-explorer-android/Sources/BluetoothExplorerModel/Extensions/UUID.swift new file mode 100644 index 0000000..535bde5 --- /dev/null +++ b/bluetooth-explorer-android/Sources/BluetoothExplorerModel/Extensions/UUID.swift @@ -0,0 +1,51 @@ +// +// UUID.swift +// Bluetooth +// +// Created by Alsey Coleman Miller on 4/5/17. +// Copyright © 2017 PureSwift. All rights reserved. +// + +import Foundation + +internal extension UUID { + + static var length: Int { return 16 } +} + +internal extension UUID { // ByteValue + + typealias ByteValue = uuid_t + + static var bitWidth: Int { return 128 } + + @inline(__always) + init(bytes: uuid_t) { + + self.init(uuid: bytes) + } + + var bytes: uuid_t { + + @inline(__always) + get { return uuid } + + @inline(__always) + set { self = UUID(uuid: newValue) } + } +} + +internal extension UUID { + + init?(data: Data) { + + guard data.count == UUID.length else { return nil } + + self.init(bytes: (data[0], data[1], data[2], data[3], data[4], data[5], data[6], data[7], data[8], data[9], data[10], data[11], data[12], data[13], data[14], data[15])) + } + + var data: Data { + + return Data([bytes.0, bytes.1, bytes.2, bytes.3, bytes.4, bytes.5, bytes.6, bytes.7, bytes.8, bytes.9, bytes.10, bytes.11, bytes.12, bytes.13, bytes.14, bytes.15]) + } +} diff --git a/bluetooth-explorer-android/Sources/BluetoothExplorerModel/Extensions/iBeacon.swift b/bluetooth-explorer-android/Sources/BluetoothExplorerModel/Extensions/iBeacon.swift new file mode 100644 index 0000000..8c08b94 --- /dev/null +++ b/bluetooth-explorer-android/Sources/BluetoothExplorerModel/Extensions/iBeacon.swift @@ -0,0 +1,58 @@ +// +// iBeacon.swift +// +// +// Created by Alsey Coleman Miller on 3/26/23. +// + +import Foundation +import Bluetooth +import GATT + +internal extension AppleBeacon { + + init?(manufacturerData: GATT.ManufacturerSpecificData) where Data: DataContainer { + + let data = manufacturerData.additionalData + + guard manufacturerData.companyIdentifier == type(of: self).companyIdentifier, + data.count > 2 + else { return nil } + + let dataType = data[0] + + guard dataType == type(of: self).appleDataType + else { return nil } + + let length = data[1] + + guard length == type(of: self).length, + data.count == type(of: self).additionalDataLength + else { return nil } + + let uuid = UUID(UInt128(bigEndian: UInt128(data: data.subdata(in: 2 ..< 18))!)) + let major = UInt16(bigEndian: UInt16(bytes: (data[18], data[19]))) + let minor = UInt16(bigEndian: UInt16(bytes: (data[20], data[21]))) + let rssi = Int8(bitPattern: data[22]) + + self.init(uuid: uuid, major: major, minor: minor, rssi: rssi) + } +} + +internal extension AppleBeacon { + + /// Apple iBeacon data type. + static var appleDataType: UInt8 { 0x02 } // iBeacon + + /// The length of the TLV encoded data. + static var length: UInt8 { 0x15 } // length: 21 = 16 byte UUID + 2 bytes major + 2 bytes minor + 1 byte RSSI + + static var additionalDataLength: Int { Int(length) + 2 } +} + +internal extension GATT.AdvertisementData { + + var beacon: AppleBeacon? { + manufacturerData.flatMap { AppleBeacon(manufacturerData: $0) } + } +} diff --git a/bluetooth-explorer-android/Sources/BluetoothExplorerModel/Model/AttributeValue.swift b/bluetooth-explorer-android/Sources/BluetoothExplorerModel/Model/AttributeValue.swift new file mode 100644 index 0000000..73b60b4 --- /dev/null +++ b/bluetooth-explorer-android/Sources/BluetoothExplorerModel/Model/AttributeValue.swift @@ -0,0 +1,41 @@ +// +// AttributeValue.swift +// +// +// Created by Alsey Coleman Miller on 22/12/21. +// + +import Foundation + +public enum AttributeValueType: Equatable, Hashable { + + case read + case write + case notification +} + +public struct AttributeValue: Equatable, Hashable { + + public let date: Date + + public let type: AttributeValueType + + internal let data: Data + + internal init( + date: Date, + type: AttributeValueType, + data: Data + ) { + self.date = date + self.type = type + self.data = data + } +} + +extension AttributeValue: Identifiable { + + public var id: Date { + date + } +} diff --git a/bluetooth-explorer-android/Sources/BluetoothExplorerModel/Model/BluetoothUUID.swift b/bluetooth-explorer-android/Sources/BluetoothExplorerModel/Model/BluetoothUUID.swift new file mode 100644 index 0000000..91dd137 --- /dev/null +++ b/bluetooth-explorer-android/Sources/BluetoothExplorerModel/Model/BluetoothUUID.swift @@ -0,0 +1,31 @@ +// +// BluetoothUUID.swift +// +// +// Created by Alsey Coleman Miller on 22/12/21. +// + +import Foundation +import Bluetooth + +internal extension BluetoothUUID { + + func description(for value: Data) -> String? { + switch self { + case BluetoothUUID.Characteristic.batteryLevel: + return value.first.flatMap { $0.description + "%" } + case BluetoothUUID.Characteristic.currentTime: + return nil + case BluetoothUUID.Characteristic.deviceName, + BluetoothUUID.Characteristic.serialNumberString, + BluetoothUUID.Characteristic.firmwareRevisionString, + BluetoothUUID.Characteristic.softwareRevisionString, + BluetoothUUID.Characteristic.hardwareRevisionString, + BluetoothUUID.Characteristic.modelNumberString, + BluetoothUUID.Characteristic.manufacturerNameString: + return String(data: value, encoding: .utf8) + default: + return nil + } + } +} diff --git a/bluetooth-explorer-android/Sources/BluetoothExplorerModel/Model/Cache.swift b/bluetooth-explorer-android/Sources/BluetoothExplorerModel/Model/Cache.swift new file mode 100644 index 0000000..4fe0952 --- /dev/null +++ b/bluetooth-explorer-android/Sources/BluetoothExplorerModel/Model/Cache.swift @@ -0,0 +1,31 @@ +// +// Cache.swift +// +// +// Created by Alsey Coleman Miller on 22/12/21. +// + +internal struct Cache { + + let capacity: Int + + private(set) var values: [T] + + init(capacity: Int) { + assert(capacity > 0) + self.capacity = capacity + self.values = [T]() + self.values.reserveCapacity(capacity) + } + + mutating func append(_ value: T) { + values.append(value) + if values.count > capacity { + values.removeFirst() + } + } + + mutating func removeAll() { + values.removeAll(keepingCapacity: true) + } +} diff --git a/bluetooth-explorer-android/Sources/BluetoothExplorerModel/Model/Mock/MockAdvertisement.swift b/bluetooth-explorer-android/Sources/BluetoothExplorerModel/Model/Mock/MockAdvertisement.swift new file mode 100644 index 0000000..be3c573 --- /dev/null +++ b/bluetooth-explorer-android/Sources/BluetoothExplorerModel/Model/Mock/MockAdvertisement.swift @@ -0,0 +1,74 @@ +// +// MockAdvertisement.swift +// BluetoothExplorer +// +// Created by Alsey Coleman Miller on 31/10/21. +// Copyright © 2021 Alsey Coleman Miller. All rights reserved. +// + +#if DEBUG +import Foundation +import Bluetooth +import GATT + +/// Mock Advertisement Data +internal struct MockAdvertisementData: AdvertisementData { + + /// The local name of a peripheral. + internal let localName: String? + + /// The Manufacturer data of a peripheral. + internal let manufacturerData: ManufacturerSpecificData? + + /// This value is available if the broadcaster (peripheral) provides its Tx power level in its advertising packet. + /// Using the RSSI value and the Tx power level, it is possible to calculate path loss. + internal let txPowerLevel: Double? + + /// Service-specific advertisement data. + internal let serviceData: [BluetoothUUID: Data]? + + /// An array of service UUIDs + internal let serviceUUIDs: [BluetoothUUID]? + + /// An array of one or more `BluetoothUUID`, representing Service UUIDs. + internal let solicitedServiceUUIDs: [BluetoothUUID]? + + internal init( + localName: String? = nil, + manufacturerData: ManufacturerSpecificData? = nil, + txPowerLevel: Double? = nil, + serviceData: [BluetoothUUID : Data]? = nil, + serviceUUIDs: [BluetoothUUID]? = nil, + solicitedServiceUUIDs: [BluetoothUUID]? = nil + ) { + self.localName = localName + self.manufacturerData = manufacturerData + self.txPowerLevel = txPowerLevel + self.serviceData = serviceData + self.serviceUUIDs = serviceUUIDs + self.solicitedServiceUUIDs = solicitedServiceUUIDs + } +} + +extension MockAdvertisementData { + + internal static let beacon = MockAdvertisementData( + localName: nil, + manufacturerData: ManufacturerSpecificData(data: Data([0x4c, 0x00, 0x02, 0x15, 0xb9, 0x40, 0x7f, 0x30, 0xf5, 0xf8, 0x46, 0x6e, 0xaf, 0xf9, 0x25, 0x55, 0x6b, 0x57, 0xfe, 0x6d, 0x29, 0x4c, 0x90, 0x39, 0x74])), + txPowerLevel: nil, + serviceData: nil, + serviceUUIDs: nil, + solicitedServiceUUIDs: nil + ) + + internal static let smartThermostat = MockAdvertisementData( + localName: "CLI-W200", + manufacturerData: ManufacturerSpecificData(data: Data([0xd9, 0x01, 0x01, 0x02, 0x00, 0x00, 0x8c, 0x85, 0x90, 0xcb, 0x31, 0x74, 0x00, 0x60])), + txPowerLevel: nil, + serviceData: nil, + serviceUUIDs: [BluetoothUUID.Member.savantSystems2], + solicitedServiceUUIDs: nil + ) +} + +#endif diff --git a/bluetooth-explorer-android/Sources/BluetoothExplorerModel/Model/Mock/MockAttributes.swift b/bluetooth-explorer-android/Sources/BluetoothExplorerModel/Model/Mock/MockAttributes.swift new file mode 100644 index 0000000..3edfba1 --- /dev/null +++ b/bluetooth-explorer-android/Sources/BluetoothExplorerModel/Model/Mock/MockAttributes.swift @@ -0,0 +1,109 @@ +// +// MockService.swift +// +// +// Created by Alsey Coleman Miller on 18/12/21. +// + +#if DEBUG +import Bluetooth +import GATT + +internal typealias MockService = GATT.Service +internal typealias MockCharacteristic = GATT.Characteristic +internal typealias MockDescriptor = GATT.Descriptor + +internal extension MockService { + + static var deviceInformation: MockService { + Service( + id: 10, + uuid: BluetoothUUID.Service.deviceInformation, + peripheral: .beacon + ) + } + + static var battery: MockService { + Service( + id: 20, + uuid: BluetoothUUID.Service.battery, + peripheral: .beacon + ) + } + + static var savantSystems: MockService { + Service( + id: 30, + uuid: BluetoothUUID.Member.savantSystems2, + peripheral: .smartThermostat + ) + } +} + +internal extension MockCharacteristic { + + static var deviceName: MockCharacteristic { + Characteristic( + id: 11, + uuid: BluetoothUUID.Characteristic.deviceName, + peripheral: .beacon, + properties: [.read] + ) + } + + static var manufacturerName: MockCharacteristic { + Characteristic( + id: 12, + uuid: BluetoothUUID.Characteristic.manufacturerNameString, + peripheral: .beacon, + properties: [.read] + ) + } + + static var modelNumber: MockCharacteristic { + Characteristic( + id: 13, + uuid: BluetoothUUID.Characteristic.modelNumberString, + peripheral: .beacon, + properties: [.read] + ) + } + + static var serialNumber: MockCharacteristic { + Characteristic( + id: 14, + uuid: BluetoothUUID.Characteristic.serialNumberString, + peripheral: .beacon, + properties: [.read] + ) + } + + static var batteryLevel: MockCharacteristic { + Characteristic( + id: 21, + uuid: BluetoothUUID.Characteristic.batteryLevel, + peripheral: .beacon, + properties: [.read, .notify] + ) + } + + static let savantTest: MockCharacteristic = Characteristic( + id: 31, + uuid: BluetoothUUID(), + peripheral: .smartThermostat, + properties: [.read, .write, .writeWithoutResponse, .notify] + ) +} + +internal extension MockDescriptor { + + static func clientCharacteristicConfiguration(_ peripheral: Peripheral) -> MockDescriptor { + Descriptor( + id: 99, + uuid: BluetoothUUID.Descriptor.clientCharacteristicConfiguration, + peripheral: peripheral + ) + } +} + +#endif diff --git a/bluetooth-explorer-android/Sources/BluetoothExplorerModel/Model/Mock/MockCentral.swift b/bluetooth-explorer-android/Sources/BluetoothExplorerModel/Model/Mock/MockCentral.swift new file mode 100644 index 0000000..1f8513e --- /dev/null +++ b/bluetooth-explorer-android/Sources/BluetoothExplorerModel/Model/Mock/MockCentral.swift @@ -0,0 +1,290 @@ +// +// MockCentral.swift +// +// +// Created by Alsey Coleman Miller on 22/12/21. +// + +#if DEBUG +import Foundation +import Bluetooth +import GATT + +@MainActor +final class MockCentral: CentralManager, @unchecked Sendable { + + /// Central Peripheral Type + typealias Peripheral = GATT.Peripheral + + /// Central Advertisement Type + internal typealias Advertisement = MockAdvertisementData + + /// Central Attribute ID (Handle) + internal typealias AttributeID = UInt16 + + internal nonisolated(unsafe) var log: (@Sendable (String) -> ())? + + internal var peripherals: [GATT.Peripheral : Bool] { + get async { + var peripherals = [Peripheral: Bool]() + for scanData in state.scanData { + peripherals[scanData.peripheral] = state.connected.contains(scanData.peripheral) + } + return peripherals + } + } + + internal var isEnabled: Bool { + get async { + state.isEnabled + } + } + + private var state = State() + + private var continuation = Continuation() + + internal init() { + Task { + try await Task.sleep(for: .seconds(1)) + updateState { + $0.isEnabled = true + } + } + } + + /// Scans for peripherals that are advertising services. + internal func scan( + with services: Set, + filterDuplicates: Bool + ) -> AsyncCentralScan { + return AsyncCentralScan { continuation in + await self.state.scanData.forEach { + continuation($0) + } + } + } + + internal func scan( + filterDuplicates: Bool + ) -> AsyncCentralScan { + scan(with: [], filterDuplicates: filterDuplicates) + } + + /// Connect to the specified device + internal func connect(to peripheral: Peripheral) async throws { + state.connected.insert(peripheral) + } + + /// Disconnect the specified device. + internal func disconnect(_ peripheral: Peripheral) async { + state.connected.remove(peripheral) + } + + /// Disconnect all connected devices. + internal func disconnectAll() { + state.connected.removeAll() + } + + /// Discover Services + internal func discoverServices( + _ services: Set = [], + for peripheral: Peripheral + ) async throws -> [Service] { + return state.characteristics + .keys + .filter { $0.peripheral == peripheral } + .sorted(by: { $0.id < $1.id }) + } + + internal func discoverIncludedServices( + _ services: Set = [], + for service: Service + ) async throws -> [Service] { + return [] + } + + /// Discover Characteristics for service + internal nonisolated func discoverCharacteristics( + _ characteristics: Set = [], + for service: Service + ) async throws -> [Characteristic] { + guard await state.connected.contains(service.peripheral) else { + throw CentralError.disconnected + } + guard let characteristics = await state.characteristics[service] else { + throw CentralError.invalidAttribute(service.uuid) + } + return characteristics + .sorted(by: { $0.id < $1.id }) + } + + /// Read Characteristic Value + internal nonisolated func readValue( + for characteristic: Characteristic + ) async throws -> Data { + guard await state.connected.contains(characteristic.peripheral) else { + throw CentralError.disconnected + } + return await state.characteristicValues[characteristic] ?? Data() + } + + /// Write Characteristic Value + internal nonisolated func writeValue( + _ data: Data, + for characteristic: Characteristic, + withResponse: Bool = true + ) async throws { + guard await state.connected.contains(characteristic.peripheral) else { + throw CentralError.disconnected + } + if withResponse { + guard characteristic.properties.contains(.write) else { + throw CentralError.invalidAttribute(characteristic.uuid) + } + } else { + guard characteristic.properties.contains(.writeWithoutResponse) else { + throw CentralError.invalidAttribute(characteristic.uuid) + } + } + // write + await updateState { state in + state.characteristicValues[characteristic] = data + } + } + + /// Discover descriptors + internal nonisolated func discoverDescriptors( + for characteristic: Characteristic + ) async throws -> [Descriptor] { + guard await state.connected.contains(characteristic.peripheral) else { + throw CentralError.disconnected + } + return await state.descriptors[characteristic] ?? [] + } + + /// Read descriptor + internal nonisolated func readValue( + for descriptor: Descriptor + ) async throws -> Data { + guard await state.connected.contains(descriptor.peripheral) else { + throw CentralError.disconnected + } + return await state.descriptorValues[descriptor] ?? Data() + } + + /// Write descriptor + internal nonisolated func writeValue( + _ data: Data, + for descriptor: Descriptor + ) async throws { + guard await state.connected.contains(descriptor.peripheral) else { + throw CentralError.disconnected + } + await updateState { state in + state.descriptorValues[descriptor] = data + } + } + + internal nonisolated func notify( + for characteristic: GATT.Characteristic + ) async throws -> AsyncCentralNotifications { + guard await state.connected.contains(characteristic.peripheral) else { + throw CentralError.disconnected + } + return AsyncCentralNotifications { [unowned self] continuation in + if let notifications = await self.state.notifications[characteristic] { + for notification in notifications { + if #available(iOS 16.0, *) { + try await Task.sleep(for: .seconds(1)) + } + continuation(notification) + } + } + } + } + + /// Read MTU + internal func maximumTransmissionUnit(for peripheral: Peripheral) async throws -> MaximumTransmissionUnit { + guard state.connected.contains(peripheral) else { + throw CentralError.disconnected + } + return .default + } + + // Read RSSI + internal func rssi(for peripheral: Peripheral) async throws -> RSSI { + return .init(rawValue: 127)! + } +} + +private extension MockCentral { + + func updateState(_ body: (inout State) -> ()) { + body(&state) + } +} + +internal extension MockCentral { + + struct State { + var isEnabled = false + var isScanning = false + var scanData: [MockScanData] = [.beacon, .smartThermostat] + var connected = Set() + var characteristics: [MockService: [MockCharacteristic]] = [ + .deviceInformation: [ + .deviceName, + .manufacturerName, + .modelNumber, + .serialNumber + ], + .battery: [ + .batteryLevel + ], + .savantSystems: [ + .savantTest + ] + ] + var descriptors: [MockCharacteristic: [MockDescriptor]] = [ + .batteryLevel: [.clientCharacteristicConfiguration(.beacon)], + .savantTest: [.clientCharacteristicConfiguration(.smartThermostat)], + ] + var characteristicValues: [MockCharacteristic: Data] = [ + .deviceName: Data("iBeacon".utf8), + .manufacturerName: Data("Apple Inc.".utf8), + .modelNumber: Data("iPhone11.8".utf8), + .serialNumber: Data(UUID().uuidString.utf8), + .batteryLevel: Data([100]), + .savantTest: Data(UUID().uuidString.utf8) + ] + var descriptorValues: [MockDescriptor: Data] = [ + .clientCharacteristicConfiguration(.beacon): Data([0x00]), + .clientCharacteristicConfiguration(.smartThermostat): Data([0x00]), + ] + var notifications: [MockCharacteristic: [Data]] = [ + .batteryLevel: [ + Data([99]), + Data([98]), + Data([95]), + Data([80]), + Data([75]), + Data([25]), + Data([20]), + Data([5]), + Data([1]), + ], + .savantTest: [ + Data(UUID().uuidString.utf8), + Data(UUID().uuidString.utf8), + Data(UUID().uuidString.utf8), + Data(UUID().uuidString.utf8), + ] + ] + } + + struct Continuation { + var scan: AsyncThrowingStream, Error>.Continuation? + } +} +#endif diff --git a/bluetooth-explorer-android/Sources/BluetoothExplorerModel/Model/Mock/MockScanData.swift b/bluetooth-explorer-android/Sources/BluetoothExplorerModel/Model/Mock/MockScanData.swift new file mode 100644 index 0000000..28c6925 --- /dev/null +++ b/bluetooth-explorer-android/Sources/BluetoothExplorerModel/Model/Mock/MockScanData.swift @@ -0,0 +1,46 @@ +// +// MockScanData.swift +// BluetoothExplorer +// +// Created by Alsey Coleman Miller on 31/10/21. +// Copyright © 2021 Alsey Coleman Miller. All rights reserved. +// + +#if DEBUG +import Foundation +import Bluetooth +import GATT + +internal typealias MockScanData = ScanData + +internal extension MockScanData { + + static let beacon = MockScanData( + peripheral: .beacon, + date: Date(timeIntervalSinceReferenceDate: 10_000), + rssi: -20, + advertisementData: .beacon, + isConnectable: true + ) + + static let smartThermostat = MockScanData( + peripheral: .smartThermostat, + date: Date(timeIntervalSinceReferenceDate: 10_100), + rssi: -127, + advertisementData: .smartThermostat, + isConnectable: true + ) +} + +internal extension Peripheral { + + static var beacon: Peripheral { + Peripheral(id: BluetoothAddress(rawValue: "00:AA:AB:03:10:01")!) + } + + static var smartThermostat: Peripheral { + Peripheral(id: BluetoothAddress(rawValue: "00:1A:7D:DA:71:13")!) + } +} + +#endif diff --git a/bluetooth-explorer-android/Sources/BluetoothExplorerModel/Model/NativeCentral.swift b/bluetooth-explorer-android/Sources/BluetoothExplorerModel/Model/NativeCentral.swift new file mode 100644 index 0000000..9c79a08 --- /dev/null +++ b/bluetooth-explorer-android/Sources/BluetoothExplorerModel/Model/NativeCentral.swift @@ -0,0 +1,50 @@ +// +// Central.swift +// BluetoothExplorer +// +// Created by Alsey Coleman Miller on 6/9/19. +// Copyright © 2019 Alsey Coleman Miller. All rights reserved. +// + +import Foundation +import Bluetooth +import GATT +#if canImport(DarwinGATT) +import DarwinGATT +#endif + +#if os(Android) +typealias NativeCentral = MockCentral +#elseif os(iOS) && targetEnvironment(simulator) +typealias NativeCentral = MockCentral +#elseif canImport(Darwin) +typealias NativeCentral = DarwinCentral +#else +#error("Platform not supported") +#endif + +extension NativeCentral { + + /// Wait for CoreBluetooth to be ready. + func wait( + warning: Int = 3, + timeout: Int = 10 + ) async throws { + + var powerOnWait = 0 + var currentState: Bool + repeat { + currentState = await self.isEnabled + // inform user after 3 seconds + if powerOnWait == warning { + NSLog("Waiting for Bluetooth to be ready, please turn on Bluetooth") + } + // sleep for 1s + try await Task.sleep(for: .seconds(1)) + powerOnWait += 1 + guard powerOnWait < timeout else { + throw CocoaError(.featureUnsupported) // TODO: Update error + } + } while currentState != true + } +} diff --git a/bluetooth-explorer-android/Sources/BluetoothExplorerModel/Model/Store.swift b/bluetooth-explorer-android/Sources/BluetoothExplorerModel/Model/Store.swift new file mode 100644 index 0000000..8486882 --- /dev/null +++ b/bluetooth-explorer-android/Sources/BluetoothExplorerModel/Model/Store.swift @@ -0,0 +1,363 @@ +// +// Store.swift +// BluetoothExplorer +// +// Created by Alsey Coleman Miller on 6/9/19. +// Copyright © 2019 Alsey Coleman Miller. All rights reserved. +// + +import Foundation +import Observation +import Bluetooth +import GATT + +/// Store +@MainActor +@Observable +public final class Store: @unchecked Sendable { + + typealias Central = NativeCentral + + typealias Peripheral = Central.Peripheral + + typealias ScanData = GATT.ScanData + + typealias Service = GATT.Service + + typealias Characteristic = GATT.Characteristic + + typealias Descriptor = GATT.Descriptor + + typealias ScanResult = ScanDataCache + + // MARK: - Properties + + private(set) var activity = [Peripheral: Bool]() + + private(set) var isEnabled = false + + private(set) var scanResults = [Peripheral: ScanResult]() + + var isScanning: Bool { + self.scanStream?.isScanning ?? false + } + + private(set) var connected: Set = [] + + private(set) var services = [Peripheral: [Service]]() + + private(set) var characteristics = [Service: [Characteristic]]() + + private(set) var includedServices = [Service: [Service]]() + + private(set) var descriptors = [Characteristic: [Descriptor]]() + + private(set) var characteristicValues = [Characteristic: Cache]() + + private(set) var descriptorValues = [Descriptor: Cache]() + + private(set) var isNotifying = [Characteristic: Bool]() + + internal let central: Central + + private var scanStream: AsyncCentralScan? + + // MARK: - Initialization + + public convenience init() { + let central = Central() + self.init(central: central) + } + + init(central: Central) { + self.central = central + setupLog() + observeValues() + } + + // MARK: - Methods + + private func setupLog() { + central.log = { print("Central: \($0)") } + } + + private func observeValues() { + Task { [weak self] in + do { + while let self { + try await Task.sleep(for: .seconds(1)) + await self.updateState() + } + } + catch { + + } + } + } + + private func updateState() async { + assert(Thread.isMainThread) + let oldValue = self.isEnabled + let newValue = await self.central.isEnabled + guard newValue != oldValue else { + return + } + // update value + self.isEnabled = newValue + // start scanning when powered on + guard newValue else { + return + } + do { try await self.scan() } + catch { } // ignore error + } + + private func updateConnected() async { + assert(Thread.isMainThread) + let oldValue = self.connected + let newValue = await Set(central.peripherals.compactMap { $0.value ? $0.key : nil }) + guard oldValue != newValue else { + return + } + self.connected = newValue + } + + public func log(_ message: String) { + print(message) + } + + func log(error: any Error) { + print("Error", error.localizedDescription) + } + + func scan( + with services: Set = [], + filterDuplicates: Bool = true + ) async throws { + scanResults.removeAll(keepingCapacity: true) + self.scanStream = nil // end previous scan + let stream = central.scan( + with: services, + filterDuplicates: filterDuplicates + ) + self.scanStream = stream + Task { + for try await scanData in stream { + await found(scanData: scanData) + } + self.scanStream = nil + } + } + + /// Cache discovered values + private func found(scanData: ScanData) async { + var cache = scanResults[scanData.peripheral] ?? ScanDataCache(scanData: scanData) + cache += scanData + #if os(Android) + + #elseif os(iOS) && targetEnvironment(simulator) + + #elseif canImport(CoreBluetooth) + cache.name = try? await central.name(for: scanData.peripheral) + for serviceUUID in scanData.advertisementData.overflowServiceUUIDs ?? [] { + cache.overflowServiceUUIDs.insert(serviceUUID) + } + #endif + scanResults[scanData.peripheral] = cache + } + + func stopScan() async { + scanStream?.stop() + scanStream = nil + } + + func connect(to peripheral: Central.Peripheral) async throws { + activity[peripheral] = true + defer { activity[peripheral] = false } + if isScanning { + scanStream?.stop() + } + try await central.connect(to: peripheral) + } + + func disconnect(_ peripheral: Central.Peripheral) async { + await central.disconnect(peripheral) + } + + func discoverServices(for peripheral: Central.Peripheral) async throws { + activity[peripheral] = true + defer { activity[peripheral] = false } + let services = try await central.discoverServices(for: peripheral) + assert(Thread.isMainThread) + self.services[peripheral] = services + } + + func discoverCharacteristics(for service: Service) async throws { + activity[service.peripheral] = true + defer { activity[service.peripheral] = false } + let characteristics = try await central.discoverCharacteristics([], for: service) + assert(Thread.isMainThread) + self.characteristics[service] = characteristics + } + + func discoverIncludedServices(for service: Service) async throws { + activity[service.peripheral] = true + defer { activity[service.peripheral] = false } + let includedServices = try await central.discoverIncludedServices(for: service) + assert(Thread.isMainThread) + self.includedServices[service] = includedServices + } + + func discoverDescriptors(for characteristic: Characteristic) async throws { + activity[characteristic.peripheral] = true + defer { activity[characteristic.peripheral] = false } + let includedServices = try await central.discoverDescriptors(for: characteristic) + assert(Thread.isMainThread) + self.descriptors[characteristic] = includedServices + } + + func readValue(for characteristic: Characteristic) async throws { + activity[characteristic.peripheral] = true + defer { activity[characteristic.peripheral] = false } + let data = try await central.readValue(for: characteristic) + assert(Thread.isMainThread) + let value = AttributeValue( + date: Date(), + type: .read, + data: data + ) + self.characteristicValues[characteristic, default: .init(capacity: 10)].append(value) + } + + func writeValue(_ data: Data, for characteristic: Characteristic, withResponse: Bool = true) async throws { + activity[characteristic.peripheral] = true + defer { activity[characteristic.peripheral] = false } + try await central.writeValue(data, for: characteristic, withResponse: withResponse) + assert(Thread.isMainThread) + let value = AttributeValue( + date: Date(), + type: .write, + data: data + ) + self.characteristicValues[characteristic, default: .init(capacity: 10)].append(value) + } + + func notify(_ isEnabled: Bool, for characteristic: Characteristic) async throws { + activity[characteristic.peripheral] = true + defer { activity[characteristic.peripheral] = false } + if isEnabled { + let stream = try await central.notify(for: characteristic) + isNotifying[characteristic] = isEnabled + Task.detached(priority: .low) { [unowned self] in + for try await notification in stream { + await self.notification(notification, for: characteristic) + } + } + } else { + //try await central.stopNotifications(for: characteristic) + isNotifying[characteristic] = false + } + } + + private func notification(_ data: Data, for characteristic: Characteristic) async { + assert(Thread.isMainThread) + let value = AttributeValue( + date: Date(), + type: .notification, + data: data + ) + self.characteristicValues[characteristic, default: .init(capacity: 10)].append(value) + } + + func readValue(for descriptor: Descriptor) async throws { + activity[descriptor.peripheral] = true + defer { activity[descriptor.peripheral] = false } + let data = try await central.readValue(for: descriptor) + assert(Thread.isMainThread) + let value = AttributeValue( + date: Date(), + type: .read, + data: data + ) + self.descriptorValues[descriptor, default: .init(capacity: 10)].append(value) + } + + func writeValue(_ data: Data, for descriptor: Descriptor) async throws { + activity[descriptor.peripheral] = true + defer { activity[descriptor.peripheral] = false } + try await central.writeValue(data, for: descriptor) + assert(Thread.isMainThread) + let value = AttributeValue( + date: Date(), + type: .write, + data: data + ) + self.descriptorValues[descriptor, default: .init(capacity: 10)].append(value) + } +} + +// MARK: - Supporting Types + +struct ScanDataCache : Equatable, Hashable { + + var scanData: GATT.ScanData + + /// GAP or advertised name + var name: String? + + /// Advertised name + var advertisedName: String? + + var manufacturerData: GATT.ManufacturerSpecificData? + + /// This value is available if the broadcaster (peripheral) provides its Tx power level in its advertising packet. + /// Using the RSSI value and the Tx power level, it is possible to calculate path loss. + var txPowerLevel: Double? + + /// Service-specific advertisement data. + var serviceData = [BluetoothUUID: Advertisement.Data]() + + /// An array of service UUIDs + var serviceUUIDs = Set() + + /// An array of one or more ``BluetoothUUID``, representing Service UUIDs. + var solicitedServiceUUIDs = Set() + + /// An array of one or more ``BluetoothUUID``, representing Service UUIDs that were found in the “overflow” area of the advertisement data. + var overflowServiceUUIDs = Set() + + /// Advertised iBeacon + var beacon: AppleBeacon? + + init(scanData: GATT.ScanData) { + self.scanData = scanData + self += scanData + } + + static func += (cache: inout ScanDataCache, scanData: GATT.ScanData) { + cache.scanData = scanData + cache.advertisedName = scanData.advertisementData.localName + if cache.name == nil { + cache.name = scanData.advertisementData.localName + } + cache.txPowerLevel = scanData.advertisementData.txPowerLevel + if let beacon = scanData.advertisementData.beacon { + cache.beacon = beacon + } else { + cache.manufacturerData = scanData.advertisementData.manufacturerData + } + for serviceUUID in scanData.advertisementData.serviceUUIDs ?? [] { + cache.serviceUUIDs.insert(serviceUUID) + } + for (serviceUUID, serviceData) in scanData.advertisementData.serviceData ?? [:] { + cache.serviceData[serviceUUID] = serviceData + } + } +} + +extension ScanDataCache: Identifiable { + + var id: Peripheral.ID { + scanData.id + } +} diff --git a/bluetooth-explorer-android/Sources/BluetoothExplorerModel/ViewModel/CentralListViewModel.swift b/bluetooth-explorer-android/Sources/BluetoothExplorerModel/ViewModel/CentralListViewModel.swift new file mode 100644 index 0000000..e2c61d6 --- /dev/null +++ b/bluetooth-explorer-android/Sources/BluetoothExplorerModel/ViewModel/CentralListViewModel.swift @@ -0,0 +1,196 @@ +// +// CentralListViewModel.swift +// BluetoothExplorer +// +// Created by Alsey Coleman Miller on 7/11/25. +// + +import Foundation +import Observation +import Bluetooth +import GATT + +@MainActor +@Observable +public final class CentralListViewModel { + + let store: Store + + var scanToggleTask: Task? + + public init(store: Store) { + self.store = store + } + + var state: State { + State( + .init( + store: store, + didToggle: scanToggleTask != nil + ) + ) + } + + public var scanResults: [ScanResult] { + state.scanResults + } + + public var isEnabled: Bool { + state.isEnabled + } + + public var isScanning: Bool { + state.isScanning + } + + public var canToggleScan: Bool { + state.canToggleScan + } + + public func scanToggle() { + scanToggleTask = Task { + defer { scanToggleTask = nil } + if store.isScanning { + await store.stopScan() + } else { + do { + try await store.scan() + } + catch { + store.log(error: error) + } + } + } + } +} + +public extension CentralListViewModel { + + struct State: Sendable { + + let input: Input + + init(_ input: Input) { + self.input = input + } + + public var scanResults: [ScanResult] { + input.scanResults + .values + .lazy + .sorted(by: { $0.id.description < $1.id.description }) + .sorted(by: { ($0.name ?? "") < ($1.name ?? "") }) + .sorted(by: { $0.name != nil && $1.name == nil }) + .sorted(by: { $0.beacon != nil && $1.beacon == nil }) + .map { ScanResult($0) } + } + + public var isEnabled: Bool { + input.isEnabled + } + + public var isScanning: Bool { + input.isScanning + } + + public var canToggleScan: Bool { + input.didToggle == false && input.isEnabled + } + } +} + +public extension CentralListViewModel.State { + + struct Input: Sendable { + + let scanResults: [Store.Peripheral: Store.ScanResult] + + let isEnabled: Bool + + let isScanning: Bool + + let didToggle: Bool + + init(scanResults: [Store.Peripheral : Store.ScanResult], isEnabled: Bool, isScanning: Bool, didToggle: Bool) { + self.scanResults = scanResults + self.isEnabled = isEnabled + self.isScanning = isScanning + self.didToggle = didToggle + } + + @MainActor + init(store: Store, didToggle: Bool) { + self.scanResults = store.scanResults + self.isEnabled = store.isEnabled + self.isScanning = store.isScanning + self.didToggle = didToggle + } + } +} + +public extension CentralListViewModel { + + struct ScanResult: Identifiable { + + typealias ScanData = ScanDataCache + + let scanData: ScanData + + init(_ scanData: ScanData) { + self.scanData = scanData + } + + public var id: String { + scanData.id.description + } + + public var name: String { + scanData.name ?? (beacon != nil ? "iBeacon" : "Unknown") + } + + public var company: String? { + scanData.manufacturerData?.companyIdentifier.name + } + + public var services: String? { + let services = scanData.serviceUUIDs + .sorted(by: { $0.description < $1.description }) + .map { $0.metadata?.name ?? $0.rawValue } + guard services.isEmpty == false + else { return nil } + return "Services: " + services.reduce("", { ($0.isEmpty ? "" : ", ") + $1 }) + } + + public var beacon: CentralListViewModel.Beacon? { + scanData.beacon.flatMap(Beacon.init) + } + } +} + +public extension CentralListViewModel { + + struct Beacon: Sendable { + + let beacon: AppleBeacon + + init(_ beacon: AppleBeacon) { + self.beacon = beacon + } + + public var uuid: String { + beacon.uuid.uuidString + } + + public var major: String { + "Major: 0x\(beacon.major.toHexadecimal())" + } + + public var minor: String { + "Minor: 0x\(beacon.minor.toHexadecimal())" + } + + public var rssi: String { + "RSSI: \(beacon.rssi)" + } + } +} diff --git a/bluetooth-explorer-android/Sources/BluetoothExplorerModel/ViewModel/PeripheralViewModel.swift b/bluetooth-explorer-android/Sources/BluetoothExplorerModel/ViewModel/PeripheralViewModel.swift new file mode 100644 index 0000000..edd950a --- /dev/null +++ b/bluetooth-explorer-android/Sources/BluetoothExplorerModel/ViewModel/PeripheralViewModel.swift @@ -0,0 +1,56 @@ +// +// PeripheralViewModel.swift +// bluetooth-explorer +// +// Created by Alsey Coleman Miller on 7/12/25. +// + +import Foundation +import Observation +import Bluetooth +import GATT + +@MainActor +@Observable +public final class PeripheralViewModel { + + let store: Store + + let peripheral: Store.Peripheral + + init(store: Store, peripheral: Peripheral) { + self.store = store + self.peripheral = peripheral + } + + public convenience init(store: Store, peripheral: String) { + guard let peripheral = store.scanResults.first(where: { $0.key.description == peripheral })?.key else { + fatalError("Invalid peripheral: \(peripheral)") + } + self.init(store: store, peripheral: peripheral) + } + + var title: String { + store.scanResults[peripheral]?.name ?? "Device" + } + + var isConnected: Bool { + store.connected.contains(peripheral) + } + + var services: [Store.Service] { + store.services[peripheral] ?? [] + } + + var showActivity: Bool { + store.activity[peripheral] ?? false + } + + public func connect() { + + } + + public func reload() { + + } +} diff --git a/bluetooth-explorer-android/Sources/BluetoothExplorerModel/swift-java.config b/bluetooth-explorer-android/Sources/BluetoothExplorerModel/swift-java.config new file mode 100644 index 0000000..f6db9ce --- /dev/null +++ b/bluetooth-explorer-android/Sources/BluetoothExplorerModel/swift-java.config @@ -0,0 +1,4 @@ +{ + "javaPackage": "org.pureswift.bluetoothexplorer", + "mode": "jni" +} \ No newline at end of file diff --git a/bluetooth-explorer-android/app/.gitignore b/bluetooth-explorer-android/app/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/bluetooth-explorer-android/app/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/bluetooth-explorer-android/app/build.gradle.kts b/bluetooth-explorer-android/app/build.gradle.kts new file mode 100644 index 0000000..0fb11be --- /dev/null +++ b/bluetooth-explorer-android/app/build.gradle.kts @@ -0,0 +1,89 @@ +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.compose) +} + +android { + namespace = "com.pureswift.swiftandroid" + compileSdk = 35 + + defaultConfig { + applicationId = "com.pureswift.swiftandroid" + minSdk = 29 + targetSdk = 35 + versionCode = 1 + versionName = "1.0" + ndk { + //noinspection ChromeOsAbiSupport + abiFilters += listOf("arm64-v8a") + } + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + kotlinOptions { + jvmTarget = "11" + } + buildFeatures { + compose = true + } + packaging { + resources { + excludes += listOf("/META-INF/{AL2.0,LGPL2.1}") + } + jniLibs { + keepDebugSymbols += listOf( + "*/arm64-v8a/*.so", + "*/armeabi-v7a/*.so", + "*/x86_64/*.so" + ) + } + } +/* + // Custom Swift build task + val buildSwift by tasks.registering(Exec::class) { + group = "build" + description = "Build Swift sources" + workingDir("$projectDir") + commandLine("bash", "build-swift.sh") + } + + tasks.withType { + dependsOn(buildSwift) + }*/ +} + +dependencies { + + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.lifecycle.runtime.ktx) + implementation(libs.androidx.activity.compose) + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.ui) + implementation(libs.androidx.ui.graphics) + implementation(libs.androidx.ui.tooling.preview) + implementation(libs.androidx.recyclerview) + implementation(libs.androidx.navigation.runtime) + implementation(libs.androidx.material3) + implementation(libs.material) + testImplementation(libs.junit) + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.espresso.core) + androidTestImplementation(platform(libs.androidx.compose.bom)) + androidTestImplementation(libs.androidx.ui.test.junit4) + debugImplementation(libs.androidx.ui.tooling) + debugImplementation(libs.androidx.ui.test.manifest) +} \ No newline at end of file diff --git a/Android/app/proguard-rules.pro b/bluetooth-explorer-android/app/proguard-rules.pro similarity index 94% rename from Android/app/proguard-rules.pro rename to bluetooth-explorer-android/app/proguard-rules.pro index f1b4245..481bb43 100644 --- a/Android/app/proguard-rules.pro +++ b/bluetooth-explorer-android/app/proguard-rules.pro @@ -18,4 +18,4 @@ # If you keep the line number information, uncomment this to # hide the original source file name. -#-renamesourcefileattribute SourceFile +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/Android/app/src/androidTest/java/com/millertech/bluetoothexplorer/ExampleInstrumentedTest.kt b/bluetooth-explorer-android/app/src/androidTest/java/com/pureswift/swiftandroid/ExampleInstrumentedTest.kt similarity index 54% rename from Android/app/src/androidTest/java/com/millertech/bluetoothexplorer/ExampleInstrumentedTest.kt rename to bluetooth-explorer-android/app/src/androidTest/java/com/pureswift/swiftandroid/ExampleInstrumentedTest.kt index 9f8b95d..0b3aecb 100644 --- a/Android/app/src/androidTest/java/com/millertech/bluetoothexplorer/ExampleInstrumentedTest.kt +++ b/bluetooth-explorer-android/app/src/androidTest/java/com/pureswift/swiftandroid/ExampleInstrumentedTest.kt @@ -1,7 +1,7 @@ -package com.millertech.bluetoothexplorer +package com.pureswift.swiftandroid -import android.support.test.InstrumentationRegistry -import android.support.test.runner.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.ext.junit.runners.AndroidJUnit4 import org.junit.Test import org.junit.runner.RunWith @@ -18,7 +18,7 @@ class ExampleInstrumentedTest { @Test fun useAppContext() { // Context of the app under test. - val appContext = InstrumentationRegistry.getTargetContext() - assertEquals("com.millertech.bluetoothexplorer", appContext.packageName) + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + assertEquals("com.pureswift.swiftandroid", appContext.packageName) } -} +} \ No newline at end of file diff --git a/Android/app/src/main/AndroidManifest.xml b/bluetooth-explorer-android/app/src/main/AndroidManifest.xml similarity index 65% rename from Android/app/src/main/AndroidManifest.xml rename to bluetooth-explorer-android/app/src/main/AndroidManifest.xml index 5601e0b..b361577 100644 --- a/Android/app/src/main/AndroidManifest.xml +++ b/bluetooth-explorer-android/app/src/main/AndroidManifest.xml @@ -1,26 +1,27 @@ - - + xmlns:tools="http://schemas.android.com/tools"> - - - + android:theme="@style/Theme.SwiftAndroid" + tools:targetApi="31"> + diff --git a/bluetooth-explorer-android/app/src/main/java/com/example/swift/HelloSubclass.java b/bluetooth-explorer-android/app/src/main/java/com/example/swift/HelloSubclass.java new file mode 100644 index 0000000..2312d8f --- /dev/null +++ b/bluetooth-explorer-android/app/src/main/java/com/example/swift/HelloSubclass.java @@ -0,0 +1,27 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2024 Apple Inc. and the Swift.org project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of Swift.org project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +package com.example.swift; + +public class HelloSubclass extends HelloSwift { + private String greeting; + + public HelloSubclass(String greeting) { + this.greeting = greeting; + } + + public void greetMe() { + super.greet(greeting); + } +} diff --git a/bluetooth-explorer-android/app/src/main/java/com/example/swift/HelloSwift.java b/bluetooth-explorer-android/app/src/main/java/com/example/swift/HelloSwift.java new file mode 100644 index 0000000..b04f53a --- /dev/null +++ b/bluetooth-explorer-android/app/src/main/java/com/example/swift/HelloSwift.java @@ -0,0 +1,64 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2024 Apple Inc. and the Swift.org project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of Swift.org project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +package com.example.swift; + +import java.util.function.Predicate; + +public class HelloSwift { + public double value; + public static double initialValue = 3.14159; + public String name = "Java"; + + static { + System.loadLibrary("BluetoothExplorerApp"); + } + + public HelloSwift() { + this.value = initialValue; + } + + public native int sayHello(int x, int y); + public native String throwMessageFromSwift(String message) throws Exception; + + // To be called back by the native code + public double sayHelloBack(int i) { + System.out.println("And hello back from " + name + "! You passed me " + i); + return value; + } + + public void greet(String name) { + System.out.println("Salutations, " + name); + } + + public Predicate lessThanTen() { + Predicate predicate = i -> (i < 10); + return predicate; + } + + public String[] doublesToStrings(double[] doubles) { + int size = doubles.length; + String[] strings = new String[size]; + + for(int i = 0; i < size; i++) { + strings[i] = "" + doubles[i]; + } + + return strings; + } + + public void throwMessage(String message) throws Exception { + throw new Exception(message); + } +} diff --git a/bluetooth-explorer-android/app/src/main/java/com/example/swift/ThreadSafe.java b/bluetooth-explorer-android/app/src/main/java/com/example/swift/ThreadSafe.java new file mode 100644 index 0000000..2b1b358 --- /dev/null +++ b/bluetooth-explorer-android/app/src/main/java/com/example/swift/ThreadSafe.java @@ -0,0 +1,22 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2024 Apple Inc. and the Swift.org project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of Swift.org project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +package com.example.swift; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +@Retention(RetentionPolicy.RUNTIME) +public @interface ThreadSafe { +} diff --git a/bluetooth-explorer-android/app/src/main/java/com/example/swift/ThreadSafeHelperClass.java b/bluetooth-explorer-android/app/src/main/java/com/example/swift/ThreadSafeHelperClass.java new file mode 100644 index 0000000..3b7793f --- /dev/null +++ b/bluetooth-explorer-android/app/src/main/java/com/example/swift/ThreadSafeHelperClass.java @@ -0,0 +1,41 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2024 Apple Inc. and the Swift.org project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of Swift.org project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +package com.example.swift; + +import java.util.Optional; +import java.util.OptionalLong; +import java.util.OptionalInt; +import java.util.OptionalDouble; + +@ThreadSafe +public class ThreadSafeHelperClass { + public ThreadSafeHelperClass() { } + + public Optional text = Optional.of(""); + + public final OptionalDouble val = OptionalDouble.of(2); + + public String getValue(Optional name) { + return name.orElse(""); + } + + public Optional getText() { + return text; + } + + public OptionalLong from(OptionalInt value) { + return OptionalLong.of(value.getAsInt()); + } +} diff --git a/bluetooth-explorer-android/app/src/main/java/com/pureswift/swiftandroid/Application.kt b/bluetooth-explorer-android/app/src/main/java/com/pureswift/swiftandroid/Application.kt new file mode 100644 index 0000000..4075c97 --- /dev/null +++ b/bluetooth-explorer-android/app/src/main/java/com/pureswift/swiftandroid/Application.kt @@ -0,0 +1,29 @@ +package com.pureswift.swiftandroid + +import com.example.swift.HelloSubclass + +class Application: android.app.Application() { + + init { + NativeLibrary.shared() + } + + override fun onCreate() { + super.onCreate() + onCreateSwift() + } + + private external fun onCreateSwift() + + override fun onTerminate() { + super.onTerminate() + onTerminateSwift() + } + + private external fun onTerminateSwift() + + fun sayHello() { + val result = HelloSubclass("Swift").sayHello(17, 25) + println("sayHello(17, 25) = $result") + } +} \ No newline at end of file diff --git a/bluetooth-explorer-android/app/src/main/java/com/pureswift/swiftandroid/EmbeddedAndroidViewDemo.kt b/bluetooth-explorer-android/app/src/main/java/com/pureswift/swiftandroid/EmbeddedAndroidViewDemo.kt new file mode 100644 index 0000000..5b20617 --- /dev/null +++ b/bluetooth-explorer-android/app/src/main/java/com/pureswift/swiftandroid/EmbeddedAndroidViewDemo.kt @@ -0,0 +1,63 @@ +package com.pureswift.swiftandroid + +import android.view.ViewGroup.LayoutParams.MATCH_PARENT +import android.view.ViewGroup.LayoutParams.WRAP_CONTENT +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.TextView +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.material3.Text +import androidx.compose.material3.Button +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.core.content.ContextCompat + +@Preview(showBackground = true) +@Composable +fun EmbeddedAndroidViewDemo() { + Column { + val state = remember { mutableIntStateOf(0) } + + //widget.ImageView + AndroidView(factory = { ctx -> + ImageView(ctx).apply { + val drawable = ContextCompat.getDrawable(ctx, R.drawable.ic_launcher_foreground) + setImageDrawable(drawable) + } + }) + + //Compose Button + Button(onClick = { state.value++ }) { + Text("MyComposeButton") + } + + //widget.Button + AndroidView(factory = { ctx -> + //Here you can construct your View + android.widget.Button(ctx).apply { + text = "MyAndroidButton" + layoutParams = LinearLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT) + setOnClickListener { + state.value++ + } + } + }, modifier = Modifier.padding(8.dp)) + + //widget.TextView + AndroidView(factory = { ctx -> + //Here you can construct your View + TextView(ctx).apply { + layoutParams = LinearLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT) + } + }, update = { + it.text = "You have clicked the buttons: " + state.value.toString() + " times" + }) + } +} \ No newline at end of file diff --git a/bluetooth-explorer-android/app/src/main/java/com/pureswift/swiftandroid/Fragment.kt b/bluetooth-explorer-android/app/src/main/java/com/pureswift/swiftandroid/Fragment.kt new file mode 100644 index 0000000..26a6c39 --- /dev/null +++ b/bluetooth-explorer-android/app/src/main/java/com/pureswift/swiftandroid/Fragment.kt @@ -0,0 +1,24 @@ +package com.pureswift.swiftandroid + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.LinearLayout + +class Fragment(val swiftObject: SwiftObject): android.app.Fragment() { + + @Deprecated("Deprecated in Java") + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + val context = this.context + checkNotNull(context) + val linearLayout = LinearLayout(context) + return linearLayout + } + + external override fun onViewCreated(view: View, savedInstanceState: Bundle?) +} \ No newline at end of file diff --git a/bluetooth-explorer-android/app/src/main/java/com/pureswift/swiftandroid/ListViewAdapter.kt b/bluetooth-explorer-android/app/src/main/java/com/pureswift/swiftandroid/ListViewAdapter.kt new file mode 100644 index 0000000..722b2b0 --- /dev/null +++ b/bluetooth-explorer-android/app/src/main/java/com/pureswift/swiftandroid/ListViewAdapter.kt @@ -0,0 +1,14 @@ +package com.pureswift.swiftandroid + +import android.R +import android.content.Context +import android.view.View +import android.view.ViewGroup +import android.widget.ArrayAdapter +import androidx.recyclerview.widget.RecyclerView + +class ListViewAdapter(context: Context, val swiftObject: SwiftObject, val objects: ArrayList) : + ArrayAdapter(context, 0, objects) { + + external override fun getView(position: Int, convertView: View?, parent: ViewGroup): View +} diff --git a/bluetooth-explorer-android/app/src/main/java/com/pureswift/swiftandroid/MainActivity.kt b/bluetooth-explorer-android/app/src/main/java/com/pureswift/swiftandroid/MainActivity.kt new file mode 100644 index 0000000..249104b --- /dev/null +++ b/bluetooth-explorer-android/app/src/main/java/com/pureswift/swiftandroid/MainActivity.kt @@ -0,0 +1,76 @@ +package com.pureswift.swiftandroid + +import android.os.Bundle +import android.util.Log +import android.view.View +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import java.util.Date + +class MainActivity : ComponentActivity() { + + init { + NativeLibrary.shared() + } + + val emitter = UnitEmitter() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + onCreateSwift(savedInstanceState) + //enableEdgeToEdge() + + // set composable root view + setContent { + EventReceiver(emitter = emitter) + } + } + + external fun onCreateSwift(savedInstanceState: Bundle?) + + fun setRootView(view: View) { + Log.d("MainActivity", "AndroidSwiftUI.MainActivity.setRootView(_:)") + setContentView(view) + } +} + +@Composable +fun EventReceiver(emitter: UnitEmitter) { + + val tick by emitter.flow.collectAsState(initial = Unit) + + var date by remember { mutableStateOf(Date()) } + + LaunchedEffect(Unit) { + emitter.flow.collect { + date = Date() + } + } + + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text("Hello Swift!") + Text(date.toString()) + } + } +} \ No newline at end of file diff --git a/bluetooth-explorer-android/app/src/main/java/com/pureswift/swiftandroid/NativeLibrary.kt b/bluetooth-explorer-android/app/src/main/java/com/pureswift/swiftandroid/NativeLibrary.kt new file mode 100644 index 0000000..c4bc3f4 --- /dev/null +++ b/bluetooth-explorer-android/app/src/main/java/com/pureswift/swiftandroid/NativeLibrary.kt @@ -0,0 +1,34 @@ +package com.pureswift.swiftandroid + +import android.util.Log + +class NativeLibrary private constructor() { + + companion object { + + @Volatile + var shared : NativeLibrary? = null + + fun shared(): NativeLibrary { + return shared?: synchronized(this){ + val instance = NativeLibrary() + shared = instance + return instance + } + } + } + + init { + loadNativeLibrary() + } + + private fun loadNativeLibrary() { + try { + System.loadLibrary("BluetoothExplorerApp") + } catch (error: UnsatisfiedLinkError) { + Log.e("NativeLibrary", "Unable to load native libraries: $error") + return + } + Log.d("NativeLibrary", "Loaded Swift library") + } +} diff --git a/bluetooth-explorer-android/app/src/main/java/com/pureswift/swiftandroid/NavigationBarViewOnItemSelectedListener.kt b/bluetooth-explorer-android/app/src/main/java/com/pureswift/swiftandroid/NavigationBarViewOnItemSelectedListener.kt new file mode 100644 index 0000000..cf8014f --- /dev/null +++ b/bluetooth-explorer-android/app/src/main/java/com/pureswift/swiftandroid/NavigationBarViewOnItemSelectedListener.kt @@ -0,0 +1,9 @@ +package com.pureswift.swiftandroid + +import android.view.MenuItem +import com.google.android.material.navigation.NavigationBarView + +class NavigationBarViewOnItemSelectedListener(val action: SwiftObject): NavigationBarView.OnItemSelectedListener { + + external override fun onNavigationItemSelected(menuItem: MenuItem): Boolean +} \ No newline at end of file diff --git a/bluetooth-explorer-android/app/src/main/java/com/pureswift/swiftandroid/RecyclerViewAdapter.kt b/bluetooth-explorer-android/app/src/main/java/com/pureswift/swiftandroid/RecyclerViewAdapter.kt new file mode 100644 index 0000000..f069972 --- /dev/null +++ b/bluetooth-explorer-android/app/src/main/java/com/pureswift/swiftandroid/RecyclerViewAdapter.kt @@ -0,0 +1,40 @@ +package com.pureswift.swiftandroid + +import android.util.Log +import android.view.View +import android.view.ViewGroup +import android.widget.LinearLayout +import androidx.recyclerview.widget.RecyclerView +import androidx.recyclerview.widget.RecyclerView.ViewHolder + +class RecyclerViewAdapter(val swiftObject: SwiftObject) : + RecyclerView.Adapter() { + + class ViewHolder(view: View) : RecyclerView.ViewHolder(view) { + + } + + // Create new views (invoked by the layout manager) + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerViewAdapter.ViewHolder { + Log.d("RecyclerViewAdapter", "BluetoothExplorerApp.RecyclerViewAdapter.onCreateViewHolderSwift(_:_:) $viewType") + val view = LinearLayout(parent.context) + val viewHolder = ViewHolder(view) + checkNotNull(viewHolder) + checkNotNull(viewHolder.itemView) + return viewHolder + } + + // Replace the contents of a view (invoked by the layout manager) + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { + onBindViewHolderSwift(holder as RecyclerViewAdapter.ViewHolder, position) + } + + external fun onBindViewHolderSwift(holder: RecyclerViewAdapter.ViewHolder, position: Int) + + // Return the size of your dataset (invoked by the layout manager) + override fun getItemCount(): Int { + return getItemCountSwift() + } + + external fun getItemCountSwift(): Int +} \ No newline at end of file diff --git a/bluetooth-explorer-android/app/src/main/java/com/pureswift/swiftandroid/Runnable.kt b/bluetooth-explorer-android/app/src/main/java/com/pureswift/swiftandroid/Runnable.kt new file mode 100644 index 0000000..08a0987 --- /dev/null +++ b/bluetooth-explorer-android/app/src/main/java/com/pureswift/swiftandroid/Runnable.kt @@ -0,0 +1,6 @@ +package com.pureswift.swiftandroid + +class Runnable(val block: SwiftObject): java.lang.Runnable { + + external override fun run() +} diff --git a/bluetooth-explorer-android/app/src/main/java/com/pureswift/swiftandroid/SwiftObject.kt b/bluetooth-explorer-android/app/src/main/java/com/pureswift/swiftandroid/SwiftObject.kt new file mode 100644 index 0000000..94ff2db --- /dev/null +++ b/bluetooth-explorer-android/app/src/main/java/com/pureswift/swiftandroid/SwiftObject.kt @@ -0,0 +1,17 @@ +package com.pureswift.swiftandroid + +/// Swift object retained by JVM +class SwiftObject(val swiftObject: Long, val type: String) { + + override fun toString(): String { + return toStringSwift() + } + + external fun toStringSwift(): String + + fun finalize() { + finalizeSwift() + } + + external fun finalizeSwift() +} \ No newline at end of file diff --git a/bluetooth-explorer-android/app/src/main/java/com/pureswift/swiftandroid/UnitEmitter.kt b/bluetooth-explorer-android/app/src/main/java/com/pureswift/swiftandroid/UnitEmitter.kt new file mode 100644 index 0000000..93af004 --- /dev/null +++ b/bluetooth-explorer-android/app/src/main/java/com/pureswift/swiftandroid/UnitEmitter.kt @@ -0,0 +1,15 @@ +package com.pureswift.swiftandroid + +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow + +class UnitEmitter() { + + private val _flow = MutableSharedFlow(extraBufferCapacity = 64) + val flow: SharedFlow get() = _flow + + fun emit() { + //println("Emit") + _flow.tryEmit(Unit) + } +} \ No newline at end of file diff --git a/bluetooth-explorer-android/app/src/main/java/com/pureswift/swiftandroid/ViewOnClickListener.kt b/bluetooth-explorer-android/app/src/main/java/com/pureswift/swiftandroid/ViewOnClickListener.kt new file mode 100644 index 0000000..39b5ce3 --- /dev/null +++ b/bluetooth-explorer-android/app/src/main/java/com/pureswift/swiftandroid/ViewOnClickListener.kt @@ -0,0 +1,8 @@ +package com.pureswift.swiftandroid + +import android.view.View + +class ViewOnClickListener(val action: SwiftObject): View.OnClickListener { + + external override fun onClick(view: View) +} \ No newline at end of file diff --git a/bluetooth-explorer-android/app/src/main/java/com/pureswift/swiftandroid/ui/theme/Color.kt b/bluetooth-explorer-android/app/src/main/java/com/pureswift/swiftandroid/ui/theme/Color.kt new file mode 100644 index 0000000..4a8371b --- /dev/null +++ b/bluetooth-explorer-android/app/src/main/java/com/pureswift/swiftandroid/ui/theme/Color.kt @@ -0,0 +1,11 @@ +package com.pureswift.swiftandroid.ui.theme + +import androidx.compose.ui.graphics.Color + +val Purple80 = Color(0xFFD0BCFF) +val PurpleGrey80 = Color(0xFFCCC2DC) +val Pink80 = Color(0xFFEFB8C8) + +val Purple40 = Color(0xFF6650a4) +val PurpleGrey40 = Color(0xFF625b71) +val Pink40 = Color(0xFF7D5260) \ No newline at end of file diff --git a/bluetooth-explorer-android/app/src/main/java/com/pureswift/swiftandroid/ui/theme/Theme.kt b/bluetooth-explorer-android/app/src/main/java/com/pureswift/swiftandroid/ui/theme/Theme.kt new file mode 100644 index 0000000..47f8f81 --- /dev/null +++ b/bluetooth-explorer-android/app/src/main/java/com/pureswift/swiftandroid/ui/theme/Theme.kt @@ -0,0 +1,58 @@ +package com.pureswift.swiftandroid.ui.theme + +import android.app.Activity +import android.os.Build +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext + +private val DarkColorScheme = darkColorScheme( + primary = Purple80, + secondary = PurpleGrey80, + tertiary = Pink80 +) + +private val LightColorScheme = lightColorScheme( + primary = Purple40, + secondary = PurpleGrey40, + tertiary = Pink40 + + /* Other default colors to override + background = Color(0xFFFFFBFE), + surface = Color(0xFFFFFBFE), + onPrimary = Color.White, + onSecondary = Color.White, + onTertiary = Color.White, + onBackground = Color(0xFF1C1B1F), + onSurface = Color(0xFF1C1B1F), + */ +) + +@Composable +fun SwiftAndroidTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + // Dynamic color is available on Android 12+ + dynamicColor: Boolean = true, + content: @Composable () -> Unit +) { + val colorScheme = when { + dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { + val context = LocalContext.current + if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + } + + darkTheme -> DarkColorScheme + else -> LightColorScheme + } + + MaterialTheme( + colorScheme = colorScheme, + typography = Typography, + content = content + ) +} \ No newline at end of file diff --git a/bluetooth-explorer-android/app/src/main/java/com/pureswift/swiftandroid/ui/theme/Type.kt b/bluetooth-explorer-android/app/src/main/java/com/pureswift/swiftandroid/ui/theme/Type.kt new file mode 100644 index 0000000..4de94b7 --- /dev/null +++ b/bluetooth-explorer-android/app/src/main/java/com/pureswift/swiftandroid/ui/theme/Type.kt @@ -0,0 +1,34 @@ +package com.pureswift.swiftandroid.ui.theme + +import androidx.compose.material3.Typography +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp + +// Set of Material typography styles to start with +val Typography = Typography( + bodyLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.5.sp + ) + /* Other default text styles to override + titleLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 22.sp, + lineHeight = 28.sp, + letterSpacing = 0.sp + ), + labelSmall = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Medium, + fontSize = 11.sp, + lineHeight = 16.sp, + letterSpacing = 0.5.sp + ) + */ +) \ No newline at end of file diff --git a/Android/app/src/main/res/drawable/ic_launcher_background.xml b/bluetooth-explorer-android/app/src/main/res/drawable/ic_launcher_background.xml similarity index 54% rename from Android/app/src/main/res/drawable/ic_launcher_background.xml rename to bluetooth-explorer-android/app/src/main/res/drawable/ic_launcher_background.xml index d5fccc5..07d5da9 100644 --- a/Android/app/src/main/res/drawable/ic_launcher_background.xml +++ b/bluetooth-explorer-android/app/src/main/res/drawable/ic_launcher_background.xml @@ -2,169 +2,169 @@ + android:viewportWidth="108" + android:viewportHeight="108"> + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> diff --git a/bluetooth-explorer-android/app/src/main/res/drawable/ic_launcher_foreground.xml b/bluetooth-explorer-android/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..2b068d1 --- /dev/null +++ b/bluetooth-explorer-android/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/bluetooth-explorer-android/app/src/main/res/layout/list_item.xml b/bluetooth-explorer-android/app/src/main/res/layout/list_item.xml new file mode 100644 index 0000000..eaf5504 --- /dev/null +++ b/bluetooth-explorer-android/app/src/main/res/layout/list_item.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/Android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/bluetooth-explorer-android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml similarity index 79% rename from Android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml rename to bluetooth-explorer-android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml index eca70cf..6f3b755 100644 --- a/Android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +++ b/bluetooth-explorer-android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -2,4 +2,5 @@ + \ No newline at end of file diff --git a/Android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/bluetooth-explorer-android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml similarity index 79% rename from Android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml rename to bluetooth-explorer-android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml index eca70cf..6f3b755 100644 --- a/Android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml +++ b/bluetooth-explorer-android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -2,4 +2,5 @@ + \ No newline at end of file diff --git a/bluetooth-explorer-android/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/bluetooth-explorer-android/app/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 0000000..c209e78 Binary files /dev/null and b/bluetooth-explorer-android/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/bluetooth-explorer-android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/bluetooth-explorer-android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 0000000..b2dfe3d Binary files /dev/null and b/bluetooth-explorer-android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/bluetooth-explorer-android/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/bluetooth-explorer-android/app/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 0000000..4f0f1d6 Binary files /dev/null and b/bluetooth-explorer-android/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/bluetooth-explorer-android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/bluetooth-explorer-android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp new file mode 100644 index 0000000..62b611d Binary files /dev/null and b/bluetooth-explorer-android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/bluetooth-explorer-android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/bluetooth-explorer-android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 0000000..948a307 Binary files /dev/null and b/bluetooth-explorer-android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/bluetooth-explorer-android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/bluetooth-explorer-android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..1b9a695 Binary files /dev/null and b/bluetooth-explorer-android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/bluetooth-explorer-android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/bluetooth-explorer-android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp new file mode 100644 index 0000000..28d4b77 Binary files /dev/null and b/bluetooth-explorer-android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/bluetooth-explorer-android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/bluetooth-explorer-android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..9287f50 Binary files /dev/null and b/bluetooth-explorer-android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/bluetooth-explorer-android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/bluetooth-explorer-android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp new file mode 100644 index 0000000..aa7d642 Binary files /dev/null and b/bluetooth-explorer-android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/bluetooth-explorer-android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/bluetooth-explorer-android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..9126ae3 Binary files /dev/null and b/bluetooth-explorer-android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/bluetooth-explorer-android/app/src/main/res/values/colors.xml b/bluetooth-explorer-android/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..f8c6127 --- /dev/null +++ b/bluetooth-explorer-android/app/src/main/res/values/colors.xml @@ -0,0 +1,10 @@ + + + #FFBB86FC + #FF6200EE + #FF3700B3 + #FF03DAC5 + #FF018786 + #FF000000 + #FFFFFFFF + \ No newline at end of file diff --git a/bluetooth-explorer-android/app/src/main/res/values/strings.xml b/bluetooth-explorer-android/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..7de10d2 --- /dev/null +++ b/bluetooth-explorer-android/app/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + SwiftAndroid + \ No newline at end of file diff --git a/bluetooth-explorer-android/app/src/main/res/values/themes.xml b/bluetooth-explorer-android/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..c81f2c7 --- /dev/null +++ b/bluetooth-explorer-android/app/src/main/res/values/themes.xml @@ -0,0 +1,5 @@ + + + +