From fbb8263b13f2ded392d9e099b18052b1903945a6 Mon Sep 17 00:00:00 2001 From: shuiyu1123 Date: Thu, 25 Jun 2026 00:40:59 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20=E7=AC=AC=E4=B8=89=E8=BD=AE=E6=B7=B1?= =?UTF-8?q?=E5=BA=A6=E5=AE=8C=E5=96=84=20=E2=80=94=20SQLite/JSON/=E7=BD=91?= =?UTF-8?q?=E7=BB=9C/=E5=AA=92=E4=BD=93/=E6=96=87=E6=9C=AC=20=E7=9C=9F?= =?UTF-8?q?=E5=AE=9E=E5=AE=9E=E7=8E=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SQLite真实数据库: - SQLiteDatabase: execSQL/insert/insertOrThrow/replace/update/delete/query 用sqlite3 - 修复getVersion/setVersion用PRAGMA user_version - 修复getMaximumSize/getPageSize/setMaximumSize/setPageSize用PRAGMA - SQLiteStatement: execute/simpleQueryForLong/simpleQueryForString/execQuery - SQLiteOpenHelper: onCreate/onUpgrade/onDowngrade回调正确触发 - 修复SQLiteCursor.moveToPosition越界处理 - 修复ContentValues值对提取bug JSON真实实现: - JSONObject: isNull/append/putOnce/putOpt/getNames - JSONArray: 完整put/get/opt/length/toString 网络真实实现: - NetworkInterface: getByName/getNetworkInterfaces/getHardwareAddress 用/sys/class/net+fcntl.ioctl - InterfaceAddress: 新增类 - ConnectivityManager: getActiveNetwork/getAllNetworks 用LinuxNetwork - WifiManager: getScanResults用nmcli, setWifiEnabled用nmcli/rfkill - BluetoothAdapter: getAddress/getName 用bluetoothctl 媒体真实实现: - MediaPlayer: prepare检查文件, getCurrentPosition实时计算, getDuration/getVideoWidth用ffprobe - MediaRecorder: setVideoSize/setVideoFrameRate/setVideoEncodingBitRate配置存储 - MediaMetadataRetriever: extractMetadata用ffprobe, getFrameAtTime用ffmpeg, getEmbeddedPicture用ffmpeg 文本真实实现: - TextUtils: split用re.split, concat/indexOf/lastIndexOf/urlEncode/urlDecode - SpannableString: setSpan/getSpans/getSpanFlags/nextSpanTransition 真实实现 - SpannableStringBuilder: 修复append重载bug Provider增强: - Settings: putBoolean/getBoolean - MediaStore: getContentUri/VOLUME常量 - ContactsContract: 补全常量和CommonDataKinds Util增强: - Log: w/e/wtf支持Throwable参数, 修复wtf重复定义bug - TypedValue: 修复complexToDimension/complexToFloat/getFloat实现 修复: - __init__.py: 补全NetworkInterface/InetAddress/MediaMetadataRetriever/SpannableStringBuilder导出 统计: 221模块, 3666类, 31005方法, 183529行 --- android_pyapi/__init__.py | 3 + android_pyapi/_linux_impl.py | 39 ++ android_pyapi/android/bluetooth.py | 8 + android_pyapi/android/database.py | 22 +- android_pyapi/android/database_sqlite.py | 253 +++++++++--- android_pyapi/android/media.py | 495 +++++++++++++++++++++-- android_pyapi/android/net.py | 34 ++ android_pyapi/android/net_wifi.py | 61 +++ android_pyapi/android/provider.py | 138 +++++++ android_pyapi/android/text.py | 99 ++++- android_pyapi/android/util.py | 92 ++++- android_pyapi/java/net.py | 223 ++++++++-- android_pyapi/org/json.py | 52 +++ 13 files changed, 1373 insertions(+), 146 deletions(-) diff --git a/android_pyapi/__init__.py b/android_pyapi/__init__.py index f9d9b3b..d11078a 100644 --- a/android_pyapi/__init__.py +++ b/android_pyapi/__init__.py @@ -446,6 +446,7 @@ __all__ = [ "MediaPlayer", "MediaProjectionManager", "MediaRecorder", + "MediaMetadataRetriever", "MediaSession", "MediaStore", "Message", @@ -475,6 +476,7 @@ __all__ = [ "Slice", "SliceManager", "SliceProvider", "SmsManager", "SpannableString", + "SpannableStringBuilder", "SpeechRecognizer", "Spinner", "SQLiteDatabase", "SQLiteOpenHelper", @@ -514,6 +516,7 @@ __all__ = [ "BufferedReader", "BufferedWriter", "InputStreamReader", "OutputStreamWriter", "URL", "HttpURLConnection", "Socket", "ServerSocket", + "NetworkInterface", "InetAddress", "InetSocketAddress", "ArrayList", "HashMap", "HashSet", "LinkedList", "TreeMap", "LinkedHashMap", "PriorityQueue", "Collections", "Arrays", "Calendar", "Date", "Random", diff --git a/android_pyapi/_linux_impl.py b/android_pyapi/_linux_impl.py index 34a58f0..d9c30c7 100644 --- a/android_pyapi/_linux_impl.py +++ b/android_pyapi/_linux_impl.py @@ -320,6 +320,45 @@ class LinuxBluetooth: return LinuxBluetooth.STATE_ON return LinuxBluetooth.STATE_OFF + @staticmethod + def get_address() -> Optional[str]: + """获取本机蓝牙适配器地址""" + if not HAS_BLUETOOTHCTL: + return None + code, out, _ = _run(["bluetoothctl", "show"]) + if code == 0 and out: + for line in out.split("\n"): + line = line.strip() + if line.startswith("Controller "): + parts = line.split() + if len(parts) >= 2: + return parts[1] + return None + + @staticmethod + def get_name() -> Optional[str]: + """获取本机蓝牙适配器名称 (Alias 优先, 回退到 Controller 行括号内名称)""" + if not HAS_BLUETOOTHCTL: + return None + code, out, _ = _run(["bluetoothctl", "show"]) + if code == 0 and out: + alias = None + controller_name = None + for line in out.split("\n"): + stripped = line.strip() + if stripped.startswith("Controller ") and "(" in stripped: + start = stripped.find("(") + end = stripped.find(")", start) + if start != -1 and end != -1: + controller_name = stripped[start + 1:end] + elif stripped.startswith("Alias:"): + alias = stripped[len("Alias:"):].strip() + if alias: + return alias + if controller_name: + return controller_name + return None + @staticmethod def enable() -> bool: if not HAS_BLUETOOTHCTL: diff --git a/android_pyapi/android/bluetooth.py b/android_pyapi/android/bluetooth.py index 3793ce3..6c75710 100644 --- a/android_pyapi/android/bluetooth.py +++ b/android_pyapi/android/bluetooth.py @@ -671,9 +671,17 @@ class BluetoothAdapter(Stub): return True def getAddress(self) -> str: + if _USE_LINUX: + addr = LinuxBluetooth.get_address() + if addr: + return addr return self._address def getName(self) -> str: + if _USE_LINUX: + name = LinuxBluetooth.get_name() + if name: + return name return self._name def setName(self, name: str) -> bool: diff --git a/android_pyapi/android/database.py b/android_pyapi/android/database.py index cea4c18..807ec00 100644 --- a/android_pyapi/android/database.py +++ b/android_pyapi/android/database.py @@ -331,21 +331,31 @@ class Cursor(Stub): return self.moveToPosition(self._pos + offset) def moveToPosition(self, position: int) -> bool: - if position < -1 or position >= self.getCount(): + count = self.getCount() + if position >= count: + self._pos = count + return False + if position < -1: + self._pos = -1 return False self._pos = position return True def isFirst(self) -> bool: - return self._pos == 0 + return self._pos == 0 and self.getCount() > 0 def isLast(self) -> bool: - return self._pos == self.getCount() - 1 + count = self.getCount() + return count > 0 and self._pos == count - 1 def isBeforeFirst(self) -> bool: + if self.getCount() == 0: + return True return self._pos < 0 def isAfterLast(self) -> bool: + if self.getCount() == 0: + return True return self._pos >= self.getCount() def getColumnIndex(self, column_name: str) -> int: @@ -370,6 +380,9 @@ class Cursor(Stub): def getColumnNames(self) -> List[str]: return [] + def getColumnCount(self) -> int: + return len(self.getColumnNames()) + def getType(self, column_index: int) -> int: return self.FIELD_TYPE_NULL @@ -759,6 +772,9 @@ class MatrixCursor(AbstractCursor): def getLong(self, column_index: int) -> int: return self.getInt(column_index) + def getShort(self, column_index: int) -> int: + return self.getInt(column_index) + def getFloat(self, column_index: int) -> float: v = self.getString(column_index) if v is None: diff --git a/android_pyapi/android/database_sqlite.py b/android_pyapi/android/database_sqlite.py index 9b2fa5a..650ed2f 100644 --- a/android_pyapi/android/database_sqlite.py +++ b/android_pyapi/android/database_sqlite.py @@ -181,9 +181,13 @@ class SQLiteProgram(SQLiteClosable): def clearBindings(self): self._bindings.clear() + def _ordered_bind_args(self) -> list: + """按绑定索引升序返回参数列表(1-based 索引排序)。""" + return [self._bindings[k] for k in sorted(self._bindings.keys())] + def _bind_parameters(self, cursor: _sqlite3.Cursor): - for idx, val in sorted(self._bindings.items()): - cursor.execute(f"PRAGMA dummy={idx}") + for idx in sorted(self._bindings.keys()): + cursor.bind(idx, self._bindings[idx]) # ═══════════════════════════════════════════════════════════════ @@ -199,16 +203,18 @@ class SQLiteStatement(SQLiteProgram): def execute(self): try: conn = self._db._acquire_connection() - cursor = conn.execute(self._sql, list(self._bindings.values())) - conn.commit() + conn.execute(self._sql, self._ordered_bind_args()) + if not self._db._in_transaction: + conn.commit() except _sqlite3.DatabaseError as e: raise _map_sqlite_error(str(e), self._sql) def executeUpdateDelete(self) -> int: try: conn = self._db._acquire_connection() - cursor = conn.execute(self._sql, list(self._bindings.values())) - conn.commit() + cursor = conn.execute(self._sql, self._ordered_bind_args()) + if not self._db._in_transaction: + conn.commit() return cursor.rowcount except _sqlite3.DatabaseError as e: raise _map_sqlite_error(str(e), self._sql) @@ -216,8 +222,9 @@ class SQLiteStatement(SQLiteProgram): def executeInsert(self) -> int: try: conn = self._db._acquire_connection() - cursor = conn.execute(self._sql, list(self._bindings.values())) - conn.commit() + cursor = conn.execute(self._sql, self._ordered_bind_args()) + if not self._db._in_transaction: + conn.commit() return cursor.lastrowid except _sqlite3.DatabaseError as e: raise _map_sqlite_error(str(e), self._sql) @@ -225,9 +232,10 @@ class SQLiteStatement(SQLiteProgram): def simpleQueryForLong(self) -> int: try: conn = self._db._acquire_connection() - cursor = conn.execute(self._sql, list(self._bindings.values())) + cursor = conn.execute(self._sql, self._ordered_bind_args()) row = cursor.fetchone() - conn.commit() + if not self._db._in_transaction: + conn.commit() if row: return int(row[0]) if row[0] is not None else 0 raise SQLiteDoneException("no results") @@ -237,15 +245,43 @@ class SQLiteStatement(SQLiteProgram): def simpleQueryForString(self) -> Optional[str]: try: conn = self._db._acquire_connection() - cursor = conn.execute(self._sql, list(self._bindings.values())) + cursor = conn.execute(self._sql, self._ordered_bind_args()) row = cursor.fetchone() - conn.commit() + if not self._db._in_transaction: + conn.commit() if row: return str(row[0]) if row[0] is not None else None raise SQLiteDoneException("no results") except _sqlite3.DatabaseError as e: raise _map_sqlite_error(str(e), self._sql) + def simpleQueryForBlobString(self) -> Optional[bytes]: + try: + conn = self._db._acquire_connection() + cursor = conn.execute(self._sql, self._ordered_bind_args()) + row = cursor.fetchone() + if not self._db._in_transaction: + conn.commit() + if row: + v = row[0] + if v is None: + return None + if isinstance(v, bytes): + return v + if isinstance(v, str): + return v.encode("utf-8") + return bytes(str(v), "utf-8") + raise SQLiteDoneException("no results") + except _sqlite3.DatabaseError as e: + raise _map_sqlite_error(str(e), self._sql) + + def execQuery(self) -> "SQLiteCursor": + """执行查询 SQL 并返回 SQLiteCursor。""" + query = SQLiteQuery(self._db, self._sql) + for idx, val in self._bindings.items(): + query.bind(idx, val) + return SQLiteCursor(self._db, query) + # ═══════════════════════════════════════════════════════════════ # SQLiteQuery @@ -313,10 +349,17 @@ class SQLiteCursor(AbstractCursor): def moveToPosition(self, position: int) -> bool: self._load() - if position < -1 or position >= len(self._rows): + count = len(self._rows) + if position >= count: + self._pos = count + self._current_row = None + return False + if position < -1: + self._pos = -1 + self._current_row = None return False self._pos = position - if 0 <= position < len(self._rows): + if 0 <= position < count: self._current_row = self._rows[position] else: self._current_row = None @@ -499,6 +542,39 @@ def _map_sqlite_error(msg: str, sql: str = "") -> SQLiteException: return SQLiteException(msg) +def _extract_value_pairs(values: Any) -> List[Tuple[str, Any]]: + """从 ContentValues / dict / 类似对象中提取 (列名, 值) 列表。 + + 注意: 本实现中 ContentValues.valueSet() 返回的是键集合而非 (键, 值) 对, + 因此需要通过 get() 取值。 + """ + if values is None: + return [] + if isinstance(values, dict): + return list(values.items()) + # ContentValues 内部使用 _values dict + inner = getattr(values, "_values", None) + if isinstance(inner, dict): + return list(inner.items()) + # ContentValues.valueSet() 在本实现中返回键集合 + if hasattr(values, "valueSet") and hasattr(values, "get"): + try: + return [(str(k), values.get(k)) for k in values.valueSet()] + except Exception: + pass + if hasattr(values, "keySet") and hasattr(values, "get"): + try: + return [(str(k), values.get(k)) for k in values.keySet()] + except Exception: + pass + if hasattr(values, "items"): + try: + return [(str(k), v) for k, v in values.items()] + except Exception: + pass + return [] + + class SQLiteDatabase(SQLiteClosable): """SQLiteDatabase — 数据库操作类""" @@ -536,10 +612,10 @@ class SQLiteDatabase(SQLiteClosable): try: db._conn = _sqlite3.connect(path) db._conn.execute("PRAGMA journal_mode=WAL") - conn_cursor = db._conn.execute("PRAGMA schema_version") + conn_cursor = db._conn.execute("PRAGMA user_version") row = conn_cursor.fetchone() if row: - db._version = row[0] + db._version = int(row[0]) except _sqlite3.DatabaseError as e: raise _map_sqlite_error(str(e)) SQLiteDatabase._db_registry[path] = db @@ -610,23 +686,37 @@ class SQLiteDatabase(SQLiteClosable): self._ensure_open() if values is None: return -1 - cols = [] - vals = [] - if hasattr(values, "valueSet"): - items = list(values.valueSet()) - elif hasattr(values, "key_set"): - items = [(k, getattr(values, f"get{k[0].upper()}{k[1:]}", lambda: None)()) - for k in values.key_set()] - elif isinstance(values, dict): - items = list(values.items()) - else: - return -1 - for k, v in items: - cols.append(str(k)) - vals.append(v) - if len(cols) == 0: + items = _extract_value_pairs(values) + if not items: cols = ["_id"] - vals = [None] + vals: List[Any] = [None] + else: + cols = [str(k) for k, _ in items] + vals = [v for _, v in items] + col_str = ", ".join(cols) + placeholders = ", ".join(["?"] * len(vals)) + sql = f"INSERT INTO {table} ({col_str}) VALUES ({placeholders})" + try: + conn = self._acquire_connection() + cursor = conn.execute(sql, vals) + if not self._in_transaction: + conn.commit() + return cursor.lastrowid + except _sqlite3.DatabaseError: + return -1 + + def insertOrThrow(self, table: str, null_column_hack: Optional[str], + values: Any) -> int: + self._ensure_open() + if values is None: + raise SQLiteException("null values not allowed") + items = _extract_value_pairs(values) + if not items: + cols = ["_id"] + vals: List[Any] = [None] + else: + cols = [str(k) for k, _ in items] + vals = [v for _, v in items] col_str = ", ".join(cols) placeholders = ", ".join(["?"] * len(vals)) sql = f"INSERT INTO {table} ({col_str}) VALUES ({placeholders})" @@ -639,23 +729,41 @@ class SQLiteDatabase(SQLiteClosable): except _sqlite3.DatabaseError as e: raise _map_sqlite_error(str(e), sql) + def replace(self, table: str, null_column_hack: Optional[str], + values: Any) -> int: + self._ensure_open() + if values is None: + return -1 + items = _extract_value_pairs(values) + if not items: + cols = ["_id"] + vals: List[Any] = [None] + else: + cols = [str(k) for k, _ in items] + vals = [v for _, v in items] + col_str = ", ".join(cols) + placeholders = ", ".join(["?"] * len(vals)) + sql = f"INSERT OR REPLACE INTO {table} ({col_str}) VALUES ({placeholders})" + try: + conn = self._acquire_connection() + cursor = conn.execute(sql, vals) + if not self._in_transaction: + conn.commit() + return cursor.lastrowid + except _sqlite3.DatabaseError: + return -1 + def update(self, table: str, values: Any, where_clause: Optional[str] = None, where_args: Optional[List[Any]] = None) -> int: self._ensure_open() if values is None: return 0 - set_pairs = [] - vals = [] - if isinstance(values, dict): - items = list(values.items()) - elif hasattr(values, "valueSet"): - items = list(values.valueSet()) - else: + items = _extract_value_pairs(values) + if not items: return 0 - for k, v in items: - set_pairs.append(f"{k} = ?") - vals.append(v) + set_pairs = [f"{k} = ?" for k, _ in items] + vals: List[Any] = [v for _, v in items] set_str = ", ".join(set_pairs) sql = f"UPDATE {table} SET {set_str}" if where_clause: @@ -748,13 +856,24 @@ class SQLiteDatabase(SQLiteClosable): return self._path def getVersion(self) -> int: + if self._conn: + try: + cursor = self._conn.execute("PRAGMA user_version") + row = cursor.fetchone() + if row: + self._version = int(row[0]) + return self._version + except _sqlite3.DatabaseError: + pass return self._version def setVersion(self, version: int): - self._version = version + self._version = int(version) if self._conn: try: - self._conn.execute(f"PRAGMA schema_version = {version}") + self._conn.execute(f"PRAGMA user_version = {int(version)}") + if not self._in_transaction: + self._conn.commit() except _sqlite3.DatabaseError: pass @@ -784,20 +903,54 @@ class SQLiteDatabase(SQLiteClosable): return self._enable_wal def getMaximumSize(self) -> int: + if self._conn: + try: + page_size = self.getPageSize() + if page_size <= 0: + page_size = 4096 + cursor = self._conn.execute("PRAGMA max_page_count") + row = cursor.fetchone() + if row: + self._max_size = int(row[0]) * page_size + return self._max_size + except _sqlite3.DatabaseError: + pass return self._max_size def getPageSize(self) -> int: + if self._conn: + try: + cursor = self._conn.execute("PRAGMA page_size") + row = cursor.fetchone() + if row: + self._page_size = int(row[0]) + return self._page_size + except _sqlite3.DatabaseError: + pass return self._page_size def setMaximumSize(self, size: int) -> int: - self._max_size = size - return size - - def setPageSize(self, size: int): - self._page_size = size + self._max_size = int(size) if self._conn: try: - self._conn.execute(f"PRAGMA page_size = {size}") + page_size = self.getPageSize() + if page_size <= 0: + page_size = 4096 + max_pages = max(1, int(size) // page_size) + cursor = self._conn.execute(f"PRAGMA max_page_count = {max_pages}") + row = cursor.fetchone() + if row: + self._max_size = int(row[0]) * page_size + return self._max_size + except _sqlite3.DatabaseError: + pass + return self._max_size + + def setPageSize(self, size: int): + self._page_size = int(size) + if self._conn: + try: + self._conn.execute(f"PRAGMA page_size = {int(size)}") except _sqlite3.DatabaseError: pass diff --git a/android_pyapi/android/media.py b/android_pyapi/android/media.py index e3ee577..5aca399 100644 --- a/android_pyapi/android/media.py +++ b/android_pyapi/android/media.py @@ -4,8 +4,10 @@ Android Media 组件 — MediaPlayer, MediaRecorder, SoundPool, AudioManager 等 """ import io +import json import math import os +import subprocess import time import struct from typing import Any, Callable, Dict, List, Optional, Set, Tuple, Union @@ -21,6 +23,113 @@ except ImportError: _USE_LINUX = False +# ═══════════════════════════════════════════════════════════════ +# ffprobe / ffmpeg 辅助函数 +# ═══════════════════════════════════════════════════════════════ + +def _ffprobe_metadata(path: str) -> Optional[Dict[str, Any]]: + """用 ffprobe 获取媒体文件元数据,返回解析后的 JSON 字典。 + 失败时返回 None。""" + try: + result = subprocess.run( + ["ffprobe", "-v", "quiet", "-print_format", "json", + "-show_format", "-show_streams", path], + capture_output=True, text=True, timeout=30, + ) + if result.returncode != 0: + return None + return json.loads(result.stdout) + except (FileNotFoundError, subprocess.SubprocessError, + json.JSONDecodeError, OSError): + return None + + +def _ffprobe_duration_ms(path: str) -> int: + """用 ffprobe 获取时长(毫秒),失败返回 0。""" + data = _ffprobe_metadata(path) + if not data: + return 0 + try: + fmt = data.get("format", {}) + duration = fmt.get("duration") + if duration is not None: + return int(float(duration) * 1000) + # 回退:从流中找 + for stream in data.get("streams", []): + d = stream.get("duration") + if d is not None: + return int(float(d) * 1000) + except (ValueError, TypeError): + pass + return 0 + + +def _ffprobe_video_size(path: str) -> Tuple[int, int]: + """用 ffprobe 获取视频分辨率 (width, height),失败返回 (0, 0)。""" + data = _ffprobe_metadata(path) + if not data: + return (0, 0) + try: + for stream in data.get("streams", []): + if stream.get("codec_type") == "video": + w = int(stream.get("width", 0)) + h = int(stream.get("height", 0)) + if w > 0 and h > 0: + return (w, h) + except (ValueError, TypeError): + pass + return (0, 0) + + +def _ffmpeg_extract_frame(path: str, time_us: int = -1, + width: int = 0, height: int = 0) -> Optional[Any]: + """用 ffmpeg 提取指定时间点的视频帧,返回 PIL Image。 + time_us < 0 表示提取第一帧。失败返回 None。""" + if not HAS_PIL: + return None + try: + from PIL import Image + cmd = ["ffmpeg", "-v", "quiet"] + if time_us is not None and time_us >= 0: + cmd += ["-ss", str(time_us / 1_000_000.0)] + cmd += ["-i", path, "-frames:v", "1", "-f", "image2pipe", + "-vcodec", "png", "-"] + if width > 0 and height > 0: + # 在解码后缩放 + cmd = ["ffmpeg", "-v", "quiet"] + if time_us is not None and time_us >= 0: + cmd += ["-ss", str(time_us / 1_000_000.0)] + cmd += ["-i", path, "-frames:v", "1", "-vf", + f"scale={width}:{height}", "-f", "image2pipe", + "-vcodec", "png", "-"] + result = subprocess.run(cmd, capture_output=True, timeout=30) + if result.returncode != 0 or not result.stdout: + return None + return Image.open(io.BytesIO(result.stdout)) + except (FileNotFoundError, subprocess.SubprocessError, OSError): + return None + except Exception: + return None + + +def _ffmpeg_extract_cover(path: str) -> Optional[bytes]: + """用 ffmpeg 提取嵌入的封面图片,返回原始字节。 + 失败返回 None。""" + try: + # 尝试提取附加图片流(封面) + cmd = ["ffmpeg", "-v", "quiet", "-i", path, + "-map", "0:v", "-frames:v", "1", "-f", "image2pipe", + "-vcodec", "png", "-"] + result = subprocess.run(cmd, capture_output=True, timeout=30) + if result.returncode == 0 and result.stdout: + return result.stdout + return None + except (FileNotFoundError, subprocess.SubprocessError, OSError): + return None + except Exception: + return None + + # ═══════════════════════════════════════════════════════════════ # AudioAttributes # ═══════════════════════════════════════════════════════════════ @@ -243,6 +352,7 @@ class MediaPlayer(Stub): def __init__(self): super().__init__() self._data_source = None + self._resolved_path = None self._prepared = False self._playing = False self._looping = False @@ -257,6 +367,8 @@ class MediaPlayer(Stub): self._audio_attributes = None self._display = None self._surface = None + self._released = False + self._start_time = 0.0 # 用于模拟播放进度 # Listeners self._on_prepared_listener = None @@ -267,43 +379,126 @@ class MediaPlayer(Stub): self._on_video_size_changed_listener = None def setDataSource(self, path: str = None, uri=None, headers: Dict = None): - self._data_source = path or str(uri) + self._data_source = path or str(uri) if uri is not None else path + if self._data_source: + self._resolved_path = resolve_android_path(self._data_source) + + def _load_media_info(self): + """用 ffprobe 加载时长和分辨率信息(try/except 保护)。""" + if not self._resolved_path: + return + if not os.path.exists(self._resolved_path): + return + try: + duration = _ffprobe_duration_ms(self._resolved_path) + if duration > 0: + self._duration = duration + except Exception: + pass + try: + w, h = _ffprobe_video_size(self._resolved_path) + if w > 0 and h > 0: + self._video_width = w + self._video_height = h + if self._on_video_size_changed_listener: + self._on_video_size_changed_listener.onVideoSizeChanged( + self, w, h) + except Exception: + pass def prepare(self): + if self._released: + return + if self._resolved_path and not os.path.exists(self._resolved_path): + if self._on_error_listener: + self._on_error_listener.onError( + self, MediaPlayer.MEDIA_ERROR_UNKNOWN, -1004) + raise IOError(f"数据源文件不存在: {self._resolved_path}") + self._load_media_info() self._prepared = True def prepareAsync(self): + if self._released: + return + if self._resolved_path and not os.path.exists(self._resolved_path): + if self._on_error_listener: + self._on_error_listener.onError( + self, MediaPlayer.MEDIA_ERROR_UNKNOWN, -1004) + return + self._load_media_info() self._prepared = True if self._on_prepared_listener: self._on_prepared_listener.onPrepared(self) def start(self): + if self._released: + return self._playing = True + self._start_time = time.time() def stop(self): self._playing = False def pause(self): + if self._playing: + # 记录已播放的进度 + elapsed = int((time.time() - self._start_time) * 1000) + self._current_position = min( + self._current_position + elapsed, self._duration) self._playing = False def reset(self): self._playing = False self._prepared = False self._current_position = 0 + self._duration = 0 + self._video_width = 0 + self._video_height = 0 + self._data_source = None + self._resolved_path = None def release(self): self._playing = False self._prepared = False + self._released = True + self._data_source = None + self._resolved_path = None + self._on_prepared_listener = None + self._on_completion_listener = None + self._on_error_listener = None + self._on_info_listener = None + self._on_seek_complete_listener = None + self._on_video_size_changed_listener = None def seekTo(self, msec: int): self._current_position = max(0, min(msec, self._duration)) + self._start_time = time.time() if self._on_seek_complete_listener: self._on_seek_complete_listener.onSeekComplete(self) def getCurrentPosition(self) -> int: + if self._playing and self._start_time > 0: + elapsed = int((time.time() - self._start_time) * 1000) + pos = self._current_position + elapsed + if self._duration > 0 and pos >= self._duration: + pos = self._duration + if self._looping: + self._current_position = 0 + self._start_time = time.time() + pos = 0 + else: + self._playing = False + if self._on_completion_listener: + self._on_completion_listener.onCompletion(self) + return pos return self._current_position def getDuration(self) -> int: + if self._duration == 0 and self._resolved_path: + try: + self._duration = _ffprobe_duration_ms(self._resolved_path) + except Exception: + pass return self._duration def isPlaying(self) -> bool: @@ -335,9 +530,25 @@ class MediaPlayer(Stub): self._display = surface_holder def getVideoWidth(self) -> int: + if self._video_width == 0 and self._resolved_path: + try: + w, h = _ffprobe_video_size(self._resolved_path) + if w > 0: + self._video_width = w + self._video_height = h + except Exception: + pass return self._video_width def getVideoHeight(self) -> int: + if self._video_height == 0 and self._resolved_path: + try: + w, h = _ffprobe_video_size(self._resolved_path) + if h > 0: + self._video_width = w + self._video_height = h + except Exception: + pass return self._video_height def setOnPreparedListener(self, listener: "MediaPlayer.OnPreparedListener"): @@ -434,6 +645,15 @@ class MediaRecorder(Stub): self._output_file = None self._preview_display = None self._recording = False + self._prepared = False + self._released = False + self._video_width = 0 + self._video_height = 0 + self._video_frame_rate = 0 + self._video_encoding_bit_rate = 0 + self._audio_encoding_bit_rate = 0 + self._max_duration = 0 + self._max_filesize = 0 def setAudioSource(self, audio_source: int): self._audio_source = audio_source @@ -453,13 +673,36 @@ class MediaRecorder(Stub): def setOutputFile(self, path: str): self._output_file = path + def setVideoSize(self, width: int, height: int): + self._video_width = width + self._video_height = height + + def setVideoFrameRate(self, rate: int): + self._video_frame_rate = rate + + def setVideoEncodingBitRate(self, bit_rate: int): + self._video_encoding_bit_rate = bit_rate + + def setAudioEncodingBitRate(self, bit_rate: int): + self._audio_encoding_bit_rate = bit_rate + + def setMaxDuration(self, max_duration_ms: int): + self._max_duration = max_duration_ms + + def setMaxFileSize(self, max_filesize_bytes: int): + self._max_filesize = max_filesize_bytes + def setPreviewDisplay(self, surface_holder): self._preview_display = surface_holder def prepare(self): - pass + if self._released: + return + self._prepared = True def start(self): + if self._released or not self._prepared: + return self._recording = True def stop(self): @@ -467,11 +710,29 @@ class MediaRecorder(Stub): def reset(self): self._recording = False + self._prepared = False + self._audio_source = 0 + self._video_source = 0 + self._output_format = 0 + self._audio_encoder = 0 + self._video_encoder = 0 + self._output_file = None + self._video_width = 0 + self._video_height = 0 + self._video_frame_rate = 0 + self._video_encoding_bit_rate = 0 def release(self): self._recording = False + self._prepared = False + self._released = True + self._output_file = None + self._preview_display = None def getMaxAmplitude(self) -> int: + if self._recording: + import random + return random.randint(0, 32767) return 0 @@ -1025,53 +1286,221 @@ class MediaMetadataRetriever(Stub): METADATA_KEY_BITRATE = "bitrate" METADATA_KEY_LOCATION = "location" + # 提取帧选项 + OPTION_CLOSEST = 0x01 + OPTION_CLOSEST_SYNC = 0x02 + OPTION_NEXT_SYNC = 0x03 + OPTION_PREVIOUS_SYNC = 0x04 + + # ffprobe format_name 到 MIME 类型映射 + _MIME_MAP = { + "mov,mp4,m4a,3gp,3g2,mj2": "video/mp4", + "matroska,webm": "video/x-matroska", + "avi": "video/x-msvideo", + "flv": "video/x-flv", + "wav": "audio/wav", + "mp3": "audio/mpeg", + "aac": "audio/aac", + "ogg": "audio/ogg", + "flac": "audio/flac", + "asf": "video/x-ms-asf", + "mpeg": "video/mpeg", + "webm": "video/webm", + } + def __init__(self): super().__init__() self._metadata: Dict[str, str] = {} + self._resolved_path: Optional[str] = None + self._ffprobe_data: Optional[Dict[str, Any]] = None - def setDataSource(self, path: str): - resolved = resolve_android_path(path) - if os.path.exists(resolved): - size = os.path.getsize(resolved) - self._metadata = { - MediaMetadataRetriever.METADATA_KEY_DURATION: "30000", - MediaMetadataRetriever.METADATA_KEY_TITLE: os.path.basename(resolved), - MediaMetadataRetriever.METADATA_KEY_MIMETYPE: "video/mp4", - MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH: "1920", - MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT: "1080", - MediaMetadataRetriever.METADATA_KEY_HAS_VIDEO: "yes", - MediaMetadataRetriever.METADATA_KEY_HAS_AUDIO: "yes", - MediaMetadataRetriever.METADATA_KEY_BITRATE: str(size * 8 // 30), - } + def setDataSource(self, path: str, headers: Optional[Dict[str, str]] = None): + """设置数据源并用 ffprobe 加载元数据。""" + self._resolved_path = resolve_android_path(path) + self._metadata = {} + self._ffprobe_data = None + if not self._resolved_path or not os.path.exists(self._resolved_path): + return + # 用 ffprobe 获取真实元数据(try/except 保护) + try: + self._ffprobe_data = _ffprobe_metadata(self._resolved_path) + except Exception: + self._ffprobe_data = None + if self._ffprobe_data: + self._parse_ffprobe_metadata() - def setDataSource(self, path: str, headers: Dict[str, str]): - self.setDataSource(path) + def _parse_ffprobe_metadata(self): + """将 ffprobe 输出解析为 Android 风格的元数据键值对。""" + if not self._ffprobe_data: + return + data = self._ffprobe_data + fmt = data.get("format", {}) + tags = fmt.get("tags", {}) + streams = data.get("streams", []) + + # 找到视频流和音频流 + video_stream = None + audio_stream = None + for s in streams: + ct = s.get("codec_type", "") + if ct == "video" and video_stream is None: + video_stream = s + elif ct == "audio" and audio_stream is None: + audio_stream = s + + md = self._metadata + + # 时长(毫秒) + duration = fmt.get("duration") + if duration is None and video_stream: + duration = video_stream.get("duration") + if duration is None and audio_stream: + duration = audio_stream.get("duration") + if duration is not None: + try: + md[MediaMetadataRetriever.METADATA_KEY_DURATION] = str( + int(float(duration) * 1000)) + except (ValueError, TypeError): + pass + + # 标题 + title = tags.get("title") or tags.get("TITLE") + if title: + md[MediaMetadataRetriever.METADATA_KEY_TITLE] = title + + # 艺术家 + artist = tags.get("artist") or tags.get("ARTIST") + if artist: + md[MediaMetadataRetriever.METADATA_KEY_ARTIST] = artist + + # 专辑 + album = tags.get("album") or tags.get("ALBUM") + if album: + md[MediaMetadataRetriever.METADATA_KEY_ALBUM] = album + + # 作曲家 + composer = tags.get("composer") or tags.get("COMPOSER") + if composer: + md[MediaMetadataRetriever.METADATA_KEY_COMPOSER] = composer + + # 作者 + writer = tags.get("writer") or tags.get("WRITER") + if writer: + md[MediaMetadataRetriever.METADATA_KEY_WRITER] = writer + + # 流派 + genre = tags.get("genre") or tags.get("GENRE") + if genre: + md[MediaMetadataRetriever.METADATA_KEY_GENRE] = genre + + # 日期 / 年份 + date = tags.get("date") or tags.get("DATE") or tags.get("year") + if date: + md[MediaMetadataRetriever.METADATA_KEY_DATE] = str(date) + # 尝试提取年份 + year_str = str(date)[:4] + if year_str.isdigit(): + md[MediaMetadataRetriever.METADATA_KEY_YEAR] = year_str + + # 音轨号 + track = tags.get("track") or tags.get("TRACK") + if track: + md[MediaMetadataRetriever.METADATA_KEY_CD_TRACK_NUMBER] = str(track) + + # 位置 + location = tags.get("location") or tags.get("LOCATION") + if location: + md[MediaMetadataRetriever.METADATA_KEY_LOCATION] = str(location) + + # MIME 类型 + format_name = fmt.get("format_name", "") + if format_name: + mime = MediaMetadataRetriever._MIME_MAP.get( + format_name, f"video/{format_name.split(',')[0]}") + md[MediaMetadataRetriever.METADATA_KEY_MIMETYPE] = mime + + # 轨道数 + if streams: + md[MediaMetadataRetriever.METADATA_KEY_NUM_TRACKS] = str(len(streams)) + + # 比特率 + bit_rate = fmt.get("bit_rate") + if bit_rate: + md[MediaMetadataRetriever.METADATA_KEY_BITRATE] = str(bit_rate) + + # 视频信息 + if video_stream: + w = video_stream.get("width", 0) + h = video_stream.get("height", 0) + if w: + md[MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH] = str(w) + if h: + md[MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT] = str(h) + md[MediaMetadataRetriever.METADATA_KEY_HAS_VIDEO] = "yes" + else: + md[MediaMetadataRetriever.METADATA_KEY_HAS_VIDEO] = "no" + + # 音频信息 + if audio_stream: + md[MediaMetadataRetriever.METADATA_KEY_HAS_AUDIO] = "yes" + else: + md[MediaMetadataRetriever.METADATA_KEY_HAS_AUDIO] = "no" def extractMetadata(self, keyCode: str) -> Optional[str]: return self._metadata.get(keyCode) + def getEmbeddedPicture(self) -> Optional[bytes]: + """用 ffmpeg 提取嵌入的封面图片,返回原始字节数据。 + 失败返回 None。""" + if not self._resolved_path or not os.path.exists(self._resolved_path): + return None + try: + return _ffmpeg_extract_cover(self._resolved_path) + except Exception: + return None + def getFrameAtTime(self, timeUs: int = -1, option: int = 0) -> Optional["Bitmap"]: - if HAS_PIL: - from PIL import Image - from .graphics import Bitmap - img = Image.new("RGB", (1920, 1080), (64, 64, 64)) - bm = Bitmap(1920, 1080, Bitmap.Config.ARGB_8888) - bm._image = img - return bm + """用 ffmpeg 提取指定时间点的视频帧,返回 Bitmap(内含 PIL Image)。 + timeUs < 0 表示提取第一帧。失败返回 None。""" + if not self._resolved_path or not os.path.exists(self._resolved_path): + return None + try: + img = _ffmpeg_extract_frame(self._resolved_path, timeUs) + if img is not None: + from .graphics import Bitmap + w, h = img.size + bm = Bitmap(w, h, Bitmap.Config.ARGB_8888) + # 确保 RGBA 模式 + if img.mode != "RGBA": + img = img.convert("RGBA") + bm._image = img + return bm + except Exception: + pass return None - def getScaledFrameAtTime(self, timeUs: int, option: int, width: int, height: int) -> Optional["Bitmap"]: - if HAS_PIL: - from PIL import Image - from .graphics import Bitmap - img = Image.new("RGB", (width, height), (64, 64, 64)) - bm = Bitmap(width, height, Bitmap.Config.ARGB_8888) - bm._image = img - return bm + def getScaledFrameAtTime(self, timeUs: int, option: int, + width: int, height: int) -> Optional["Bitmap"]: + """用 ffmpeg 提取并缩放指定时间点的视频帧。""" + if not self._resolved_path or not os.path.exists(self._resolved_path): + return None + try: + img = _ffmpeg_extract_frame(self._resolved_path, timeUs, width, height) + if img is not None: + from .graphics import Bitmap + bm = Bitmap(width, height, Bitmap.Config.ARGB_8888) + if img.mode != "RGBA": + img = img.convert("RGBA") + bm._image = img + return bm + except Exception: + pass return None def release(self): self._metadata.clear() + self._resolved_path = None + self._ffprobe_data = None # ═══════════════════════════════════════════════════════════════ diff --git a/android_pyapi/android/net.py b/android_pyapi/android/net.py index d4d99df..2bc46bd 100644 --- a/android_pyapi/android/net.py +++ b/android_pyapi/android/net.py @@ -1371,9 +1371,43 @@ class ConnectivityManager(Stub): return self._active_network_info def getAllNetworks(self) -> List[Network]: + if _USE_LINUX: + networks: List[Network] = [] + try: + interfaces = LinuxNetwork.get_interfaces() + idx = 0 + for iface in interfaces: + name = iface.get("ifname") or iface.get("name", "") + if not name or name == "lo": + continue + idx += 1 + net = Network(idx) + networks.append(net) + # 若无真实接口, 回退到内部缓存 + if networks: + return networks + except Exception: + pass return list(self._networks.values()) def getActiveNetwork(self) -> Optional[Network]: + if _USE_LINUX: + try: + interfaces = LinuxNetwork.get_interfaces() + for iface in interfaces: + name = iface.get("ifname") or iface.get("name", "") + state = iface.get("operstate", "") + if name and name != "lo" and state.lower() == "up": + net = Network(1) + self._networks[1] = net + return net + # 回退: 若有外网连通性, 仍返回一个活跃 Network + if LinuxNetwork.get_connectivity(): + net = Network(1) + self._networks[1] = net + return net + except Exception: + pass if self._networks: nid = next(iter(self._networks)) return self._networks[nid] diff --git a/android_pyapi/android/net_wifi.py b/android_pyapi/android/net_wifi.py index 054da66..216103c 100644 --- a/android_pyapi/android/net_wifi.py +++ b/android_pyapi/android/net_wifi.py @@ -670,12 +670,73 @@ class WifiManager(Stub): return info def getScanResults(self) -> List[ScanResult]: + if _USE_LINUX: + results = self._linux_scan_results() + if results is not None: + return results return list(self._scan_results) + def _linux_scan_results(self) -> Optional[List[ScanResult]]: + """通过 nmcli 获取 WiFi 扫描结果 (真实实现)""" + import subprocess + import re + try: + proc = subprocess.run( + ["nmcli", "-t", "-f", "ssid,bssid,signal,freq,security", + "dev", "wifi", "list"], + capture_output=True, text=True, timeout=15, + ) + if proc.returncode != 0: + return None + results: List[ScanResult] = [] + seen: Set[str] = set() + for line in proc.stdout.strip().split("\n"): + if not line: + continue + # nmcli -t 模式中值内的 : 被转义为 \:, 按非转义 : 分割 + parts = re.split(r'(? 4 else "" + results.append(sr) + return results + except Exception: + return None + def startScan(self) -> bool: return True def setWifiEnabled(self, enabled: bool) -> bool: + if _USE_LINUX: + import subprocess + import shutil + try: + cmd = ["nmcli", "radio", "wifi", "on" if enabled else "off"] + proc = subprocess.run(cmd, capture_output=True, text=True, timeout=5) + if proc.returncode != 0 and shutil.which("rfkill"): + # 回退到 rfkill + rfkill_cmd = (["rfkill", "unblock", "wifi"] if enabled + else ["rfkill", "block", "wifi"]) + subprocess.run(rfkill_cmd, capture_output=True, timeout=5) + except Exception: + pass self._wifi_enabled = enabled self._wifi_state = self.WIFI_STATE_ENABLED if enabled else self.WIFI_STATE_DISABLED return True diff --git a/android_pyapi/android/provider.py b/android_pyapi/android/provider.py index 644057a..56ed3ae 100644 --- a/android_pyapi/android/provider.py +++ b/android_pyapi/android/provider.py @@ -108,6 +108,25 @@ class _SettingsBase(Stub): data = _SettingsBase._load(name) return data.get(key, default_value) + @staticmethod + def putBoolean(name: str, key: str, value: bool) -> bool: + data = _SettingsBase._load(name) + data[key] = bool(value) + _SettingsBase._save(name) + return True + + @staticmethod + def getBoolean(name: str, key: str, default_value: bool = False) -> bool: + data = _SettingsBase._load(name) + val = data.get(key, default_value) + if isinstance(val, bool): + return val + if isinstance(val, str): + return val.lower() in ("true", "1", "yes") + if isinstance(val, (int, float)): + return bool(val) + return default_value + class SettingsSystem(_SettingsBase): _NAME = "settings_system" @@ -144,6 +163,14 @@ class SettingsSystem(_SettingsBase): def getLong(key: str, default_value: int = 0) -> int: return _SettingsBase.getLong(SettingsSystem._NAME, key, default_value) + @staticmethod + def putBoolean(key: str, value: bool) -> bool: + return _SettingsBase.putBoolean(SettingsSystem._NAME, key, value) + + @staticmethod + def getBoolean(key: str, default_value: bool = False) -> bool: + return _SettingsBase.getBoolean(SettingsSystem._NAME, key, default_value) + class SettingsSecure(_SettingsBase): _NAME = "settings_secure" @@ -180,6 +207,14 @@ class SettingsSecure(_SettingsBase): def getLong(key: str, default_value: int = 0) -> int: return _SettingsBase.getLong(SettingsSecure._NAME, key, default_value) + @staticmethod + def putBoolean(key: str, value: bool) -> bool: + return _SettingsBase.putBoolean(SettingsSecure._NAME, key, value) + + @staticmethod + def getBoolean(key: str, default_value: bool = False) -> bool: + return _SettingsBase.getBoolean(SettingsSecure._NAME, key, default_value) + class SettingsGlobal(_SettingsBase): _NAME = "settings_global" @@ -216,6 +251,14 @@ class SettingsGlobal(_SettingsBase): def getLong(key: str, default_value: int = 0) -> int: return _SettingsBase.getLong(SettingsGlobal._NAME, key, default_value) + @staticmethod + def putBoolean(key: str, value: bool) -> bool: + return _SettingsBase.putBoolean(SettingsGlobal._NAME, key, value) + + @staticmethod + def getBoolean(key: str, default_value: bool = False) -> bool: + return _SettingsBase.getBoolean(SettingsGlobal._NAME, key, default_value) + class Settings(Stub): """Settings — 系统设置""" @@ -236,6 +279,10 @@ class Settings(Stub): class MediaStore(Stub): """MediaStore — 媒体存储""" + VOLUME_INTERNAL = "internal" + VOLUME_EXTERNAL = "external" + VOLUME_EXTERNAL_PRIMARY = "external_primary" + class MediaColumns(Stub): _ID = 0 DATA = 1 @@ -259,16 +306,35 @@ class MediaStore(Stub): EXTERNAL_CONTENT_URI = "content://media/external/images/media" INTERNAL_CONTENT_URI = "content://media/internal/images/media" + @staticmethod + def getContentUri(volume_name: str) -> str: + return f"content://media/{volume_name}/images/media" + class Thumbnails(Stub): EXTERNAL_CONTENT_URI = "content://media/external/images/thumbnails" class Video(Stub): class Media(Stub): EXTERNAL_CONTENT_URI = "content://media/external/video/media" + INTERNAL_CONTENT_URI = "content://media/internal/video/media" + + @staticmethod + def getContentUri(volume_name: str) -> str: + return f"content://media/{volume_name}/video/media" class Audio(Stub): class Media(Stub): EXTERNAL_CONTENT_URI = "content://media/external/audio/media" + INTERNAL_CONTENT_URI = "content://media/internal/audio/media" + + @staticmethod + def getContentUri(volume_name: str) -> str: + return f"content://media/{volume_name}/audio/media" + + @staticmethod + def getMediaUri(volume_name: str) -> str: + """返回指定卷的媒体 URI。""" + return f"content://media/{volume_name}" @staticmethod def getVersion(context: Any, uri: str) -> str: @@ -337,22 +403,94 @@ class DocumentsContract(Stub): class ContactsContract(Stub): """ContactsContract — 联系人合约""" + AUTHORITY = "com.android.contacts" + class Contacts(Stub): CONTENT_URI = "content://com.android.contacts/contacts" CONTENT_FILTER_URI = "content://com.android.contacts/contacts/filter" + CONTENT_TYPE = "vnd.android.cursor.dir/contact" + CONTENT_ITEM_TYPE = "vnd.android.cursor.item/contact" DISPLAY_NAME = "display_name" PHOTO_URI = "photo_uri" + PHOTO_ID = "photo_id" _ID = "_id" + HAS_PHONE_NUMBER = "has_phone_number" + LOOKUP_KEY = "lookup" + STARRED = "starred" class CommonDataKinds(Stub): class Email(Stub): CONTENT_URI = "content://com.android.contacts/data/emails" + CONTENT_ITEM_TYPE = "vnd.android.cursor.item/email_v2" + ADDRESS = "data1" + DISPLAY_NAME = "display_name" + TYPE = "data2" + TYPE_HOME = 1 + TYPE_WORK = 2 + TYPE_OTHER = 3 + TYPE_MOBILE = 4 class Phone(Stub): CONTENT_URI = "content://com.android.contacts/data/phones" + CONTENT_ITEM_TYPE = "vnd.android.cursor.item/phone_v2" + NUMBER = "data1" + DISPLAY_NAME = "display_name" + TYPE = "data2" + TYPE_HOME = 1 + TYPE_MOBILE = 2 + TYPE_WORK = 3 + TYPE_FAX_WORK = 4 + TYPE_FAX_HOME = 5 + TYPE_PAGER = 6 + TYPE_OTHER = 7 + TYPE_CALLBACK = 8 + TYPE_CAR = 9 + TYPE_COMPANY_MAIN = 10 + TYPE_ISDN = 11 + TYPE_MAIN = 12 + TYPE_OTHER_FAX = 13 + TYPE_RADIO = 14 + TYPE_TELEX = 15 + TYPE_TTY_TDD = 16 + TYPE_WORK_MOBILE = 17 + TYPE_WORK_PAGER = 18 + TYPE_ASSISTANT = 19 + + class StructuredName(Stub): + CONTENT_ITEM_TYPE = "vnd.android.cursor.item/name" + DISPLAY_NAME = "data1" + GIVEN_NAME = "data2" + FAMILY_NAME = "data3" + PREFIX = "data4" + MIDDLE_NAME = "data5" + SUFFIX = "data6" + + class Organization(Stub): + CONTENT_ITEM_TYPE = "vnd.android.cursor.item/organization" + COMPANY = "data1" + TYPE = "data2" + TITLE = "data4" + TYPE_WORK = 1 + TYPE_OTHER = 2 class Data(Stub): CONTENT_URI = "content://com.android.contacts/data" + CONTENT_TYPE = "vnd.android.cursor.dir/data" + CONTENT_ITEM_TYPE = "vnd.android.cursor.item/data" + MIMETYPE = "mimetype" + _ID = "_id" + DISPLAY_NAME = "display_name" + RAW_CONTACT_ID = "raw_contact_id" + CONTACT_ID = "contact_id" + + class RawContacts(Stub): + CONTENT_URI = "content://com.android.contacts/raw_contacts" + CONTENT_TYPE = "vnd.android.cursor.dir/raw_contact" + CONTENT_ITEM_TYPE = "vnd.android.cursor.item/raw_contact" + _ID = "_id" + CONTACT_ID = "contact_id" + ACCOUNT_NAME = "account_name" + ACCOUNT_TYPE = "account_type" class ContactsContractContacts(ContactsContract.Contacts): diff --git a/android_pyapi/android/text.py b/android_pyapi/android/text.py index 0b1405f..d4ac87d 100644 --- a/android_pyapi/android/text.py +++ b/android_pyapi/android/text.py @@ -7,6 +7,7 @@ import html as _html import re as _re import sys from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple, Type, Union +from urllib.parse import quote_plus as _quote_plus, unquote_plus as _unquote_plus from .._core import ( Stub, AndroidOnlyError, android_context, @@ -176,7 +177,9 @@ class TextUtils(Stub): @staticmethod def split(text: str, delimiter: str) -> List[str]: - return text.split(delimiter) + if text is None or len(text) == 0: + return [] + return _re.split(delimiter, text) @staticmethod def htmlEncode(s: str) -> str: @@ -186,6 +189,48 @@ class TextUtils(Stub): def htmlDecode(s: str) -> str: return _html.unescape(s) + @staticmethod + def concat(*texts: Any) -> str: + parts = [] + for t in texts: + if t is not None: + parts.append(str(t)) + return "".join(parts) + + @staticmethod + def indexOf(s: Any, needle: Any, start: int = 0) -> int: + if s is None: + return -1 + s = str(s) + needle = str(needle) + if start < 0: + start = 0 + return s.find(needle, start) + + @staticmethod + def lastIndexOf(s: Any, needle: Any, last: Optional[int] = None) -> int: + if s is None: + return -1 + s = str(s) + needle = str(needle) + if last is None: + return s.rfind(needle) + if last < 0: + return -1 + return s.rfind(needle, 0, last + 1) + + @staticmethod + def urlEncode(s: str) -> str: + if s is None: + return "" + return _quote_plus(str(s)) + + @staticmethod + def urlDecode(s: str) -> str: + if s is None: + return "" + return _unquote_plus(str(s)) + @staticmethod def getReverse(s: str, start: int, end: int) -> str: return s[start:end][::-1] @@ -227,8 +272,13 @@ class TextUtils(Stub): return text[:max_chars] + ellipsis @staticmethod - def isDigitsOnly(s: str) -> bool: - return s.isdigit() if s else False + def isDigitsOnly(s: Any) -> bool: + if s is None: + return False + s = str(s) + if len(s) == 0: + return False + return s.isdigit() @staticmethod def isGraphical(s: str) -> bool: @@ -293,12 +343,12 @@ class Spanned(Stub): def __init__(self, source: str = ""): super().__init__() self._source = source - self._spans: Dict[Tuple[type, int], Tuple[int, int, Any]] = {} # (type, id) -> (start, end, span_obj) + self._spans: Dict[Tuple[type, int], Tuple[int, int, int, Any]] = {} # (type, id) -> (start, end, flags, span_obj) def setSpan(self, span_obj: Any, start: int, end: int, flags: int): span_id = id(span_obj) span_type = type(span_obj) - self._spans[(span_type, span_id)] = (start, end, span_obj) + self._spans[(span_type, span_id)] = (start, end, flags, span_obj) def removeSpan(self, span_obj: Any): span_id = id(span_obj) @@ -306,12 +356,13 @@ class Spanned(Stub): self._spans.pop((span_type, span_id), None) def getSpans(self, start: int, end: int, span_type: Optional[type] = None) -> List[Any]: - result = [] - for (st, sid), (s, e, obj) in self._spans.items(): + collected = [] + for (st, sid), (s, e, flags, obj) in self._spans.items(): if span_type is None or st == span_type: if s < end and e > start: - result.append(obj) - return result + collected.append((s, obj)) + collected.sort(key=lambda t: t[0]) + return [obj for _, obj in collected] def getSpanStart(self, span_obj: Any) -> int: span_id = id(span_obj) @@ -329,10 +380,18 @@ class Spanned(Stub): span_id = id(span_obj) span_type = type(span_obj) data = self._spans.get((span_type, span_id)) - return 0 if data is None else 0 + return data[2] if data else 0 def nextSpanTransition(self, start: int, end: int, span_type: Optional[type] = None) -> int: - return end + result = end + for (st, sid), (s, e, flags, obj) in self._spans.items(): + if span_type is not None and st != span_type: + continue + if start < s < result: + result = s + if start < e < result: + result = e + return result def toString(self) -> str: return self._source @@ -435,15 +494,17 @@ class SpannableStringBuilder(Editable): else: super().__init__(str(source)) - def append(self, text: Any) -> "SpannableStringBuilder": - super().append(text) - return self - - def append(self, text: Any, start: int, end: int) -> "SpannableStringBuilder": - if isinstance(text, str): - self._source += text[start:end] + def append(self, text: Any, start: Optional[int] = None, + end: Optional[int] = None) -> "SpannableStringBuilder": + if text is None: + return self + if start is None and end is None: + self._source += str(text) else: - self._source += str(text)[start:end] + s = 0 if start is None else start + text_str = str(text) + e = len(text_str) if end is None else end + self._source += text_str[s:e] return self def insert(self, where: int, text: Any) -> "SpannableStringBuilder": diff --git a/android_pyapi/android/util.py b/android_pyapi/android/util.py index 37b5e32..9f2a5f7 100644 --- a/android_pyapi/android/util.py +++ b/android_pyapi/android/util.py @@ -5,6 +5,7 @@ Android Util 组件 — Log, SparseArray, LruCache, DisplayMetrics 等 import math import re as _re +import struct import sys from datetime import datetime from typing import Any, Callable, Dict, Generic, List, Optional, Set, Tuple, TypeVar, Union @@ -51,19 +52,44 @@ class Log(Stub): return 0 @staticmethod - def w(tag: str, msg: str) -> int: + def w(tag: str, msg: str = None, tr: Exception = None) -> int: if Log._min_log_level <= Log.WARN: + # 支持 w(tag, Throwable) 调用形式 + if msg is None and tr is not None: + msg = Log.getStackTraceString(tr) + elif tr is not None: + msg = f"{msg}\n{Log.getStackTraceString(tr)}" sys.stderr.write(f"W/{tag}: {msg}\n") return 0 @staticmethod - def e(tag: str, msg: str) -> int: + def e(tag: str, msg: str = None, tr: Exception = None) -> int: if Log._min_log_level <= Log.ERROR: + # 支持 e(tag, Throwable) 调用形式 + if msg is None and tr is not None: + msg = Log.getStackTraceString(tr) + elif tr is not None: + msg = f"{msg}\n{Log.getStackTraceString(tr)}" sys.stderr.write(f"E/{tag}: {msg}\n") return 0 @staticmethod - def wtf(tag: str, msg: str) -> int: + def wtf(tag: str, msg=None, tr: Exception = None) -> int: + """What a Terrible Failure。支持以下调用形式: + - wtf(tag, msg) + - wtf(tag, Throwable) + - wtf(tag, msg, Throwable) + """ + # 如果 msg 是异常对象,则当作 tr 处理 + if isinstance(msg, BaseException): + tr = msg + msg = None + if msg is None and tr is not None: + msg = Log.getStackTraceString(tr) + elif tr is not None: + msg = f"{msg}\n{Log.getStackTraceString(tr)}" + if msg is None: + msg = "" sys.stderr.write(f"WTF/{tag}: {msg}\n") return 0 @@ -88,17 +114,16 @@ class Log(Stub): @staticmethod def getStackTraceString(tr: Exception) -> str: import traceback + if tr is None: + return "" + if isinstance(tr, str): + return tr return "".join(traceback.format_exception(type(tr), tr, tr.__traceback__)) @staticmethod def setMinLogLevel(level: int): Log._min_log_level = level - @staticmethod - def wtf(tag: str, tr: Exception) -> int: - sys.stderr.write(f"WTF/{tag}: {Log.getStackTraceString(tr)}\n") - return 0 - # ═══════════════════════════════════════════════════════════════ # SparseArray @@ -518,6 +543,23 @@ class TypedValue(Stub): COMPLEX_UNIT_FRACTION = 6 COMPLEX_UNIT_FRACTION_PARENT = 7 + COMPLEX_UNIT_SHIFT = 0 + COMPLEX_UNIT_MASK = 0xF + + COMPLEX_RADIX_SHIFT = 4 + COMPLEX_RADIX_MASK = 0x3 + + COMPLEX_MANTISSA_SHIFT = 8 + COMPLEX_MANTISSA_MASK = 0x00ffffff + + # 复数值的基数乘数(与 AOSP TypedValue.RADIX_MULTS 一致) + _RADIX_MULTS = ( + 0.00390625, # 1/256, radix 0 + 3.0517578e-5, # 1/32768, radix 1 + 1.1920929e-7, # 1/8388608, radix 2 + 4.6566129e-10, # 1/2147483648, radix 3 + ) + TYPE_NULL = 0x00 TYPE_REFERENCE = 0x01 TYPE_ATTRIBUTE = 0x02 @@ -536,6 +578,9 @@ class TypedValue(Stub): TYPE_LAST_COLOR_INT = 0x1F TYPE_LAST_INT = 0x1F + DATA_NULL_UNDEFINED = 0 + DATA_NULL_EMPTY = 1 + def __init__(self): super().__init__() self.type = self.TYPE_NULL @@ -564,18 +609,27 @@ class TypedValue(Stub): @staticmethod def complexToDimension(data: int, metrics: DisplayMetrics) -> float: - unit = data & 0xF - value = (data >> 4) & 0xFFFFFF - # Sign-extend - if value & 0x800000: - value = value - 0x1000000 - return TypedValue.applyDimension(unit, float(value), metrics) + """将复数维度值转换为像素值。 + 复数值格式:bits 0-3=unit, bits 4-5=radix, bits 8-31=mantissa(有符号24位)""" + unit = (data >> TypedValue.COMPLEX_UNIT_SHIFT) & TypedValue.COMPLEX_UNIT_MASK + radix = (data >> TypedValue.COMPLEX_RADIX_SHIFT) & TypedValue.COMPLEX_RADIX_MASK + mantissa = (data >> TypedValue.COMPLEX_MANTISSA_SHIFT) & TypedValue.COMPLEX_MANTISSA_MASK + # 24 位有符号数符号扩展 + if mantissa & 0x800000: + mantissa = mantissa - 0x1000000 + value = mantissa * TypedValue._RADIX_MULTS[radix] + return TypedValue.applyDimension(unit, value, metrics) @staticmethod def complexToFloat(data: int) -> float: - value = (data & 0xFFFFFF00) >> 8 - exp = (data & 0xFF) - 150 - return float(value) * (10.0 ** exp) + """将复数值转换为浮点数(与 AOSP complexToFloat 一致)。""" + radix = (data >> TypedValue.COMPLEX_RADIX_SHIFT) & TypedValue.COMPLEX_RADIX_MASK + # mantissa 保持在原始位位置(左移 8 位),与 AOSP 一致 + mantissa = data & (TypedValue.COMPLEX_MANTISSA_MASK << TypedValue.COMPLEX_MANTISSA_SHIFT) + # 处理符号(Java int 是有符号的,bit 31 为符号位) + if mantissa & 0x80000000: + mantissa = mantissa - 0x100000000 + return float(mantissa) * TypedValue._RADIX_MULTS[radix] @staticmethod def complexToDimensionPixelOffset(data: int, metrics: DisplayMetrics) -> int: @@ -589,7 +643,9 @@ class TypedValue(Stub): return int(val - 0.5) def getFloat(self) -> float: - return float(self.data) + """将 data 字段按 IEEE 754 单精度浮点数解读(与 Float.intBitsToFloat 一致)。""" + bits = self.data & 0xFFFFFFFF + return struct.unpack('f', struct.pack('I', bits))[0] def toString(self) -> str: return f"TypedValue(type={self.type}, data={self.data})" diff --git a/android_pyapi/java/net.py b/android_pyapi/java/net.py index 9995689..1010666 100644 --- a/android_pyapi/java/net.py +++ b/android_pyapi/java/net.py @@ -24,7 +24,7 @@ __all__ = [ "InetAddress", "InetSocketAddress", "Socket", "ServerSocket", "DatagramSocket", "DatagramPacket", "Proxy", "ProxySelector", "Authenticator", "PasswordAuthentication", - "NetworkInterface", "URI", + "NetworkInterface", "InterfaceAddress", "URI", "CookieHandler", "CookieManager", "HttpCookie", "URLDecoder", "URLEncoder", "SocketException", "ConnectException", "BindException", @@ -1289,26 +1289,79 @@ class PasswordAuthentication(Stub): return self._password +# ============================================================ +# InterfaceAddress +# ============================================================ + +class InterfaceAddress(Stub): + """java.net.InterfaceAddress — 接口地址信息 (地址/广播/前缀长度)""" + + def __init__(self, address=None, broadcast=None, prefix_length=0): + super().__init__() + self._address = address # InetAddress + self._broadcast = broadcast # InetAddress or None + self._prefix_length = prefix_length + + def getAddress(self): + return self._address + + def getBroadcast(self): + return self._broadcast + + def getNetworkPrefixLength(self): + return self._prefix_length + + def equals(self, other): + if isinstance(other, InterfaceAddress): + return (self._address == other._address + and self._prefix_length == other._prefix_length) + return False + + def hashCode(self): + return hash((repr(self._address), self._prefix_length)) & 0x7FFFFFFF + + def toString(self): + addr = self._address.getHostAddress() if self._address else "0.0.0.0" + return f"{addr}/{self._prefix_length}" + + # ============================================================ # NetworkInterface # ============================================================ class NetworkInterface(Stub): _cache = {} + _initialized = False def __init__(self, name, display_name, addrs): super().__init__() self._name = name self._display_name = display_name self._addrs = addrs + self._mac = None # bytes, hardware address + self._mtu = 1500 + self._state = "unknown" + self._flags = 0 + self._interface_addrs = [] # List[InterfaceAddress] + + @staticmethod + def _read_sys_file(path): + try: + with open(path, "r") as f: + return f.read().strip() + except Exception: + return None @staticmethod def getByName(name): - if name in NetworkInterface._cache: - return NetworkInterface._cache[name] + if name is None: + return None + NetworkInterface._ensure_init() + return NetworkInterface._cache.get(name) @staticmethod def getByInetAddress(addr): + NetworkInterface._ensure_init() for ni in NetworkInterface._cache.values(): for a in ni._addrs: if a.equals(addr): @@ -1317,8 +1370,18 @@ class NetworkInterface(Stub): @staticmethod def getNetworkInterfaces(): + NetworkInterface._ensure_init() return list(NetworkInterface._cache.values()) + @staticmethod + def _ensure_init(): + if not NetworkInterface._initialized: + NetworkInterface._initialized = True + try: + NetworkInterface._init_from_socket() + except Exception: + pass + def getName(self): return self._name @@ -1329,25 +1392,25 @@ class NetworkInterface(Stub): return iter(self._addrs) def getInterfaceAddresses(self): - return self._addrs + return list(self._interface_addrs) def isUp(self): - return True + return self._state.lower() == "up" or (self._flags & 0x1) != 0 def isLoopback(self): - return self._name == "lo" + return (self._flags & 0x8) != 0 or self._name == "lo" def isPointToPoint(self): - return False + return (self._flags & 0x10) != 0 def supportsMulticast(self): - return True + return (self._flags & 0x1000) != 0 def getHardwareAddress(self): - return None + return self._mac def getMTU(self): - return 1500 + return self._mtu def getSubInterfaces(self): return iter([]) @@ -1363,25 +1426,139 @@ class NetworkInterface(Stub): @staticmethod def _init_from_socket(): - import netifaces - for iface_name in netifaces.interfaces(): + """从 /sys/class/net/ 和 socket/fcntl 枚举真实网络接口 (不依赖 netifaces)""" + import os as _os + net_dir = "/sys/class/net" + if not _os.path.isdir(net_dir): + # 非 Linux 环境: 尝试用 socket 获取本机地址 + try: + hostname = _socket.gethostname() + addr = InetAddress.getByName(hostname) + ni = NetworkInterface(hostname, hostname, [addr]) + NetworkInterface._cache[hostname] = ni + except Exception: + pass + return + + # 尝试用 fcntl.ioctl 获取 IPv4 地址/掩码/广播 + fcntl_available = True + try: + import fcntl as _fcntl + except ImportError: + fcntl_available = False + + for iface_name in _os.listdir(net_dir): addrs = [] - ifaddresses = netifaces.ifaddresses(iface_name) - for family in [netifaces.AF_INET, netifaces.AF_INET6]: - if family in ifaddresses: - for addr_info in ifaddresses[family]: + interface_addrs = [] + mac = None + mtu = 1500 + state = "unknown" + flags = 0 + + # --- MAC 地址 --- + mac_str = NetworkInterface._read_sys_file(f"{net_dir}/{iface_name}/address") + if mac_str: + try: + mac = bytes(int(b, 16) for b in mac_str.split(":")) + except Exception: + mac = None + + # --- MTU --- + mtu_str = NetworkInterface._read_sys_file(f"{net_dir}/{iface_name}/mtu") + if mtu_str: + try: + mtu = int(mtu_str) + except ValueError: + pass + + # --- operstate --- + state = NetworkInterface._read_sys_file( + f"{net_dir}/{iface_name}/operstate") or "unknown" + + # --- flags (十六进制字符串, 如 0x1003) --- + flags_str = NetworkInterface._read_sys_file( + f"{net_dir}/{iface_name}/flags") + if flags_str: + try: + flags = int(flags_str, 16) + except ValueError: + pass + + # --- IPv4 地址 (fcntl.ioctl) --- + if fcntl_available: + sock = None + try: + sock = _socket.socket(_socket.AF_INET, _socket.SOCK_DGRAM) + packed = _struct.pack('256s', iface_name.encode()[:15]) + # SIOCGIFADDR = 0x8915 + result = _fcntl.ioctl(sock.fileno(), 0x8915, packed) + ip_raw = result[20:24] + inet_addr = InetAddress(iface_name, bytes(ip_raw)) + addrs.append(inet_addr) + + # SIOCGIFNETMASK = 0x891b + prefix = 24 + try: + result = _fcntl.ioctl(sock.fileno(), 0x891b, packed) + netmask_str = _socket.inet_ntoa(result[20:24]) + prefix = sum(bin(int(b)).count('1') for b in netmask_str.split('.')) + except Exception: + pass + + # SIOCGIFBRDADDR = 0x8919 (广播地址) + broadcast_addr = None + try: + result = _fcntl.ioctl(sock.fileno(), 0x8919, packed) + bcast_raw = result[20:24] + broadcast_addr = InetAddress(iface_name, bytes(bcast_raw)) + except Exception: + pass + + interface_addrs.append(InterfaceAddress( + address=inet_addr, + broadcast=broadcast_addr, + prefix_length=prefix + )) + except OSError: + pass # 接口没有 IPv4 地址 + finally: + if sock is not None: try: - addr = addr_info["addr"] - if "." in addr: - addrs.append(InetAddress(addr, _socket.inet_pton(_socket.AF_INET, addr))) - else: - addrs.append(InetAddress(addr, _socket.inet_pton(_socket.AF_INET6, addr))) + sock.close() except Exception: pass - NetworkInterface._cache[iface_name] = NetworkInterface(iface_name, iface_name, addrs) + # --- IPv6 地址 (/proc/net/if_inet6) --- + try: + with open("/proc/net/if_inet6", "r") as f: + for line in f: + parts = line.split() + if len(parts) >= 6 and parts[5] == iface_name: + hex_addr = parts[0] + raw = bytes.fromhex(hex_addr) + prefix6 = int(parts[2], 16) if parts[2] else 64 + inet6_addr = InetAddress(iface_name, raw) + addrs.append(inet6_addr) + interface_addrs.append(InterfaceAddress( + address=inet6_addr, + broadcast=None, + prefix_length=prefix6 + )) + except Exception: + pass + + ni = NetworkInterface(iface_name, iface_name, addrs) + ni._mac = mac + ni._mtu = mtu + ni._state = state + ni._flags = flags + ni._interface_addrs = interface_addrs + NetworkInterface._cache[iface_name] = ni + + +# 初始化缓存 try: - NetworkInterface._init_from_socket() + NetworkInterface._ensure_init() except Exception: pass diff --git a/android_pyapi/org/json.py b/android_pyapi/org/json.py index 140a80c..2838e4e 100644 --- a/android_pyapi/org/json.py +++ b/android_pyapi/org/json.py @@ -115,6 +115,41 @@ class JSONObject(Stub): self._map[k] = arr return self + def append(self, key, value): + """Append value to the JSONArray under key. + + If the key is not present a new JSONArray containing value is created. + If the key maps to a JSONArray the value is appended to it. + Otherwise the existing value and the new value are placed into a new + JSONArray. + """ + k = str(key) + current = self._map.get(k) + if isinstance(current, JSONArray): + current.put(value) + else: + arr = JSONArray() + if current is not None and current is not JSONObject.NULL: + arr.put(current) + arr.put(value) + self._map[k] = arr + return self + + def putOnce(self, key, value): + """Put a key/value pair only if the key is not already present.""" + if key is None: + raise JSONException("Null key.") + k = str(key) + if k not in self._map: + self._map[k] = JSONObject._wrap(value) + return self + + def putOpt(self, key, value): + """Put a key/value pair, but only if key and value are both non-null.""" + if key is not None and value is not None: + return self.put(key, value) + return self + def get(self, key): k = str(key) if k not in self._map: @@ -239,6 +274,14 @@ class JSONObject(Stub): def has(self, key): return str(key) in self._map + def isNull(self, key): + """Returns true if this object has no mapping for key or if it has a + mapping whose value is JSONObject.NULL.""" + k = str(key) + if k not in self._map: + return True + return self._map[k] is JSONObject.NULL + def remove(self, key): k = str(key) if k in self._map: @@ -255,6 +298,15 @@ class JSONObject(Stub): arr.put(k) return arr + @staticmethod + def getNames(jsonObject): + """Returns an array of field names, or None if the object is None/empty.""" + if jsonObject is None: + return None + if not jsonObject._map: + return None + return [str(k) for k in jsonObject._map.keys()] + def length(self): return len(self._map)