pyxpo-device 0.1.0a1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Vishal Chaubey
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,80 @@
1
+ Metadata-Version: 2.4
2
+ Name: pyxpo-device
3
+ Version: 0.1.0a1
4
+ Summary: Device facts for pyxpo apps: brand, model, OS, memory, device type (expo-device shape).
5
+ Author: Vishal Chaubey
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/pyxpo/pyxpo/tree/main/packages/device
8
+ Project-URL: Issues, https://github.com/pyxpo/pyxpo/issues
9
+ Keywords: pyxpo,pyxpo-module,mobile,android,ios
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Topic :: Software Development :: User Interfaces
14
+ Requires-Python: >=3.9
15
+ Description-Content-Type: text/markdown
16
+ License-File: LICENSE
17
+ Requires-Dist: pyxpo<0.2,>=0.1.0a2
18
+ Dynamic: license-file
19
+
20
+ # pyxpo-device
21
+
22
+ Facts about the device a [pyxpo](https://github.com/pyxpo/pyxpo) app runs on: brand, model, OS, memory and
23
+ device type. The API follows [expo-device](https://docs.expo.dev/versions/latest/sdk/device/).
24
+
25
+ ```sh
26
+ pip install pyxpo-device
27
+ ```
28
+
29
+ ```toml
30
+ [tool.pyxpo]
31
+ modules = ["device"]
32
+ ```
33
+
34
+ ```python
35
+ import pyxpo_device as Device
36
+
37
+ print(Device.brand, Device.model_name, Device.os_name, Device.os_version)
38
+ if Device.device_type == Device.DeviceType.TABLET:
39
+ ...
40
+ uptime_ms = await Device.get_uptime_async()
41
+ ```
42
+
43
+ Every name also has its expo-device spelling (`Device.modelName`, `Device.getUptimeAsync`).
44
+
45
+ ## Constants
46
+
47
+ Read once at app start, no bridge round trip. `None` where a platform has no value.
48
+
49
+ | Name | iOS | Android |
50
+ | --- | --- | --- |
51
+ | `is_device` | False on the Simulator | False on an emulator (best-effort check) |
52
+ | `brand`, `manufacturer` | `"Apple"` | `Build.BRAND`, `Build.MANUFACTURER` |
53
+ | `model_name` | Marketing name for known iPhones, else the identifier | `Build.MODEL` |
54
+ | `model_id` | Identifier, for example `"iPhone17,3"` | None |
55
+ | `design_name`, `product_name` | None | `Build.DEVICE`, `Build.PRODUCT` |
56
+ | `device_year_class` | Release year of known iPhones | None |
57
+ | `total_memory` | Bytes of RAM | Bytes of RAM |
58
+ | `supported_cpu_architectures` | `["arm64"]` | `Build.SUPPORTED_ABIS` |
59
+ | `os_name` | `"iOS"` or `"iPadOS"` | `"Android"` |
60
+ | `os_version` | For example `"26.0"` | For example `"15"` |
61
+ | `os_build_id`, `os_internal_build_id` | Kernel OS build | `Build.DISPLAY`, `Build.ID` |
62
+ | `os_build_fingerprint`, `platform_api_level` | None | `Build.FINGERPRINT`, API level |
63
+ | `device_name` | `UIDevice.name` (a generic name on iOS 16+) | Device name setting, else the model |
64
+ | `device_type` | `DeviceType` from the interface idiom | `DeviceType` from TV mode and screen width (600dp+ = tablet) |
65
+
66
+ On the desktop the values come from Python's `platform` module and `device_type` is `DESKTOP`.
67
+
68
+ ## Functions
69
+
70
+ - `get_device_type_async()` -> `DeviceType`
71
+ - `get_uptime_async()` -> milliseconds since boot, not counting deep sleep
72
+ - `get_max_memory_async()` -> Android: the runtime's memory limit in bytes; elsewhere `MAX_SAFE_INTEGER`
73
+ - `is_rooted_experimental_async()` -> best-effort jailbreak or root check. Easy to fool; never use it for security.
74
+ - `is_side_loading_enabled_async()` -> Android: whether the app may install apps (needs `REQUEST_INSTALL_PACKAGES` on Android 8+)
75
+ - `get_platform_features_async()` -> Android system feature names; `[]` elsewhere
76
+ - `has_platform_feature_async(feature)` -> Android: for example `"android.hardware.camera"`; `False` elsewhere
77
+
78
+ ## Permissions
79
+
80
+ None.
@@ -0,0 +1,61 @@
1
+ # pyxpo-device
2
+
3
+ Facts about the device a [pyxpo](https://github.com/pyxpo/pyxpo) app runs on: brand, model, OS, memory and
4
+ device type. The API follows [expo-device](https://docs.expo.dev/versions/latest/sdk/device/).
5
+
6
+ ```sh
7
+ pip install pyxpo-device
8
+ ```
9
+
10
+ ```toml
11
+ [tool.pyxpo]
12
+ modules = ["device"]
13
+ ```
14
+
15
+ ```python
16
+ import pyxpo_device as Device
17
+
18
+ print(Device.brand, Device.model_name, Device.os_name, Device.os_version)
19
+ if Device.device_type == Device.DeviceType.TABLET:
20
+ ...
21
+ uptime_ms = await Device.get_uptime_async()
22
+ ```
23
+
24
+ Every name also has its expo-device spelling (`Device.modelName`, `Device.getUptimeAsync`).
25
+
26
+ ## Constants
27
+
28
+ Read once at app start, no bridge round trip. `None` where a platform has no value.
29
+
30
+ | Name | iOS | Android |
31
+ | --- | --- | --- |
32
+ | `is_device` | False on the Simulator | False on an emulator (best-effort check) |
33
+ | `brand`, `manufacturer` | `"Apple"` | `Build.BRAND`, `Build.MANUFACTURER` |
34
+ | `model_name` | Marketing name for known iPhones, else the identifier | `Build.MODEL` |
35
+ | `model_id` | Identifier, for example `"iPhone17,3"` | None |
36
+ | `design_name`, `product_name` | None | `Build.DEVICE`, `Build.PRODUCT` |
37
+ | `device_year_class` | Release year of known iPhones | None |
38
+ | `total_memory` | Bytes of RAM | Bytes of RAM |
39
+ | `supported_cpu_architectures` | `["arm64"]` | `Build.SUPPORTED_ABIS` |
40
+ | `os_name` | `"iOS"` or `"iPadOS"` | `"Android"` |
41
+ | `os_version` | For example `"26.0"` | For example `"15"` |
42
+ | `os_build_id`, `os_internal_build_id` | Kernel OS build | `Build.DISPLAY`, `Build.ID` |
43
+ | `os_build_fingerprint`, `platform_api_level` | None | `Build.FINGERPRINT`, API level |
44
+ | `device_name` | `UIDevice.name` (a generic name on iOS 16+) | Device name setting, else the model |
45
+ | `device_type` | `DeviceType` from the interface idiom | `DeviceType` from TV mode and screen width (600dp+ = tablet) |
46
+
47
+ On the desktop the values come from Python's `platform` module and `device_type` is `DESKTOP`.
48
+
49
+ ## Functions
50
+
51
+ - `get_device_type_async()` -> `DeviceType`
52
+ - `get_uptime_async()` -> milliseconds since boot, not counting deep sleep
53
+ - `get_max_memory_async()` -> Android: the runtime's memory limit in bytes; elsewhere `MAX_SAFE_INTEGER`
54
+ - `is_rooted_experimental_async()` -> best-effort jailbreak or root check. Easy to fool; never use it for security.
55
+ - `is_side_loading_enabled_async()` -> Android: whether the app may install apps (needs `REQUEST_INSTALL_PACKAGES` on Android 8+)
56
+ - `get_platform_features_async()` -> Android system feature names; `[]` elsewhere
57
+ - `has_platform_feature_async(feature)` -> Android: for example `"android.hardware.camera"`; `False` elsewhere
58
+
59
+ ## Permissions
60
+
61
+ None.
@@ -0,0 +1,42 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "pyxpo-device"
7
+ dynamic = ["version"]
8
+ description = "Device facts for pyxpo apps: brand, model, OS, memory, device type (expo-device shape)."
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ license-files = ["LICENSE"]
12
+ authors = [{ name = "Vishal Chaubey" }]
13
+ keywords = ["pyxpo", "pyxpo-module", "mobile", "android", "ios"]
14
+ classifiers = [
15
+ "Development Status :: 3 - Alpha",
16
+ "Intended Audience :: Developers",
17
+ "Programming Language :: Python :: 3",
18
+ "Topic :: Software Development :: User Interfaces",
19
+ ]
20
+ requires-python = ">=3.9"
21
+ dependencies = ["pyxpo>=0.1.0a2,<0.2"]
22
+
23
+ [project.urls]
24
+ Homepage = "https://github.com/pyxpo/pyxpo/tree/main/packages/device"
25
+ Issues = "https://github.com/pyxpo/pyxpo/issues"
26
+
27
+ # How `pyxpo build` finds this module: the entry point name is the module name apps list in
28
+ # `[tool.pyxpo] modules`, its value the import package holding pyxpo-module.json.
29
+ [project.entry-points."pyxpo.modules"]
30
+ device = "pyxpo_device"
31
+
32
+ [tool.setuptools]
33
+ packages = ["pyxpo_device"]
34
+
35
+ [tool.setuptools.dynamic]
36
+ version = { attr = "pyxpo_device.__version__" }
37
+
38
+ [tool.setuptools.package-data]
39
+ pyxpo_device = ["pyxpo-module.json", "py.typed", "ios/**/*", "android/**/*"]
40
+
41
+ [tool.setuptools.exclude-package-data]
42
+ "*" = ["__pycache__", "*.pyc", ".DS_Store"]
@@ -0,0 +1,163 @@
1
+ """Facts about the device the app runs on: the expo-device API.
2
+
3
+ import pyxpo_device as Device
4
+
5
+ Device.brand, Device.model_name, Device.os_name, Device.os_version # or Device.modelName, Device.osName ...
6
+ if Device.device_type == Device.DeviceType.TABLET: ...
7
+ ms = await Device.get_uptime_async()
8
+
9
+ The constants are read once at startup (no bridge round trip). Values a platform does not have are None:
10
+ `model_id` is iOS only; `design_name`, `product_name`, `os_build_fingerprint` and `platform_api_level` are
11
+ Android only. `device_year_class` is the release year of the iPhone model (None for unknown models and on
12
+ Android). On the desktop the values come from Python's `platform` module.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import os
17
+ import platform as _py_platform
18
+ import sys
19
+ import time
20
+ from enum import IntEnum
21
+ from typing import Any, Dict, List, Optional
22
+
23
+ from pyxpo.module_api import call, constants, platform, require_linked
24
+
25
+ __version__ = "0.1.0a1"
26
+
27
+ # Number.MAX_SAFE_INTEGER: what expo-device returns when memory has no inherent limit.
28
+ MAX_SAFE_INTEGER = 2 ** 53 - 1
29
+
30
+
31
+ class DeviceType(IntEnum):
32
+ """Kind of device, from the screen size and the platform's own idiom."""
33
+ UNKNOWN = 0
34
+ PHONE = 1
35
+ TABLET = 2
36
+ DESKTOP = 3
37
+ TV = 4
38
+
39
+
40
+ _FIELDS = {
41
+ "isDevice": "is_device", "brand": "brand", "manufacturer": "manufacturer", "modelName": "model_name",
42
+ "modelId": "model_id", "designName": "design_name", "productName": "product_name",
43
+ "deviceYearClass": "device_year_class", "totalMemory": "total_memory",
44
+ "supportedCpuArchitectures": "supported_cpu_architectures", "osName": "os_name", "osVersion": "os_version",
45
+ "osBuildId": "os_build_id", "osInternalBuildId": "os_internal_build_id",
46
+ "osBuildFingerprint": "os_build_fingerprint", "platformApiLevel": "platform_api_level",
47
+ "deviceName": "device_name", "deviceType": "device_type",
48
+ }
49
+ _BY_SNAKE = {snake: camel for camel, snake in _FIELDS.items()}
50
+
51
+
52
+ def _native() -> bool:
53
+ if platform() == "desktop":
54
+ return False
55
+ require_linked("Device", "device", package="pyxpo-device")
56
+ return True
57
+
58
+
59
+ def _desktop() -> Dict[str, Any]:
60
+ u = _py_platform.uname()
61
+ total: Optional[int] = None
62
+ try:
63
+ total = os.sysconf("SC_PAGE_SIZE") * os.sysconf("SC_PHYS_PAGES")
64
+ except (AttributeError, ValueError, OSError):
65
+ pass
66
+ return {
67
+ "isDevice": True, "brand": None, "manufacturer": None, "modelName": None, "modelId": None,
68
+ "designName": None, "productName": None, "deviceYearClass": None, "totalMemory": total,
69
+ "supportedCpuArchitectures": [u.machine] if u.machine else None, "osName": u.system or sys.platform,
70
+ "osVersion": u.release or None, "osBuildId": u.version or None, "osInternalBuildId": u.version or None,
71
+ "osBuildFingerprint": None, "platformApiLevel": None, "deviceName": u.node or None,
72
+ "deviceType": int(DeviceType.DESKTOP),
73
+ }
74
+
75
+
76
+ def _all() -> Dict[str, Any]:
77
+ if not _native():
78
+ return _desktop()
79
+ c = constants("Device")
80
+ return {k: c.get(k) for k in _FIELDS}
81
+
82
+
83
+ def __getattr__(name: str) -> Any:
84
+ # Constants resolve on access: the startup env is not there yet when this module is imported.
85
+ camel = name if name in _FIELDS else _BY_SNAKE.get(name)
86
+ if camel is None:
87
+ raise AttributeError("module 'pyxpo_device' has no attribute %r" % name)
88
+ value = _all()[camel]
89
+ if camel == "deviceType":
90
+ return DeviceType(value) if value in DeviceType._value2member_map_ else DeviceType.UNKNOWN
91
+ if camel == "supportedCpuArchitectures" and value is not None:
92
+ return list(value)
93
+ return value
94
+
95
+
96
+ async def get_device_type_async() -> DeviceType:
97
+ """The `DeviceType` of this device (same as `device_type`)."""
98
+ return __getattr__("deviceType")
99
+
100
+
101
+ async def get_uptime_async() -> int:
102
+ """Milliseconds since the device booted, not counting deep sleep."""
103
+ if not _native():
104
+ return int(time.monotonic() * 1000) # the system clock since boot on macOS and Linux
105
+ return int(await call("Device", "getUptime", reply=True))
106
+
107
+
108
+ async def get_max_memory_async() -> int:
109
+ """Most memory (bytes) the Android runtime lets the app use. `MAX_SAFE_INTEGER` where there is no limit
110
+ (iOS, the desktop)."""
111
+ if platform() != "android" or not _native():
112
+ return MAX_SAFE_INTEGER
113
+ return int(await call("Device", "getMaxMemory", reply=True))
114
+
115
+
116
+ async def is_rooted_experimental_async() -> bool:
117
+ """Best-effort jailbreak (iOS) or root (Android) check. Easy to fool: never use it for security."""
118
+ if not _native():
119
+ return False
120
+ return bool(await call("Device", "isRooted", reply=True))
121
+
122
+
123
+ async def is_side_loading_enabled_async() -> bool:
124
+ """Android: whether this app may install other apps (needs REQUEST_INSTALL_PACKAGES on Android 8+).
125
+ Always False on iOS and the desktop."""
126
+ if platform() != "android" or not _native():
127
+ return False
128
+ return bool(await call("Device", "isSideLoadingEnabled", reply=True))
129
+
130
+
131
+ async def get_platform_features_async() -> List[str]:
132
+ """Android: the system feature names the device reports (`PackageManager.getSystemAvailableFeatures`).
133
+ Empty on iOS and the desktop."""
134
+ if platform() != "android" or not _native():
135
+ return []
136
+ return list(await call("Device", "getPlatformFeatures", reply=True) or [])
137
+
138
+
139
+ async def has_platform_feature_async(feature: str) -> bool:
140
+ """Android: whether the device has a system feature, for example "android.hardware.camera".
141
+ Always False on iOS and the desktop."""
142
+ if not isinstance(feature, str) or not feature:
143
+ raise ValueError("feature must be a non-empty string")
144
+ if platform() != "android" or not _native():
145
+ return False
146
+ return bool(await call("Device", "hasPlatformFeature", feature, reply=True))
147
+
148
+
149
+ getDeviceTypeAsync = get_device_type_async
150
+ getUptimeAsync = get_uptime_async
151
+ getMaxMemoryAsync = get_max_memory_async
152
+ isRootedExperimentalAsync = is_rooted_experimental_async
153
+ isSideLoadingEnabledAsync = is_side_loading_enabled_async
154
+ getPlatformFeaturesAsync = get_platform_features_async
155
+ hasPlatformFeatureAsync = has_platform_feature_async
156
+
157
+ __all__ = [
158
+ "DeviceType", "MAX_SAFE_INTEGER", "get_device_type_async", "get_uptime_async", "get_max_memory_async",
159
+ "is_rooted_experimental_async", "is_side_loading_enabled_async", "get_platform_features_async",
160
+ "has_platform_feature_async", "getDeviceTypeAsync", "getUptimeAsync", "getMaxMemoryAsync",
161
+ "isRootedExperimentalAsync", "isSideLoadingEnabledAsync", "getPlatformFeaturesAsync",
162
+ "hasPlatformFeatureAsync",
163
+ ] + list(_FIELDS) + list(_FIELDS.values())
@@ -0,0 +1 @@
1
+ <manifest xmlns:android="http://schemas.android.com/apk/res/android" />
@@ -0,0 +1,91 @@
1
+ package dev.pyxpo.modules.device
2
+
3
+ import android.app.ActivityManager
4
+ import android.app.UiModeManager
5
+ import android.content.Context
6
+ import android.content.res.Configuration
7
+ import android.os.Build
8
+ import android.os.SystemClock
9
+ import android.provider.Settings
10
+ import dev.pyxpo.moduleapi.PxModule
11
+ import dev.pyxpo.moduleapi.PxModuleContext
12
+ import dev.pyxpo.moduleapi.PxReply
13
+ import org.json.JSONArray
14
+ import java.io.File
15
+
16
+ /** expo-device on Android. Constants come from `Build`, `ActivityManager` and the configuration at startup. */
17
+ class DeviceModule : PxModule() {
18
+ override val name = "Device"
19
+
20
+ override fun constants(context: Context): Map<String, Any?> {
21
+ val mem = ActivityManager.MemoryInfo()
22
+ (context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager).getMemoryInfo(mem)
23
+ return mapOf(
24
+ "isDevice" to !isEmulator(),
25
+ "brand" to Build.BRAND,
26
+ "manufacturer" to Build.MANUFACTURER,
27
+ "modelName" to Build.MODEL,
28
+ "modelId" to null,
29
+ "designName" to Build.DEVICE,
30
+ "productName" to Build.PRODUCT,
31
+ "deviceYearClass" to null,
32
+ "totalMemory" to mem.totalMem,
33
+ "supportedCpuArchitectures" to Build.SUPPORTED_ABIS.toList(),
34
+ "osName" to "Android",
35
+ "osVersion" to Build.VERSION.RELEASE,
36
+ "osBuildId" to Build.DISPLAY,
37
+ "osInternalBuildId" to Build.ID,
38
+ "osBuildFingerprint" to Build.FINGERPRINT,
39
+ "platformApiLevel" to Build.VERSION.SDK_INT,
40
+ "deviceName" to (Settings.Global.getString(context.contentResolver, Settings.Global.DEVICE_NAME) ?: Build.MODEL),
41
+ "deviceType" to deviceType(context),
42
+ )
43
+ }
44
+
45
+ private fun isEmulator(): Boolean =
46
+ Build.FINGERPRINT.startsWith("generic") || Build.FINGERPRINT.contains("emulator") ||
47
+ Build.HARDWARE == "goldfish" || Build.HARDWARE == "ranchu" ||
48
+ Build.MODEL.contains("Emulator") || Build.MODEL.contains("Android SDK built for") ||
49
+ Build.PRODUCT.startsWith("sdk") || Build.PRODUCT.contains("emulator") || Build.PRODUCT.contains("simulator")
50
+
51
+ private fun deviceType(context: Context): Int {
52
+ val ui = context.getSystemService(Context.UI_MODE_SERVICE) as? UiModeManager
53
+ if (ui?.currentModeType == Configuration.UI_MODE_TYPE_TELEVISION) return 4
54
+ val pm = context.packageManager
55
+ if (pm.hasSystemFeature("android.software.leanback")) return 4
56
+ if (pm.hasSystemFeature("org.chromium.arc") || pm.hasSystemFeature("org.chromium.arc.device_management")) return 3
57
+ return if (context.resources.configuration.smallestScreenWidthDp >= 600) 2 else 1
58
+ }
59
+
60
+ private fun isRooted(): Boolean {
61
+ if (Build.TAGS?.contains("test-keys") == true) return true
62
+ val paths = listOf("/system/app/Superuser.apk", "/sbin/su", "/system/bin/su", "/system/xbin/su",
63
+ "/data/local/xbin/su", "/data/local/bin/su", "/system/sd/xbin/su", "/system/bin/failsafe/su", "/data/local/su")
64
+ return paths.any { File(it).exists() }
65
+ }
66
+
67
+ // Android 8+ throws unless the app declares REQUEST_INSTALL_PACKAGES; without it the answer is false.
68
+ private fun sideLoading(c: Context): Boolean =
69
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
70
+ try { c.packageManager.canRequestPackageInstalls() } catch (e: SecurityException) { false }
71
+ } else {
72
+ @Suppress("DEPRECATION")
73
+ Settings.Secure.getInt(c.contentResolver, Settings.Secure.INSTALL_NON_MARKET_APPS, 0) == 1
74
+ }
75
+
76
+ override fun call(ctx: PxModuleContext, method: String, args: JSONArray, reply: PxReply) {
77
+ val c = ctx.activity
78
+ when (method) {
79
+ "getUptime" -> reply(SystemClock.uptimeMillis(), null)
80
+ "getMaxMemory" -> {
81
+ val max = Runtime.getRuntime().maxMemory()
82
+ reply(if (max == Long.MAX_VALUE) 9007199254740991L else max, null)
83
+ }
84
+ "isRooted" -> reply(isRooted(), null)
85
+ "isSideLoadingEnabled" -> reply(sideLoading(c), null)
86
+ "getPlatformFeatures" -> reply(c.packageManager.systemAvailableFeatures.mapNotNull { it.name }, null)
87
+ "hasPlatformFeature" -> reply(c.packageManager.hasSystemFeature(args.optString(0)), null)
88
+ else -> reply(null, "Device has no method '$method'")
89
+ }
90
+ }
91
+ }
@@ -0,0 +1,120 @@
1
+ import UIKit
2
+
3
+ /// expo-device on iOS. Constants are read once at startup; the model name comes from a table of iPhone
4
+ /// identifiers (unknown models, iPads and newer phones report the raw identifier, for example "iPad14,1").
5
+ final class DeviceModule: PxModule {
6
+ static let moduleName = "Device"
7
+
8
+ required init() {}
9
+
10
+ /// iPhone identifier: (marketing name, release year).
11
+ private static let iPhones: [String: (String, Int)] = [
12
+ "iPhone10,1": ("iPhone 8", 2017), "iPhone10,4": ("iPhone 8", 2017),
13
+ "iPhone10,2": ("iPhone 8 Plus", 2017), "iPhone10,5": ("iPhone 8 Plus", 2017),
14
+ "iPhone10,3": ("iPhone X", 2017), "iPhone10,6": ("iPhone X", 2017),
15
+ "iPhone11,2": ("iPhone XS", 2018), "iPhone11,4": ("iPhone XS Max", 2018),
16
+ "iPhone11,6": ("iPhone XS Max", 2018), "iPhone11,8": ("iPhone XR", 2018),
17
+ "iPhone12,1": ("iPhone 11", 2019), "iPhone12,3": ("iPhone 11 Pro", 2019),
18
+ "iPhone12,5": ("iPhone 11 Pro Max", 2019), "iPhone12,8": ("iPhone SE (2nd generation)", 2020),
19
+ "iPhone13,1": ("iPhone 12 mini", 2020), "iPhone13,2": ("iPhone 12", 2020),
20
+ "iPhone13,3": ("iPhone 12 Pro", 2020), "iPhone13,4": ("iPhone 12 Pro Max", 2020),
21
+ "iPhone14,4": ("iPhone 13 mini", 2021), "iPhone14,5": ("iPhone 13", 2021),
22
+ "iPhone14,2": ("iPhone 13 Pro", 2021), "iPhone14,3": ("iPhone 13 Pro Max", 2021),
23
+ "iPhone14,6": ("iPhone SE (3rd generation)", 2022), "iPhone14,7": ("iPhone 14", 2022),
24
+ "iPhone14,8": ("iPhone 14 Plus", 2022), "iPhone15,2": ("iPhone 14 Pro", 2022),
25
+ "iPhone15,3": ("iPhone 14 Pro Max", 2022), "iPhone15,4": ("iPhone 15", 2023),
26
+ "iPhone15,5": ("iPhone 15 Plus", 2023), "iPhone16,1": ("iPhone 15 Pro", 2023),
27
+ "iPhone16,2": ("iPhone 15 Pro Max", 2023), "iPhone17,3": ("iPhone 16", 2024),
28
+ "iPhone17,4": ("iPhone 16 Plus", 2024), "iPhone17,1": ("iPhone 16 Pro", 2024),
29
+ "iPhone17,2": ("iPhone 16 Pro Max", 2024), "iPhone17,5": ("iPhone 16e", 2025),
30
+ "iPhone18,3": ("iPhone 17", 2025), "iPhone18,1": ("iPhone 17 Pro", 2025),
31
+ "iPhone18,2": ("iPhone 17 Pro Max", 2025), "iPhone18,4": ("iPhone Air", 2025),
32
+ "iPhone18,5": ("iPhone 17e", 2026), "iPhone19,2": ("iPhone 18 Pro", 2026),
33
+ "iPhone19,3": ("iPhone 18 Pro Max", 2026),
34
+ ]
35
+
36
+ private static func sysctlString(_ name: String) -> String? {
37
+ var size = 0
38
+ guard sysctlbyname(name, nil, &size, nil, 0) == 0, size > 0 else { return nil }
39
+ var buf = [CChar](repeating: 0, count: size)
40
+ guard sysctlbyname(name, &buf, &size, nil, 0) == 0 else { return nil }
41
+ return String(cString: buf)
42
+ }
43
+
44
+ private static var isSimulator: Bool {
45
+ #if targetEnvironment(simulator)
46
+ return true
47
+ #else
48
+ return false
49
+ #endif
50
+ }
51
+
52
+ private static var modelId: String? {
53
+ if isSimulator, let id = ProcessInfo.processInfo.environment["SIMULATOR_MODEL_IDENTIFIER"] { return id }
54
+ return sysctlString("hw.machine")
55
+ }
56
+
57
+ private static var deviceType: Int {
58
+ if ProcessInfo.processInfo.isiOSAppOnMac || ProcessInfo.processInfo.isMacCatalystApp { return 3 }
59
+ switch UIDevice.current.userInterfaceIdiom {
60
+ case .phone: return 1
61
+ case .pad: return 2
62
+ case .mac: return 3
63
+ case .tv: return 4
64
+ default: return 0
65
+ }
66
+ }
67
+
68
+ static var constants: [String: Any] {
69
+ let d = UIDevice.current
70
+ let id = modelId
71
+ let known = id.flatMap { iPhones[$0] }
72
+ let build: Any = sysctlString("kern.osversion") ?? NSNull()
73
+ var arch = "arm64"
74
+ #if arch(x86_64)
75
+ arch = "x86_64"
76
+ #endif
77
+ return [
78
+ "isDevice": !isSimulator,
79
+ "brand": "Apple",
80
+ "manufacturer": "Apple",
81
+ "modelName": known?.0 ?? id ?? NSNull(),
82
+ "modelId": id ?? NSNull(),
83
+ "designName": NSNull(),
84
+ "productName": NSNull(),
85
+ "deviceYearClass": known?.1 ?? NSNull(),
86
+ "totalMemory": ProcessInfo.processInfo.physicalMemory,
87
+ "supportedCpuArchitectures": [arch],
88
+ "osName": d.userInterfaceIdiom == .pad ? "iPadOS" : d.systemName,
89
+ "osVersion": d.systemVersion,
90
+ "osBuildId": build,
91
+ "osInternalBuildId": build,
92
+ "osBuildFingerprint": NSNull(),
93
+ "platformApiLevel": NSNull(),
94
+ "deviceName": d.name,
95
+ "deviceType": deviceType,
96
+ ]
97
+ }
98
+
99
+ private static var looksJailbroken: Bool {
100
+ if isSimulator { return false }
101
+ let paths = ["/Applications/Cydia.app", "/Library/MobileSubstrate/MobileSubstrate.dylib", "/bin/bash",
102
+ "/usr/sbin/sshd", "/etc/apt", "/private/var/lib/apt/", "/var/jb"]
103
+ if paths.contains(where: { FileManager.default.fileExists(atPath: $0) }) { return true }
104
+ // A sandboxed app cannot write outside its container.
105
+ let probe = "/private/pyxpo-jb-probe.txt"
106
+ if (try? "x".write(toFile: probe, atomically: true, encoding: .utf8)) != nil {
107
+ try? FileManager.default.removeItem(atPath: probe)
108
+ return true
109
+ }
110
+ return false
111
+ }
112
+
113
+ func call(_ ctx: PxModuleContext, _ method: String, _ args: [Any], _ reply: @escaping PxReply) {
114
+ switch method {
115
+ case "getUptime": reply(Int(ProcessInfo.processInfo.systemUptime * 1000), nil)
116
+ case "isRooted": reply(Self.looksJailbroken, nil)
117
+ default: reply(nil, "Device has no method '\(method)'")
118
+ }
119
+ }
120
+ }
File without changes
@@ -0,0 +1,15 @@
1
+ {
2
+ "schemaVersion": 1,
3
+ "nativeContract": 2,
4
+ "name": "device",
5
+ "native": "Device",
6
+ "ios": {
7
+ "classes": ["DeviceModule"]
8
+ },
9
+ "android": {
10
+ "namespace": "dev.pyxpo.modules.device",
11
+ "classes": ["dev.pyxpo.modules.device.DeviceModule"],
12
+ "dependencies": [],
13
+ "minSdk": 24
14
+ }
15
+ }
@@ -0,0 +1,80 @@
1
+ Metadata-Version: 2.4
2
+ Name: pyxpo-device
3
+ Version: 0.1.0a1
4
+ Summary: Device facts for pyxpo apps: brand, model, OS, memory, device type (expo-device shape).
5
+ Author: Vishal Chaubey
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/pyxpo/pyxpo/tree/main/packages/device
8
+ Project-URL: Issues, https://github.com/pyxpo/pyxpo/issues
9
+ Keywords: pyxpo,pyxpo-module,mobile,android,ios
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Topic :: Software Development :: User Interfaces
14
+ Requires-Python: >=3.9
15
+ Description-Content-Type: text/markdown
16
+ License-File: LICENSE
17
+ Requires-Dist: pyxpo<0.2,>=0.1.0a2
18
+ Dynamic: license-file
19
+
20
+ # pyxpo-device
21
+
22
+ Facts about the device a [pyxpo](https://github.com/pyxpo/pyxpo) app runs on: brand, model, OS, memory and
23
+ device type. The API follows [expo-device](https://docs.expo.dev/versions/latest/sdk/device/).
24
+
25
+ ```sh
26
+ pip install pyxpo-device
27
+ ```
28
+
29
+ ```toml
30
+ [tool.pyxpo]
31
+ modules = ["device"]
32
+ ```
33
+
34
+ ```python
35
+ import pyxpo_device as Device
36
+
37
+ print(Device.brand, Device.model_name, Device.os_name, Device.os_version)
38
+ if Device.device_type == Device.DeviceType.TABLET:
39
+ ...
40
+ uptime_ms = await Device.get_uptime_async()
41
+ ```
42
+
43
+ Every name also has its expo-device spelling (`Device.modelName`, `Device.getUptimeAsync`).
44
+
45
+ ## Constants
46
+
47
+ Read once at app start, no bridge round trip. `None` where a platform has no value.
48
+
49
+ | Name | iOS | Android |
50
+ | --- | --- | --- |
51
+ | `is_device` | False on the Simulator | False on an emulator (best-effort check) |
52
+ | `brand`, `manufacturer` | `"Apple"` | `Build.BRAND`, `Build.MANUFACTURER` |
53
+ | `model_name` | Marketing name for known iPhones, else the identifier | `Build.MODEL` |
54
+ | `model_id` | Identifier, for example `"iPhone17,3"` | None |
55
+ | `design_name`, `product_name` | None | `Build.DEVICE`, `Build.PRODUCT` |
56
+ | `device_year_class` | Release year of known iPhones | None |
57
+ | `total_memory` | Bytes of RAM | Bytes of RAM |
58
+ | `supported_cpu_architectures` | `["arm64"]` | `Build.SUPPORTED_ABIS` |
59
+ | `os_name` | `"iOS"` or `"iPadOS"` | `"Android"` |
60
+ | `os_version` | For example `"26.0"` | For example `"15"` |
61
+ | `os_build_id`, `os_internal_build_id` | Kernel OS build | `Build.DISPLAY`, `Build.ID` |
62
+ | `os_build_fingerprint`, `platform_api_level` | None | `Build.FINGERPRINT`, API level |
63
+ | `device_name` | `UIDevice.name` (a generic name on iOS 16+) | Device name setting, else the model |
64
+ | `device_type` | `DeviceType` from the interface idiom | `DeviceType` from TV mode and screen width (600dp+ = tablet) |
65
+
66
+ On the desktop the values come from Python's `platform` module and `device_type` is `DESKTOP`.
67
+
68
+ ## Functions
69
+
70
+ - `get_device_type_async()` -> `DeviceType`
71
+ - `get_uptime_async()` -> milliseconds since boot, not counting deep sleep
72
+ - `get_max_memory_async()` -> Android: the runtime's memory limit in bytes; elsewhere `MAX_SAFE_INTEGER`
73
+ - `is_rooted_experimental_async()` -> best-effort jailbreak or root check. Easy to fool; never use it for security.
74
+ - `is_side_loading_enabled_async()` -> Android: whether the app may install apps (needs `REQUEST_INSTALL_PACKAGES` on Android 8+)
75
+ - `get_platform_features_async()` -> Android system feature names; `[]` elsewhere
76
+ - `has_platform_feature_async(feature)` -> Android: for example `"android.hardware.camera"`; `False` elsewhere
77
+
78
+ ## Permissions
79
+
80
+ None.
@@ -0,0 +1,15 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ pyxpo_device/__init__.py
5
+ pyxpo_device/py.typed
6
+ pyxpo_device/pyxpo-module.json
7
+ pyxpo_device.egg-info/PKG-INFO
8
+ pyxpo_device.egg-info/SOURCES.txt
9
+ pyxpo_device.egg-info/dependency_links.txt
10
+ pyxpo_device.egg-info/entry_points.txt
11
+ pyxpo_device.egg-info/requires.txt
12
+ pyxpo_device.egg-info/top_level.txt
13
+ pyxpo_device/android/src/main/AndroidManifest.xml
14
+ pyxpo_device/android/src/main/java/dev/pyxpo/modules/device/DeviceModule.kt
15
+ pyxpo_device/ios/DeviceModule.swift
@@ -0,0 +1,2 @@
1
+ [pyxpo.modules]
2
+ device = pyxpo_device
@@ -0,0 +1 @@
1
+ pyxpo<0.2,>=0.1.0a2
@@ -0,0 +1 @@
1
+ pyxpo_device
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+