flutter-dev 0.1.0__py3-none-any.whl

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.
managers/build.py ADDED
@@ -0,0 +1,436 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Build Manager - APK/AAB build functions
4
+ """
5
+
6
+ import time
7
+ import shutil
8
+ import subprocess
9
+ from pathlib import Path
10
+ from datetime import datetime
11
+
12
+ from common_utils import (
13
+ RED, GREEN, YELLOW, BLUE, NC, MAGENTA, CHECKMARK, CROSS,
14
+ timer_decorator,
15
+ is_windows,
16
+ open_directory,
17
+ )
18
+ from core.constants import PATTERNS, PATHS
19
+
20
+
21
+ def show_loading(description, process):
22
+ """
23
+ Displays a loading spinner with a custom message while a process is running
24
+ Parameters:
25
+ description: Description message to display
26
+ process: Process object to monitor
27
+ """
28
+ spinner_index = 0
29
+ braille_spinner_list = '⡿⣟⣯⣷⣾⣽⣻⢿'
30
+ print(description, end='', flush=True)
31
+ # Continue spinning while the process is running
32
+ while process.poll() is None:
33
+ print(f"\b{MAGENTA}{braille_spinner_list[spinner_index]}{NC}", end='', flush=True)
34
+ spinner_index = (spinner_index + 1) % len(braille_spinner_list)
35
+ time.sleep(0.025)
36
+ stdout, stderr = process.communicate()
37
+ # Display success or failure icon based on the process exit status
38
+ if process.returncode == 0:
39
+ print(f"\b{CHECKMARK} ", flush=True)
40
+ return True
41
+ else:
42
+ print(f"\b{CROSS} ", flush=True)
43
+ if stdout:
44
+ print(f"\n{GREEN}Output:\n{stdout}{NC}")
45
+ if stderr:
46
+ print(f"\n{RED}Error Output:\n{stderr}{NC}")
47
+ return False
48
+
49
+
50
+ def run_flutter_command(cmd_list, description, env=None):
51
+ """
52
+ Runs a flutter/dart command with a loading spinner.
53
+ Parameters:
54
+ cmd_list: List of command arguments
55
+ description: Description to show with spinner
56
+ env: Optional environment variables dict (default: inherits current env)
57
+ """
58
+ # Windows compatibility for shell commands
59
+ shell_needed = (is_windows() and cmd_list[0] in ['timeout', 'start', 'flutter', 'dart']) or cmd_list[0] == 'pod'
60
+
61
+ process = subprocess.Popen(
62
+ cmd_list,
63
+ stdout=subprocess.PIPE,
64
+ stderr=subprocess.PIPE,
65
+ shell=shell_needed,
66
+ encoding='utf-8',
67
+ errors='replace',
68
+ env=env,
69
+ )
70
+ return show_loading(description, process)
71
+
72
+
73
+ def get_package_name():
74
+ """
75
+ Dynamically extract package name (applicationId) from Android build files.
76
+ Checks both build.gradle.kts and build.gradle files.
77
+ Returns the package name or None if not found.
78
+ """
79
+ gradle_kts_path = PATHS['gradle_kts']
80
+ gradle_path = PATHS['gradle']
81
+
82
+ # Try build.gradle.kts first
83
+ if gradle_kts_path.exists():
84
+ try:
85
+ with open(gradle_kts_path, 'r', encoding='utf-8') as file:
86
+ content = file.read()
87
+ match = PATTERNS['package_name_kts'].search(content)
88
+ if match:
89
+ return match.group(1)
90
+ except Exception as e:
91
+ print(f"{YELLOW}Warning: Could not read {gradle_kts_path}: {e}{NC}")
92
+
93
+ # Try build.gradle (Groovy format)
94
+ if gradle_path.exists():
95
+ try:
96
+ with open(gradle_path, 'r', encoding='utf-8') as file:
97
+ content = file.read()
98
+ match = PATTERNS['package_name_groovy'].search(content)
99
+ if match:
100
+ return match.group(1)
101
+ except Exception as e:
102
+ print(f"{YELLOW}Warning: Could not read {gradle_path}: {e}{NC}")
103
+
104
+ print(f"{RED}Error: Could not find package name in build.gradle or build.gradle.kts{NC}")
105
+ print(f"{YELLOW}Searched in: android/app/build.gradle and android/app/build.gradle.kts{NC}")
106
+ return None
107
+
108
+
109
+ def get_app_label_from_manifest():
110
+ """
111
+ Extract app label from AndroidManifest.xml
112
+ Returns the app label or None if not found
113
+ """
114
+ manifest_path = PATHS['manifest']
115
+
116
+ if not manifest_path.exists():
117
+ print(f"{YELLOW}Warning: AndroidManifest.xml not found at {manifest_path}{NC}")
118
+ return None
119
+
120
+ try:
121
+ with open(manifest_path, 'r', encoding='utf-8') as file:
122
+ content = file.read()
123
+ match = PATTERNS['app_label'].search(content)
124
+ if match:
125
+ label = match.group(1)
126
+ # If it's a string resource reference, try to get actual value
127
+ if label.startswith('@string/'):
128
+ return None
129
+ # Decode HTML entities like & to &
130
+ label = label.replace('&amp;', '&').replace('&lt;', '<').replace('&gt;', '>')
131
+ return label
132
+ else:
133
+ print(f"{YELLOW}Warning: Could not find android:label in AndroidManifest.xml{NC}")
134
+ return None
135
+ except Exception as e:
136
+ print(f"{YELLOW}Warning: Could not read AndroidManifest.xml: {e}{NC}")
137
+ return None
138
+
139
+
140
+ def sanitize_filename(name):
141
+ """
142
+ Sanitize app name for use in filename (cross-platform compatible)
143
+ Removes/replaces characters not allowed in Windows/macOS/Linux filenames
144
+ """
145
+ # Replace & with 'and'
146
+ name = name.replace('&', 'and')
147
+ # Remove Windows forbidden characters: < > : " / \ | ? *
148
+ name = PATTERNS['sanitize_special'].sub('', name)
149
+ # Remove any remaining special characters except alphanumeric, spaces, hyphens, underscores
150
+ name = PATTERNS['sanitize_non_word'].sub('', name)
151
+ # Replace multiple spaces/hyphens with single underscore
152
+ name = PATTERNS['sanitize_spaces'].sub('_', name)
153
+ # Remove leading/trailing underscores
154
+ name = name.strip('_')
155
+ return name
156
+
157
+
158
+ def get_formatted_date():
159
+ """
160
+ Get current date formatted as 'DD_MMM' (e.g., '07_Jan')
161
+ """
162
+ return datetime.now().strftime('%d_%b')
163
+
164
+
165
+ def rename_build_files(output_dir, file_extension, app_label=None):
166
+ """
167
+ Rename APK/AAB files with app label and date
168
+ Parameters:
169
+ output_dir: Directory containing the build files (Path object)
170
+ file_extension: File extension to search for ('apk' or 'aab')
171
+ app_label: Optional app label (if None, will extract from AndroidManifest.xml)
172
+ """
173
+ if not output_dir.exists():
174
+ print(f"{YELLOW}Warning: Output directory {output_dir} does not exist{NC}")
175
+ return
176
+
177
+ # Get app label from AndroidManifest.xml if not provided
178
+ if app_label is None:
179
+ app_label = get_app_label_from_manifest()
180
+
181
+ if not app_label:
182
+ print(f"{YELLOW}Warning: Could not get app label, files will not be renamed{NC}")
183
+ return
184
+
185
+ # Sanitize app name for filename
186
+ sanitized_name = sanitize_filename(app_label)
187
+
188
+ # Get formatted date
189
+ date_str = get_formatted_date()
190
+
191
+ # Find all files with the specified extension
192
+ build_files = list(output_dir.glob(f"*.{file_extension}"))
193
+
194
+ if not build_files:
195
+ print(f"{YELLOW}Warning: No {file_extension.upper()} files found in {output_dir}{NC}")
196
+ return
197
+
198
+ print(f"\n{BLUE}Renaming {file_extension.upper()} files...{NC}")
199
+
200
+ for file_path in build_files:
201
+ # Check if file contains architecture info (e.g., arm64-v8a)
202
+ arch_match = PATTERNS['architecture'].search(file_path.name)
203
+
204
+ if arch_match:
205
+ # Include architecture in filename
206
+ arch = arch_match.group(1)
207
+ new_name = f"{sanitized_name}_{arch}_{date_str}.{file_extension}"
208
+ else:
209
+ # No architecture info
210
+ new_name = f"{sanitized_name}_{date_str}.{file_extension}"
211
+
212
+ new_path = output_dir / new_name
213
+
214
+ # Rename the file
215
+ try:
216
+ shutil.move(str(file_path), str(new_path))
217
+ print(f"{GREEN} ✓ Renamed: {file_path.name} → {new_name}{NC}")
218
+ except Exception as e:
219
+ print(f"{RED} ✗ Failed to rename {file_path.name}: {e}{NC}")
220
+
221
+
222
+ def display_build_size(file_type, directory):
223
+ """
224
+ Display build file size (works for both APK and AAB)
225
+
226
+ Parameters:
227
+ file_type: "apk" or "aab"
228
+ directory: Path object or string of the directory containing build files
229
+
230
+ Returns:
231
+ None
232
+ """
233
+ # Convert to Path object if string
234
+ build_dir = Path(directory) if isinstance(directory, str) else directory
235
+
236
+ # Get files with the specified extension
237
+ build_files = list(build_dir.glob(f"*.{file_type}")) if build_dir.exists() else []
238
+
239
+ if build_files:
240
+ for build_file in build_files:
241
+ size_bytes = build_file.stat().st_size
242
+ size_mb = round(size_bytes / 1048576, 2)
243
+ # Display with uppercase file type (APK, AAB)
244
+ print(f"{BLUE}{file_type.upper()}: {build_file.name} | Size: {size_mb} MB{NC}")
245
+ else:
246
+ print(f"{RED}{file_type.upper()} file not found in {build_dir}{NC}")
247
+
248
+
249
+ def common_build_process(
250
+ build_name,
251
+ build_command,
252
+ build_description,
253
+ output_dir,
254
+ file_extension,
255
+ install_after=False
256
+ ):
257
+ """
258
+ Common build process for all build types (APK, APK-split, AAB)
259
+
260
+ Parameters:
261
+ build_name: Display name for the build (e.g., "APK", "AAB")
262
+ build_command: List of flutter build command arguments
263
+ build_description: Loading text for the build step
264
+ output_dir: Path object where build output is located
265
+ file_extension: "apk" or "aab"
266
+ install_after: Boolean to install APK after build (default: False)
267
+
268
+ Returns:
269
+ Boolean indicating success
270
+ """
271
+ # Initial message
272
+ print(f"{YELLOW}Building {build_name}...{NC}\n")
273
+
274
+ # Step 1: Clean the project
275
+ run_flutter_command(["flutter", "clean"], "Cleaning project... ")
276
+
277
+ # Step 2: Get dependencies
278
+ run_flutter_command(["flutter", "pub", "get"], "Getting dependencies... ")
279
+
280
+ # Step 3: Generate localizations
281
+ run_flutter_command(["flutter", "gen-l10n"], "Generating localizations... ")
282
+
283
+ # Step 4: Generate build files
284
+ run_flutter_command(["dart", "run", "build_runner", "build", "--delete-conflicting-outputs"], "Generating build files... ")
285
+
286
+ # Step 5: Build (APK/AAB)
287
+ run_flutter_command(build_command, build_description)
288
+
289
+ # Step 6: Rename build files with app label and date
290
+ rename_build_files(output_dir, file_extension)
291
+
292
+ # Step 7: Display file size
293
+ display_build_size(file_extension, output_dir)
294
+
295
+ # Step 8: Install (if requested) or open directory
296
+ if install_after:
297
+ # Import here to avoid circular imports
298
+ from managers.app import install_apk
299
+ install_result = install_apk()
300
+ if install_result:
301
+ print(f"\n{GREEN}✓ {build_name} built and installed successfully!{NC}")
302
+ else:
303
+ print(f"\n{RED}✗ {build_name} built but install failed!{NC}")
304
+ return install_result
305
+ else:
306
+ # Success message
307
+ print(f"\n{GREEN}✓ {build_name} built successfully!{NC}")
308
+ # Open the directory containing the build
309
+ open_directory(str(output_dir))
310
+ return True
311
+
312
+
313
+ @timer_decorator
314
+ def build_apk():
315
+ """Build APK (Full Process)"""
316
+ return common_build_process(
317
+ build_name="APK (Full Process)",
318
+ build_command=[
319
+ "flutter", "build", "apk", "--release", "--obfuscate",
320
+ "--target-platform", "android-arm64", "--split-debug-info=./"
321
+ ],
322
+ build_description="Building APK... ",
323
+ output_dir=PATHS['apk_output'],
324
+ file_extension="apk",
325
+ install_after=False
326
+ )
327
+
328
+
329
+ @timer_decorator
330
+ def build_apk_split_per_abi():
331
+ """Build APK with --split-per-abi"""
332
+ return common_build_process(
333
+ build_name="APK (split-per-abi)",
334
+ build_command=[
335
+ "flutter", "build", "apk", "--release", "--split-per-abi",
336
+ "--obfuscate", "--split-debug-info=./"
337
+ ],
338
+ build_description="Building APK (split-per-abi)... ",
339
+ output_dir=PATHS['apk_output'],
340
+ file_extension="apk",
341
+ install_after=False
342
+ )
343
+
344
+
345
+ @timer_decorator
346
+ def build_aab():
347
+ """Build AAB"""
348
+ return common_build_process(
349
+ build_name="AAB",
350
+ build_command=[
351
+ "flutter", "build", "appbundle", "--release",
352
+ "--obfuscate", "--split-debug-info=./"
353
+ ],
354
+ build_description="Building AAB... ",
355
+ output_dir=PATHS['aab_output'],
356
+ file_extension="aab",
357
+ install_after=False
358
+ )
359
+
360
+
361
+ @timer_decorator
362
+ def build_ipa():
363
+ """Build IPA for iOS App Store"""
364
+ if is_windows():
365
+ print(f"{RED}Error: iOS builds are not supported on Windows. macOS required.{NC}")
366
+ return False
367
+
368
+ print(f"{YELLOW}Building IPA (App Store)...{NC}\n")
369
+
370
+ # Step 1: Clean the project
371
+ run_flutter_command(["flutter", "clean"], "Cleaning project... ")
372
+
373
+ # Step 2: Get dependencies
374
+ run_flutter_command(["flutter", "pub", "get"], "Getting dependencies... ")
375
+
376
+ # Step 3: Generate localizations
377
+ run_flutter_command(["flutter", "gen-l10n"], "Generating localizations... ")
378
+
379
+ # Step 4: Generate build files
380
+ run_flutter_command(["dart", "run", "build_runner", "build", "--delete-conflicting-outputs"], "Generating build files... ")
381
+
382
+ # Step 5: Update iOS pods
383
+ import os
384
+ current_dir = os.getcwd()
385
+ ios_dir = Path("ios")
386
+ if ios_dir.exists():
387
+ # Set UTF-8 encoding to prevent CocoaPods locale issues
388
+ pod_env = os.environ.copy()
389
+ pod_env['LANG'] = 'en_US.UTF-8'
390
+ pod_env['LC_ALL'] = 'en_US.UTF-8'
391
+ os.chdir("ios")
392
+ run_flutter_command(["pod", "deintegrate"], "Deintegrating pods... ", env=pod_env)
393
+ run_flutter_command(["pod", "install"], "Installing pods... ", env=pod_env)
394
+ os.chdir(current_dir)
395
+
396
+ # Step 6: Build IPA
397
+ build_success = run_flutter_command(
398
+ [
399
+ "flutter", "build", "ipa", "--release",
400
+ "--obfuscate", "--split-debug-info=./",
401
+ "--export-method", "app-store"
402
+ ],
403
+ "Building IPA... "
404
+ )
405
+
406
+ if not build_success:
407
+ print(f"\n{RED}✗ IPA build failed!{NC}")
408
+ return False
409
+
410
+ # Step 7: Display IPA file info
411
+ ipa_output = PATHS['ipa_output']
412
+ display_build_size("ipa", ipa_output)
413
+
414
+ # Step 8: Success message and open directory
415
+ print(f"\n{GREEN}✓ IPA built successfully!{NC}")
416
+ print(f"{BLUE}IPA location: {ipa_output}{NC}")
417
+ print(f"\n{YELLOW}Next step: Upload to App Store Connect using Transporter or:{NC}")
418
+ print(f" {GREEN}xcrun altool --upload-app -f <path-to-ipa> -t ios -u <apple-id> -p <app-specific-password>{NC}")
419
+ open_directory(str(ipa_output))
420
+ return True
421
+
422
+
423
+ @timer_decorator
424
+ def release_run():
425
+ """Build & Install Release APK"""
426
+ return common_build_process(
427
+ build_name="Release APK",
428
+ build_command=[
429
+ "flutter", "build", "apk", "--release", "--obfuscate",
430
+ "--target-platform", "android-arm64", "--split-debug-info=./"
431
+ ],
432
+ build_description="Building APK... ",
433
+ output_dir=PATHS['apk_output'],
434
+ file_extension="apk",
435
+ install_after=True
436
+ )
managers/datetime.py ADDED
@@ -0,0 +1,49 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ DateTime Manager - Open device Date & Time settings with auto-time disabled
4
+ """
5
+
6
+ import subprocess
7
+
8
+ from common_utils import RED, GREEN, YELLOW, BLUE, NC
9
+ from core.state import get_selected_device
10
+ from managers.device import ensure_device_connected, build_adb_cmd
11
+
12
+
13
+ def _run_adb(args, timeout=5):
14
+ return subprocess.run(
15
+ build_adb_cmd(args),
16
+ capture_output=True,
17
+ text=True,
18
+ encoding='utf-8',
19
+ errors='replace',
20
+ timeout=timeout,
21
+ )
22
+
23
+
24
+ def open_datetime_settings():
25
+ """Disable auto-time and open Date & Time settings on the device."""
26
+ if not ensure_device_connected(
27
+ "No device connected!",
28
+ "Connect a device via USB or use 'fdev mirror --wireless' first",
29
+ ):
30
+ return False
31
+
32
+ print(f"{GREEN}✓ Device: {get_selected_device()}{NC}")
33
+
34
+ print(f"{BLUE}Disabling automatic date & time...{NC}")
35
+ result = _run_adb(["shell", "settings put global auto_time 0"])
36
+ if result.returncode != 0:
37
+ print(f"{RED}✗ Failed to disable auto-time{NC}")
38
+ print(f"{YELLOW}{result.stderr.strip()}{NC}")
39
+ return False
40
+ print(f"{GREEN}✓ Auto-time disabled{NC}")
41
+
42
+ print(f"{BLUE}Opening Date & Time settings on device...{NC}")
43
+ result = _run_adb(["shell", "am start -a android.settings.DATE_SETTINGS"])
44
+ if result.returncode != 0:
45
+ print(f"{RED}✗ Failed to open settings{NC}")
46
+ return False
47
+
48
+ print(f"{GREEN}✓ Settings opened — set the date/time manually on device{NC}")
49
+ return True
managers/device.py ADDED
@@ -0,0 +1,207 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Device Manager - Device selection and ADB commands
4
+ """
5
+
6
+ import subprocess
7
+
8
+ from common_utils import RED, GREEN, YELLOW, BLUE, NC
9
+ from core.state import get_selected_device, set_selected_device
10
+
11
+
12
+ def get_device_model(serial):
13
+ """
14
+ Get the device model name for a given device serial.
15
+ Returns: Model name string or empty string if failed
16
+ """
17
+ try:
18
+ result = subprocess.run(
19
+ ["adb", "-s", serial, "shell", "getprop", "ro.product.model"],
20
+ capture_output=True,
21
+ text=True,
22
+ encoding='utf-8',
23
+ errors='replace',
24
+ timeout=3
25
+ )
26
+ if result.returncode == 0:
27
+ return result.stdout.strip()
28
+ return ""
29
+ except (FileNotFoundError, subprocess.TimeoutExpired):
30
+ return ""
31
+
32
+
33
+ def get_all_connected_devices():
34
+ """
35
+ Get all connected Android devices/emulators
36
+ Returns: List of device serials, or empty list if none found
37
+ """
38
+ try:
39
+ result = subprocess.run(
40
+ ["adb", "devices"],
41
+ capture_output=True,
42
+ text=True,
43
+ encoding='utf-8',
44
+ errors='replace',
45
+ timeout=5
46
+ )
47
+
48
+ if result.returncode == 0:
49
+ devices = []
50
+ lines = result.stdout.strip().split('\n')
51
+ # Skip the first line ("List of devices attached")
52
+ for line in lines[1:]:
53
+ if line.strip() and '\tdevice' in line:
54
+ # Extract device serial (first part before tab)
55
+ serial = line.split('\t')[0].strip()
56
+ devices.append(serial)
57
+ return devices
58
+ return []
59
+ except (FileNotFoundError, subprocess.TimeoutExpired):
60
+ return []
61
+
62
+
63
+ def select_device_if_multiple():
64
+ """
65
+ Check for connected devices and prompt user to select if multiple found.
66
+ Sets the global SELECTED_DEVICE variable.
67
+ Returns: True if device selected/available, False if no devices
68
+ """
69
+ devices = get_all_connected_devices()
70
+
71
+ if not devices:
72
+ return False
73
+
74
+ if len(devices) == 1:
75
+ # Only one device, auto-select it
76
+ set_selected_device(devices[0])
77
+ return True
78
+
79
+ # Multiple devices found, ask user to select
80
+ print(f"\n{YELLOW}Multiple devices detected:{NC}")
81
+ for i, device in enumerate(devices, 1):
82
+ model = get_device_model(device)
83
+ model_str = f" {model}" if model else ""
84
+ # Check if it's a network device
85
+ if ':' in device:
86
+ print(f" {i}.{model_str} {device} {BLUE}(wireless){NC}")
87
+ else:
88
+ print(f" {i}.{model_str} {device} {GREEN}(USB){NC}")
89
+
90
+ print()
91
+ while True:
92
+ try:
93
+ choice = input(f"Select device (1-{len(devices)}): ").strip()
94
+ index = int(choice) - 1
95
+ if 0 <= index < len(devices):
96
+ set_selected_device(devices[index])
97
+ print(f"{GREEN}✓ Selected: {devices[index]}{NC}\n")
98
+ return True
99
+ else:
100
+ print(f"{RED}Invalid choice. Please enter a number between 1 and {len(devices)}{NC}")
101
+ except ValueError:
102
+ print(f"{RED}Invalid input. Please enter a number{NC}")
103
+ except KeyboardInterrupt:
104
+ print(f"\n{YELLOW}Selection cancelled{NC}")
105
+ return False
106
+
107
+
108
+ def build_adb_cmd(cmd_list, require_device=True):
109
+ """
110
+ Build ADB command with device selection if needed.
111
+ Parameters:
112
+ cmd_list: List of command parts (e.g., ["shell", "pm", "clear", "package"])
113
+ require_device: If True, adds -s flag when SELECTED_DEVICE is set
114
+ Returns: Complete command list ready for subprocess
115
+ """
116
+ selected_device = get_selected_device()
117
+
118
+ # Start with "adb"
119
+ adb_cmd = ["adb"]
120
+
121
+ # Add device selection if needed
122
+ if require_device and selected_device:
123
+ adb_cmd.extend(["-s", selected_device])
124
+
125
+ # Add the rest of the command
126
+ adb_cmd.extend(cmd_list)
127
+
128
+ return adb_cmd
129
+
130
+
131
+ def ensure_device_connected(error_message=None, additional_help=None):
132
+ """
133
+ Wrapper function to check and select Android device
134
+
135
+ Parameters:
136
+ error_message: Custom error message (default: "No Android device connected!")
137
+ additional_help: Additional help text to display after error
138
+
139
+ Returns:
140
+ Boolean - True if device selected/available, False otherwise
141
+ """
142
+ if not select_device_if_multiple():
143
+ # Use custom error message or default
144
+ if error_message:
145
+ print(f"{RED}Error: {error_message}{NC}")
146
+ else:
147
+ print(f"{RED}Error: No Android device connected!{NC}")
148
+
149
+ # Display additional help if provided
150
+ if additional_help:
151
+ print(f"{YELLOW}{additional_help}{NC}")
152
+
153
+ return False
154
+
155
+ return True
156
+
157
+
158
+ def get_usb_devices():
159
+ """
160
+ Get only USB connected devices (excludes wireless/network devices)
161
+ Returns: List of USB device serials
162
+ """
163
+ all_devices = get_all_connected_devices()
164
+ # Filter out wireless devices (they contain ':' in serial like '192.168.0.131:5555')
165
+ usb_devices = [d for d in all_devices if ':' not in d]
166
+ return usb_devices
167
+
168
+
169
+ def select_usb_device():
170
+ """
171
+ Prompt user to select a USB device if multiple are connected.
172
+ Sets the global SELECTED_DEVICE variable.
173
+ Returns: True if device selected/available, False if no devices
174
+ """
175
+ devices = get_usb_devices()
176
+
177
+ if not devices:
178
+ return False
179
+
180
+ if len(devices) == 1:
181
+ # Only one USB device, auto-select it
182
+ set_selected_device(devices[0])
183
+ return True
184
+
185
+ # Multiple USB devices found, ask user to select
186
+ print(f"\n{YELLOW}Multiple USB devices detected:{NC}")
187
+ for i, device in enumerate(devices, 1):
188
+ model = get_device_model(device)
189
+ model_str = f" {model}" if model else ""
190
+ print(f" {i}.{model_str} {device} {GREEN}(USB){NC}")
191
+
192
+ print()
193
+ while True:
194
+ try:
195
+ choice = input(f"Select USB device (1-{len(devices)}): ").strip()
196
+ index = int(choice) - 1
197
+ if 0 <= index < len(devices):
198
+ set_selected_device(devices[index])
199
+ print(f"{GREEN}✓ Selected: {devices[index]}{NC}\n")
200
+ return True
201
+ else:
202
+ print(f"{RED}Invalid choice. Please enter a number between 1 and {len(devices)}{NC}")
203
+ except ValueError:
204
+ print(f"{RED}Invalid input. Please enter a number{NC}")
205
+ except KeyboardInterrupt:
206
+ print(f"\n{YELLOW}Selection cancelled{NC}")
207
+ return False