| .. | .. |
|---|
| 468 | 468 | // Other files: save to temp and share |
|---|
| 469 | 469 | final ext = _mimeToExt(mime); |
|---|
| 470 | 470 | final dir = await getTemporaryDirectory(); |
|---|
| 471 | | - final fileName = |
|---|
| 472 | | - '${message.content.isNotEmpty ? message.content.replaceAll(RegExp(r'[^\w\s.-]'), '').trim() : 'file'}.$ext'; |
|---|
| 471 | + final fileName = _fileNameFor(message.content, ext); |
|---|
| 473 | 472 | final file = File('${dir.path}/$fileName'); |
|---|
| 474 | 473 | await file.writeAsBytes(bytes); |
|---|
| 475 | 474 | |
|---|
| .. | .. |
|---|
| 483 | 482 | ).showSnackBar(SnackBar(content: Text('Could not open file: $e'))); |
|---|
| 484 | 483 | } |
|---|
| 485 | 484 | } |
|---|
| 485 | + } |
|---|
| 486 | + |
|---|
| 487 | + /// The name to save an incoming file under. |
|---|
| 488 | + /// |
|---|
| 489 | + /// The caption is normally the sender's own filename, so it already carries |
|---|
| 490 | + /// an extension. Appending another unconditionally produced `clip.mp4.mp4` — |
|---|
| 491 | + /// and where the caption had been reduced to a trailing dot, `clip..bin`, |
|---|
| 492 | + /// which no player would open until it was renamed by hand. |
|---|
| 493 | + /// |
|---|
| 494 | + /// So: sanitise, drop trailing dots, and add the extension only when the name |
|---|
| 495 | + /// does not already end in it. |
|---|
| 496 | + String _fileNameFor(String caption, String ext) { |
|---|
| 497 | + var base = caption.replaceAll(RegExp(r'[^\w\s.-]'), '').trim(); |
|---|
| 498 | + base = base.replaceAll(RegExp(r'\.+$'), '').trim(); |
|---|
| 499 | + if (base.isEmpty) base = 'file'; |
|---|
| 500 | + if (base.toLowerCase().endsWith('.${ext.toLowerCase()}')) return base; |
|---|
| 501 | + return '$base.$ext'; |
|---|
| 486 | 502 | } |
|---|
| 487 | 503 | |
|---|
| 488 | 504 | String _mimeToExt(String mime) { |
|---|
| .. | .. |
|---|
| 503 | 519 | 'application/xml': 'xml', |
|---|
| 504 | 520 | 'application/zip': 'zip', |
|---|
| 505 | 521 | 'application/gzip': 'gz', |
|---|
| 522 | + // Video and audio were absent, which is how an mp4 became a `.bin` even |
|---|
| 523 | + // once the sender labelled it correctly. The hub's table is the source |
|---|
| 524 | + // this mirrors. |
|---|
| 525 | + 'video/mp4': 'mp4', |
|---|
| 526 | + 'video/quicktime': 'mov', |
|---|
| 527 | + 'video/x-msvideo': 'avi', |
|---|
| 528 | + 'video/x-matroska': 'mkv', |
|---|
| 529 | + 'audio/mpeg': 'mp3', |
|---|
| 530 | + 'audio/wav': 'wav', |
|---|
| 531 | + 'audio/ogg': 'ogg', |
|---|
| 532 | + 'audio/mp4': 'm4a', |
|---|
| 533 | + 'image/jpeg': 'jpg', |
|---|
| 534 | + 'image/png': 'png', |
|---|
| 535 | + 'image/gif': 'gif', |
|---|
| 536 | + 'image/webp': 'webp', |
|---|
| 537 | + 'image/svg+xml': 'svg', |
|---|
| 506 | 538 | }; |
|---|
| 507 | | - return map[mime] ?? 'bin'; |
|---|
| 539 | + final normalised = mime.toLowerCase().split(';').first.trim(); |
|---|
| 540 | + final known = map[normalised]; |
|---|
| 541 | + if (known != null) return known; |
|---|
| 542 | + // A type this table does not carry but whose subtype names itself: |
|---|
| 543 | + // image/heic -> heic. Better than calling it `bin`. |
|---|
| 544 | + final subtype = normalised.split('/').length > 1 |
|---|
| 545 | + ? normalised.split('/')[1] |
|---|
| 546 | + : ''; |
|---|
| 547 | + if (RegExp(r'^[a-z0-9]{2,5}$').hasMatch(subtype)) return subtype; |
|---|
| 548 | + return 'bin'; |
|---|
| 508 | 549 | } |
|---|
| 509 | 550 | |
|---|
| 510 | 551 | Widget _buildFooter(BuildContext context) { |
|---|