meccatronis commited on
Commit
a454a67
·
verified ·
1 Parent(s): 02fa65d

Upload core/data_recovery.py with huggingface_hub

Browse files
Files changed (1) hide show
  1. core/data_recovery.py +692 -0
core/data_recovery.py ADDED
@@ -0,0 +1,692 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Data Recovery Module
3
+ ====================
4
+
5
+ Handles the actual recovery of files from Android devices.
6
+ Supports various recovery methods including direct copy, database extraction,
7
+ and file carving for deleted files.
8
+ """
9
+
10
+ import os
11
+ import logging
12
+ import shutil
13
+ import tempfile
14
+ import hashlib
15
+ from typing import List, Dict, Optional, Callable, Any, Tuple
16
+ from dataclasses import dataclass, field
17
+ from enum import Enum
18
+ from datetime import datetime
19
+ from pathlib import Path
20
+ import threading
21
+ from concurrent.futures import ThreadPoolExecutor, as_completed
22
+
23
+ from .adb_manager import ADBManager
24
+ from .device_scanner import ScannedFile, FileType, FileStatus, ScanResult
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+
29
+ class RecoveryStatus(Enum):
30
+ """Status of a recovery operation"""
31
+ PENDING = "pending"
32
+ IN_PROGRESS = "in_progress"
33
+ COMPLETED = "completed"
34
+ FAILED = "failed"
35
+ CANCELLED = "cancelled"
36
+ PARTIAL = "partial"
37
+
38
+
39
+ @dataclass
40
+ class RecoveryTask:
41
+ """Represents a single file recovery task"""
42
+ source_file: ScannedFile
43
+ destination: str
44
+ status: RecoveryStatus = RecoveryStatus.PENDING
45
+ progress: float = 0.0
46
+ error_message: str = ""
47
+ recovered_size: int = 0
48
+ checksum: str = ""
49
+ start_time: Optional[datetime] = None
50
+ end_time: Optional[datetime] = None
51
+
52
+ @property
53
+ def duration(self) -> float:
54
+ """Get recovery duration in seconds"""
55
+ if self.start_time and self.end_time:
56
+ return (self.end_time - self.start_time).total_seconds()
57
+ return 0.0
58
+
59
+
60
+ @dataclass
61
+ class RecoveryResult:
62
+ """Results from a recovery operation"""
63
+ total_files: int = 0
64
+ recovered_files: int = 0
65
+ failed_files: int = 0
66
+ total_size: int = 0
67
+ recovered_size: int = 0
68
+ tasks: List[RecoveryTask] = field(default_factory=list)
69
+ errors: List[str] = field(default_factory=list)
70
+ start_time: Optional[datetime] = None
71
+ end_time: Optional[datetime] = None
72
+ output_directory: str = ""
73
+
74
+ @property
75
+ def success_rate(self) -> float:
76
+ """Calculate recovery success rate"""
77
+ if self.total_files == 0:
78
+ return 0.0
79
+ return (self.recovered_files / self.total_files) * 100
80
+
81
+ @property
82
+ def duration(self) -> float:
83
+ """Get total recovery duration in seconds"""
84
+ if self.start_time and self.end_time:
85
+ return (self.end_time - self.start_time).total_seconds()
86
+ return 0.0
87
+
88
+
89
+ class DataRecovery:
90
+ """
91
+ Handles data recovery from Android devices.
92
+
93
+ Features:
94
+ - Direct file copy for existing files
95
+ - Database extraction and parsing
96
+ - File carving for deleted files
97
+ - Parallel recovery for improved speed
98
+ - Progress tracking and cancellation support
99
+ """
100
+
101
+ def __init__(self, adb_manager: ADBManager, output_dir: str = "./recovered_data"):
102
+ """
103
+ Initialize Data Recovery.
104
+
105
+ Args:
106
+ adb_manager: ADB Manager instance
107
+ output_dir: Directory for recovered files
108
+ """
109
+ self.adb = adb_manager
110
+ self.output_dir = output_dir
111
+ self._cancelled = False
112
+ self._progress_callback: Optional[Callable[[int, int, str, float], None]] = None
113
+ self._current_result: Optional[RecoveryResult] = None
114
+ self._lock = threading.Lock()
115
+ self._max_workers = 4
116
+
117
+ # Create output directory
118
+ os.makedirs(output_dir, exist_ok=True)
119
+
120
+ def set_progress_callback(self, callback: Callable[[int, int, str, float], None]):
121
+ """
122
+ Set callback for progress updates.
123
+
124
+ Args:
125
+ callback: Function(current, total, filename, progress) for updates
126
+ """
127
+ self._progress_callback = callback
128
+
129
+ def _update_progress(self, current: int, total: int, filename: str, progress: float):
130
+ """Update recovery progress"""
131
+ if self._progress_callback:
132
+ self._progress_callback(current, total, filename, progress)
133
+
134
+ def cancel_recovery(self):
135
+ """Cancel the current recovery operation"""
136
+ self._cancelled = True
137
+
138
+ def recover_files(self, files: List[ScannedFile],
139
+ organize_by_type: bool = True) -> RecoveryResult:
140
+ """
141
+ Recover multiple files from the device.
142
+
143
+ Args:
144
+ files: List of files to recover
145
+ organize_by_type: Organize recovered files by type
146
+
147
+ Returns:
148
+ RecoveryResult with recovery details
149
+ """
150
+ self._cancelled = False
151
+ result = RecoveryResult(
152
+ total_files=len(files),
153
+ total_size=sum(f.size for f in files),
154
+ output_directory=self.output_dir,
155
+ start_time=datetime.now()
156
+ )
157
+ self._current_result = result
158
+
159
+ logger.info(f"Starting recovery of {len(files)} files")
160
+
161
+ # Create recovery tasks
162
+ tasks = []
163
+ for file in files:
164
+ # Determine destination path
165
+ if organize_by_type:
166
+ type_dir = self._get_type_directory(file.file_type)
167
+ dest_dir = os.path.join(self.output_dir, type_dir)
168
+ else:
169
+ dest_dir = self.output_dir
170
+
171
+ os.makedirs(dest_dir, exist_ok=True)
172
+ destination = os.path.join(dest_dir, file.name)
173
+
174
+ # Handle duplicate filenames
175
+ destination = self._get_unique_filename(destination)
176
+
177
+ task = RecoveryTask(
178
+ source_file=file,
179
+ destination=destination
180
+ )
181
+ tasks.append(task)
182
+ result.tasks.append(task)
183
+
184
+ # Execute recovery tasks
185
+ for i, task in enumerate(tasks):
186
+ if self._cancelled:
187
+ task.status = RecoveryStatus.CANCELLED
188
+ continue
189
+
190
+ self._update_progress(i + 1, len(tasks), task.source_file.name, 0.0)
191
+
192
+ try:
193
+ success = self._recover_single_file(task)
194
+
195
+ with self._lock:
196
+ if success:
197
+ result.recovered_files += 1
198
+ result.recovered_size += task.recovered_size
199
+ else:
200
+ result.failed_files += 1
201
+ if task.error_message:
202
+ result.errors.append(
203
+ f"{task.source_file.name}: {task.error_message}"
204
+ )
205
+
206
+ except Exception as e:
207
+ task.status = RecoveryStatus.FAILED
208
+ task.error_message = str(e)
209
+ result.failed_files += 1
210
+ result.errors.append(f"{task.source_file.name}: {str(e)}")
211
+ logger.error(f"Recovery failed for {task.source_file.name}: {e}")
212
+
213
+ self._update_progress(i + 1, len(tasks), task.source_file.name, 100.0)
214
+
215
+ result.end_time = datetime.now()
216
+
217
+ logger.info(
218
+ f"Recovery completed: {result.recovered_files}/{result.total_files} files "
219
+ f"({result.success_rate:.1f}% success rate)"
220
+ )
221
+
222
+ return result
223
+
224
+ def recover_files_parallel(self, files: List[ScannedFile],
225
+ organize_by_type: bool = True,
226
+ max_workers: int = 4) -> RecoveryResult:
227
+ """
228
+ Recover multiple files in parallel.
229
+
230
+ Args:
231
+ files: List of files to recover
232
+ organize_by_type: Organize recovered files by type
233
+ max_workers: Maximum parallel workers
234
+
235
+ Returns:
236
+ RecoveryResult with recovery details
237
+ """
238
+ self._cancelled = False
239
+ self._max_workers = max_workers
240
+
241
+ result = RecoveryResult(
242
+ total_files=len(files),
243
+ total_size=sum(f.size for f in files),
244
+ output_directory=self.output_dir,
245
+ start_time=datetime.now()
246
+ )
247
+ self._current_result = result
248
+
249
+ logger.info(f"Starting parallel recovery of {len(files)} files with {max_workers} workers")
250
+
251
+ # Create recovery tasks
252
+ tasks = []
253
+ for file in files:
254
+ if organize_by_type:
255
+ type_dir = self._get_type_directory(file.file_type)
256
+ dest_dir = os.path.join(self.output_dir, type_dir)
257
+ else:
258
+ dest_dir = self.output_dir
259
+
260
+ os.makedirs(dest_dir, exist_ok=True)
261
+ destination = os.path.join(dest_dir, file.name)
262
+ destination = self._get_unique_filename(destination)
263
+
264
+ task = RecoveryTask(
265
+ source_file=file,
266
+ destination=destination
267
+ )
268
+ tasks.append(task)
269
+ result.tasks.append(task)
270
+
271
+ # Execute in parallel
272
+ completed = 0
273
+ with ThreadPoolExecutor(max_workers=max_workers) as executor:
274
+ future_to_task = {
275
+ executor.submit(self._recover_single_file, task): task
276
+ for task in tasks
277
+ }
278
+
279
+ for future in as_completed(future_to_task):
280
+ if self._cancelled:
281
+ executor.shutdown(wait=False)
282
+ break
283
+
284
+ task = future_to_task[future]
285
+ completed += 1
286
+
287
+ try:
288
+ success = future.result()
289
+
290
+ with self._lock:
291
+ if success:
292
+ result.recovered_files += 1
293
+ result.recovered_size += task.recovered_size
294
+ else:
295
+ result.failed_files += 1
296
+ if task.error_message:
297
+ result.errors.append(
298
+ f"{task.source_file.name}: {task.error_message}"
299
+ )
300
+
301
+ except Exception as e:
302
+ task.status = RecoveryStatus.FAILED
303
+ task.error_message = str(e)
304
+ result.failed_files += 1
305
+ result.errors.append(f"{task.source_file.name}: {str(e)}")
306
+
307
+ self._update_progress(
308
+ completed, len(tasks), task.source_file.name, 100.0
309
+ )
310
+
311
+ result.end_time = datetime.now()
312
+
313
+ logger.info(
314
+ f"Parallel recovery completed: {result.recovered_files}/{result.total_files} files "
315
+ f"in {result.duration:.2f}s"
316
+ )
317
+
318
+ return result
319
+
320
+ def _recover_single_file(self, task: RecoveryTask) -> bool:
321
+ """
322
+ Recover a single file.
323
+
324
+ Args:
325
+ task: Recovery task
326
+
327
+ Returns:
328
+ True if recovery successful
329
+ """
330
+ task.status = RecoveryStatus.IN_PROGRESS
331
+ task.start_time = datetime.now()
332
+
333
+ source = task.source_file
334
+
335
+ try:
336
+ if source.status == FileStatus.EXISTING:
337
+ # Direct copy for existing files
338
+ success = self._copy_file(source.path, task.destination)
339
+ elif source.status == FileStatus.DELETED:
340
+ # File carving for deleted files
341
+ success = self._carve_file(source, task.destination)
342
+ else:
343
+ # Try direct copy as fallback
344
+ success = self._copy_file(source.path, task.destination)
345
+
346
+ if success:
347
+ # Verify recovered file
348
+ if os.path.exists(task.destination):
349
+ task.recovered_size = os.path.getsize(task.destination)
350
+ task.checksum = self._calculate_checksum(task.destination)
351
+ task.status = RecoveryStatus.COMPLETED
352
+ task.progress = 100.0
353
+ else:
354
+ task.status = RecoveryStatus.FAILED
355
+ task.error_message = "File not found after recovery"
356
+ success = False
357
+ else:
358
+ task.status = RecoveryStatus.FAILED
359
+ if not task.error_message:
360
+ task.error_message = "Recovery failed"
361
+
362
+ except Exception as e:
363
+ task.status = RecoveryStatus.FAILED
364
+ task.error_message = str(e)
365
+ success = False
366
+
367
+ task.end_time = datetime.now()
368
+ return success
369
+
370
+ def _copy_file(self, source_path: str, destination: str) -> bool:
371
+ """
372
+ Copy a file from device to local storage.
373
+
374
+ Args:
375
+ source_path: Path on device
376
+ destination: Local destination path
377
+
378
+ Returns:
379
+ True if successful
380
+ """
381
+ return self.adb.pull_file(source_path, destination)
382
+
383
+ def _carve_file(self, source: ScannedFile, destination: str) -> bool:
384
+ """
385
+ Attempt to carve a deleted file from device storage.
386
+
387
+ Args:
388
+ source: Source file information
389
+ destination: Local destination path
390
+
391
+ Returns:
392
+ True if successful
393
+ """
394
+ # This is a simplified implementation
395
+ # Real file carving would involve:
396
+ # 1. Reading raw partition data
397
+ # 2. Searching for file signatures
398
+ # 3. Extracting file data based on structure
399
+
400
+ device_info = self.adb.get_device_info()
401
+ if not device_info or not device_info.is_rooted:
402
+ logger.warning("Root access required for file carving")
403
+ return False
404
+
405
+ # Try to recover using file path if still accessible
406
+ if self.adb.file_exists(source.path, as_root=True):
407
+ return self._copy_file_as_root(source.path, destination)
408
+
409
+ return False
410
+
411
+ def _copy_file_as_root(self, source_path: str, destination: str) -> bool:
412
+ """
413
+ Copy a file using root privileges.
414
+
415
+ Args:
416
+ source_path: Path on device
417
+ destination: Local destination path
418
+
419
+ Returns:
420
+ True if successful
421
+ """
422
+ # Create temporary accessible copy
423
+ temp_path = f"/sdcard/.recovery_temp_{os.path.basename(source_path)}"
424
+
425
+ # Copy file to accessible location
426
+ success, _ = self.adb.shell(f"cp '{source_path}' '{temp_path}'", as_root=True)
427
+
428
+ if not success:
429
+ return False
430
+
431
+ # Pull the file
432
+ result = self.adb.pull_file(temp_path, destination)
433
+
434
+ # Clean up temp file
435
+ self.adb.shell(f"rm '{temp_path}'", as_root=True)
436
+
437
+ return result
438
+
439
+ def _get_type_directory(self, file_type: FileType) -> str:
440
+ """Get directory name for file type."""
441
+ type_dirs = {
442
+ FileType.IMAGE: "Images",
443
+ FileType.VIDEO: "Videos",
444
+ FileType.AUDIO: "Audio",
445
+ FileType.DOCUMENT: "Documents",
446
+ FileType.DATABASE: "Databases",
447
+ FileType.CONTACT: "Contacts",
448
+ FileType.MESSAGE: "Messages",
449
+ FileType.CALL_LOG: "CallLogs",
450
+ FileType.APP_DATA: "AppData",
451
+ FileType.WHATSAPP: "WhatsApp",
452
+ FileType.OTHER: "Other",
453
+ }
454
+ return type_dirs.get(file_type, "Other")
455
+
456
+ def _get_unique_filename(self, path: str) -> str:
457
+ """
458
+ Get a unique filename if file already exists.
459
+
460
+ Args:
461
+ path: Original file path
462
+
463
+ Returns:
464
+ Unique file path
465
+ """
466
+ if not os.path.exists(path):
467
+ return path
468
+
469
+ directory = os.path.dirname(path)
470
+ filename = os.path.basename(path)
471
+ name, ext = os.path.splitext(filename)
472
+
473
+ counter = 1
474
+ while True:
475
+ new_path = os.path.join(directory, f"{name}_{counter}{ext}")
476
+ if not os.path.exists(new_path):
477
+ return new_path
478
+ counter += 1
479
+
480
+ def _calculate_checksum(self, filepath: str, algorithm: str = "md5") -> str:
481
+ """
482
+ Calculate file checksum.
483
+
484
+ Args:
485
+ filepath: Path to file
486
+ algorithm: Hash algorithm (md5, sha1, sha256)
487
+
488
+ Returns:
489
+ Hex digest of checksum
490
+ """
491
+ if algorithm == "md5":
492
+ hasher = hashlib.md5()
493
+ elif algorithm == "sha1":
494
+ hasher = hashlib.sha1()
495
+ elif algorithm == "sha256":
496
+ hasher = hashlib.sha256()
497
+ else:
498
+ hasher = hashlib.md5()
499
+
500
+ try:
501
+ with open(filepath, 'rb') as f:
502
+ for chunk in iter(lambda: f.read(8192), b''):
503
+ hasher.update(chunk)
504
+ return hasher.hexdigest()
505
+ except Exception:
506
+ return ""
507
+
508
+ def recover_contacts(self, output_file: str) -> bool:
509
+ """
510
+ Recover contacts from device database.
511
+
512
+ Args:
513
+ output_file: Output file path (CSV or VCF)
514
+
515
+ Returns:
516
+ True if successful
517
+ """
518
+ from .database_recovery import DatabaseRecovery
519
+
520
+ db_recovery = DatabaseRecovery(self.adb)
521
+ contacts = db_recovery.extract_contacts()
522
+
523
+ if not contacts:
524
+ return False
525
+
526
+ # Export based on file extension
527
+ ext = os.path.splitext(output_file)[1].lower()
528
+
529
+ if ext == '.vcf':
530
+ return self._export_contacts_vcf(contacts, output_file)
531
+ else:
532
+ return self._export_contacts_csv(contacts, output_file)
533
+
534
+ def _export_contacts_csv(self, contacts: List[Dict], output_file: str) -> bool:
535
+ """Export contacts to CSV file."""
536
+ import csv
537
+
538
+ try:
539
+ with open(output_file, 'w', newline='', encoding='utf-8') as f:
540
+ if contacts:
541
+ writer = csv.DictWriter(f, fieldnames=contacts[0].keys())
542
+ writer.writeheader()
543
+ writer.writerows(contacts)
544
+ return True
545
+ except Exception as e:
546
+ logger.error(f"Failed to export contacts to CSV: {e}")
547
+ return False
548
+
549
+ def _export_contacts_vcf(self, contacts: List[Dict], output_file: str) -> bool:
550
+ """Export contacts to VCF (vCard) file."""
551
+ try:
552
+ with open(output_file, 'w', encoding='utf-8') as f:
553
+ for contact in contacts:
554
+ f.write("BEGIN:VCARD\n")
555
+ f.write("VERSION:3.0\n")
556
+
557
+ name = contact.get('name', '')
558
+ if name:
559
+ f.write(f"FN:{name}\n")
560
+ f.write(f"N:{name};;;;\n")
561
+
562
+ phone = contact.get('phone', '')
563
+ if phone:
564
+ f.write(f"TEL;TYPE=CELL:{phone}\n")
565
+
566
+ email = contact.get('email', '')
567
+ if email:
568
+ f.write(f"EMAIL:{email}\n")
569
+
570
+ f.write("END:VCARD\n\n")
571
+ return True
572
+ except Exception as e:
573
+ logger.error(f"Failed to export contacts to VCF: {e}")
574
+ return False
575
+
576
+ def recover_messages(self, output_file: str) -> bool:
577
+ """
578
+ Recover SMS/MMS messages from device database.
579
+
580
+ Args:
581
+ output_file: Output file path (CSV or XML)
582
+
583
+ Returns:
584
+ True if successful
585
+ """
586
+ from .database_recovery import DatabaseRecovery
587
+
588
+ db_recovery = DatabaseRecovery(self.adb)
589
+ messages = db_recovery.extract_messages()
590
+
591
+ if not messages:
592
+ return False
593
+
594
+ ext = os.path.splitext(output_file)[1].lower()
595
+
596
+ if ext == '.xml':
597
+ return self._export_messages_xml(messages, output_file)
598
+ else:
599
+ return self._export_messages_csv(messages, output_file)
600
+
601
+ def _export_messages_csv(self, messages: List[Dict], output_file: str) -> bool:
602
+ """Export messages to CSV file."""
603
+ import csv
604
+
605
+ try:
606
+ with open(output_file, 'w', newline='', encoding='utf-8') as f:
607
+ if messages:
608
+ writer = csv.DictWriter(f, fieldnames=messages[0].keys())
609
+ writer.writeheader()
610
+ writer.writerows(messages)
611
+ return True
612
+ except Exception as e:
613
+ logger.error(f"Failed to export messages to CSV: {e}")
614
+ return False
615
+
616
+ def _export_messages_xml(self, messages: List[Dict], output_file: str) -> bool:
617
+ """Export messages to XML file."""
618
+ try:
619
+ with open(output_file, 'w', encoding='utf-8') as f:
620
+ f.write('<?xml version="1.0" encoding="UTF-8"?>\n')
621
+ f.write('<messages>\n')
622
+
623
+ for msg in messages:
624
+ f.write(' <message>\n')
625
+ for key, value in msg.items():
626
+ safe_value = str(value).replace('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;')
627
+ f.write(f' <{key}>{safe_value}</{key}>\n')
628
+ f.write(' </message>\n')
629
+
630
+ f.write('</messages>\n')
631
+ return True
632
+ except Exception as e:
633
+ logger.error(f"Failed to export messages to XML: {e}")
634
+ return False
635
+
636
+ def recover_call_logs(self, output_file: str) -> bool:
637
+ """
638
+ Recover call logs from device database.
639
+
640
+ Args:
641
+ output_file: Output file path
642
+
643
+ Returns:
644
+ True if successful
645
+ """
646
+ from .database_recovery import DatabaseRecovery
647
+
648
+ db_recovery = DatabaseRecovery(self.adb)
649
+ call_logs = db_recovery.extract_call_logs()
650
+
651
+ if not call_logs:
652
+ return False
653
+
654
+ return self._export_messages_csv(call_logs, output_file)
655
+
656
+ def get_recovery_statistics(self) -> Dict[str, Any]:
657
+ """Get statistics from the current/last recovery."""
658
+ if not self._current_result:
659
+ return {}
660
+
661
+ result = self._current_result
662
+
663
+ # Count by status
664
+ status_counts = {}
665
+ for status in RecoveryStatus:
666
+ count = sum(1 for t in result.tasks if t.status == status)
667
+ if count > 0:
668
+ status_counts[status.value] = count
669
+
670
+ # Count by file type
671
+ type_counts = {}
672
+ for task in result.tasks:
673
+ file_type = task.source_file.file_type.value
674
+ if file_type not in type_counts:
675
+ type_counts[file_type] = {'total': 0, 'recovered': 0}
676
+ type_counts[file_type]['total'] += 1
677
+ if task.status == RecoveryStatus.COMPLETED:
678
+ type_counts[file_type]['recovered'] += 1
679
+
680
+ return {
681
+ 'total_files': result.total_files,
682
+ 'recovered_files': result.recovered_files,
683
+ 'failed_files': result.failed_files,
684
+ 'success_rate': result.success_rate,
685
+ 'total_size': result.total_size,
686
+ 'recovered_size': result.recovered_size,
687
+ 'duration': result.duration,
688
+ 'output_directory': result.output_directory,
689
+ 'by_status': status_counts,
690
+ 'by_type': type_counts,
691
+ 'errors': result.errors[:10] # First 10 errors
692
+ }