Introduction
KlugGallery saves pictures and videos from your AIR app to the photo gallery of the device, on Android and iOS:
- Pictures: a BitmapData, such as a snapshot, saved as PNG or JPEG, with quality and file name
- Files: a photo or video file your app has recorded or downloaded, copied into the gallery (mp4, mov and m4v are saved as videos)
- Smallest permission: add-only access on iOS 14 and later, so your app never sees the user's library; no permission at all on Android 10 and later; a runtime storage permission on Android 6 to 9
- Permission status and a shortcut to your app's page in the system settings after a denial
- One event per result, with the error message when something fails
Files land where users look for them: in Photos on iOS, and in Pictures/<app name> or Movies/<app name> on Android. KlugGallery is in daily use in our apps on iOS and Android.
| Extension ID | com.klug.gallery |
| Current version | 1.0.0 |
| Platforms | Android (arm, arm64, x86, x86_64) with MediaStore, iOS (devices) with PhotoKit. On other platforms, including the iOS Simulator and desktop, isSupported is false and every call returns false, UNKNOWN or does nothing. |
| ActionScript API | Two classes: com.klug.gallery.Gallery and com.klug.gallery.GalleryEvent |
| Delivery | com.klug.gallery.ane (the extension), the ActionScript library (SWC) for code completion, and this documentation |
Requirements
| AIR SDK | 51.x (HARMAN) |
| iOS | iOS 13 or later (add-only access from iOS 14), real devices |
| Android | Android 6.0 (API 23) or later |
| iOS descriptor | NSPhotoLibraryAddUsageDescription in InfoAdditions; for iOS 13 also NSPhotoLibraryUsageDescription |
| Android descriptor | KlugGalleryPermissionActivity; WRITE_EXTERNAL_STORAGE with maxSdkVersion="28" for Android 6 to 9 |
Add the extension
1 · Copy the ANE and declare it
Copy com.klug.gallery.ane into your project's extension folder (the folder you pass to ADT with -extdir, or the one your IDE links as native extensions) and add the extension ID to the application descriptor:
<extensions>
<extensionID>com.klug.gallery</extensionID>
</extensions>
2 · iOS InfoAdditions
Explain why your app saves to Photos. iOS shows this text in the permission prompt; replace it with your own wording:
<iPhone>
<InfoAdditions><![CDATA[
<key>NSPhotoLibraryAddUsageDescription</key>
<string>Saves your snapshots and videos to Photos.</string>
]]></InfoAdditions>
</iPhone>
On iOS 14 and later KlugGallery asks only for add-only access, which uses the key above. iOS 13 has no add-only level, so there the extension asks for regular Photos access. If your app still runs on iOS 13, add NSPhotoLibraryUsageDescription with a similar text as well. No entitlements are needed.
3 · Android manifest additions
Android 10 and later save through MediaStore and need no permission. Android 6 to 9 need WRITE_EXTERNAL_STORAGE, which KlugGallery requests from a small invisible activity. Limit the permission with maxSdkVersion="28" so newer versions do not list it:
<android>
<manifestAdditions><![CDATA[
<manifest android:installLocation="auto">
<!-- only used on Android 6 to 9 -->
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="28"/>
<application>
<activity android:name="com.klug.gallery.KlugGalleryPermissionActivity" android:exported="false"
android:theme="@android:style/Theme.Translucent.NoTitleBar"/>
</application>
</manifest>
]]></manifestAdditions>
</android>
If your manifest additions already contain an <application> element, put the activity inside it instead of adding a second one.
Getting started
Get the instance, check support, listen for the result events and save. Every call returns at once; the result arrives as a GalleryEvent on the Gallery instance.
import com.klug.gallery.Gallery; import com.klug.gallery.GalleryEvent; import flash.display.BitmapData; var gallery:Gallery = Gallery.getInstance(); // singleton: never call new Gallery() yourself if ( gallery.isSupported ) // Android and iOS devices { gallery.addEventListener( GalleryEvent.ADD_COMPLETE, function( e:GalleryEvent ):void { trace( "saved" ); }); gallery.addEventListener( GalleryEvent.ADD_ERROR, function( e:GalleryEvent ):void { trace( "could not save: " + e.status ); // the error message }); var picture:BitmapData = new BitmapData( 640, 480, false, 0x1A4378 ); if ( gallery.authorisationStatus() == Gallery.AUTHORISED ) gallery.addBitmapData( picture, "png", 1, "snapshot.png" ); else gallery.requestAuthorisation(); // -> GalleryEvent.AUTHORISATION, event.status }
The permission guide below shows the complete flow, including the states after a denial.
Guides
Save a BitmapData snapshot as PNG or JPEG
addBitmapData(bitmapData, format, quality, filename) encodes the picture and adds it to the gallery.
| format | "png" (default) or "jpg"; "jpeg" is accepted too, upper or lower case. Any other value saves a PNG. |
| quality | 0 to 1, default 1. Used for JPEG only; PNG is always lossless. On Android the value is clamped to 0..1. |
| filename | Optional, e.g. "snapshot.jpg". Android: the file name in the gallery folder; the characters \ / : * ? " < > | are replaced with _, and without a name the file is called image_<milliseconds>.png or .jpg. iOS: stored as the original file name of the photo; without a name Photos chooses one. |
The extension copies the pixels during the call, so you can dispose the BitmapData right after addBitmapData() returns. The file name is not changed to match the format: pass a .jpg name for JPEG and a .png name for PNG. JPEG has no transparency; use PNG for pictures with alpha.
import com.klug.gallery.Gallery; import com.klug.gallery.GalleryEvent; import flash.display.BitmapData; import flash.display.DisplayObject; private var gallery:Gallery = Gallery.getInstance(); public function saveSnapshot( view:DisplayObject ):void { if ( !gallery.isSupported ) return; var picture:BitmapData = new BitmapData( Math.ceil( view.width ), Math.ceil( view.height ), false, 0xFFFFFF ); picture.draw( view ); gallery.addEventListener( GalleryEvent.ADD_COMPLETE, onSaved ); gallery.addEventListener( GalleryEvent.ADD_ERROR, onSaveError ); var fileName:String = "snapshot_" + String( new Date().time ) + ".jpg"; if ( !gallery.addBitmapData( picture, "jpg", 0.85, fileName ) ) { removeSaveListeners(); showMessage( "The picture could not be saved." ); // your app function } picture.dispose(); // safe: the pixels were copied during the call } private function onSaved( e:GalleryEvent ):void { removeSaveListeners(); showToast( "Saved to your gallery" ); // your app function } private function onSaveError( e:GalleryEvent ):void { removeSaveListeners(); showMessage( "Could not save: " + e.status ); } private function removeSaveListeners():void { gallery.removeEventListener( GalleryEvent.ADD_COMPLETE, onSaved ); gallery.removeEventListener( GalleryEvent.ADD_ERROR, onSaveError ); }
Save a video or photo file
addFile(file) copies an existing file into the gallery. Pass a flash.filesystem.File or a native path String. The type is decided by the file extension:
| Video | .mp4, .mov, .m4v (and .3gp), upper or lower case. Saved as a video; on Android in Movies/<app name>. |
| Photo | Every other extension. iOS reads the image file itself (JPEG, PNG, HEIC and other formats Photos can open). Android stores .png, .gif and .webp with their own type and everything else as JPEG, in Pictures/<app name>. |
On Android the gallery file keeps the name of the source file (with \ / : * ? " < > | replaced by _). The source file is copied, not moved; delete it yourself if you no longer need it once ADD_FILE_COMPLETE has arrived.
import com.klug.gallery.Gallery; import com.klug.gallery.GalleryEvent; import flash.filesystem.File; private var gallery:Gallery = Gallery.getInstance(); public function exportClip():void { var clip:File = File.applicationStorageDirectory.resolvePath( "clips/last.mp4" ); if ( !gallery.isSupported || !clip.exists ) return; gallery.addEventListener( GalleryEvent.ADD_FILE_COMPLETE, onFileSaved ); gallery.addEventListener( GalleryEvent.ADD_FILE_ERROR, onFileError ); gallery.addFile( clip ); // or gallery.addFile( clip.nativePath ) } private function onFileSaved( e:GalleryEvent ):void { removeFileListeners(); showToast( "Video saved" ); // your app function } private function onFileError( e:GalleryEvent ):void { removeFileListeners(); trace( "could not save the video: " + e.status ); } private function removeFileListeners():void { gallery.removeEventListener( GalleryEvent.ADD_FILE_COMPLETE, onFileSaved ); gallery.removeEventListener( GalleryEvent.ADD_FILE_ERROR, onFileError ); }
If the file does not exist, addFile() returns false and ADD_FILE_ERROR arrives with "file not found", on both platforms.
Permission flow
authorisationStatus() answers at once; requestAuthorisation() shows the system prompt and reports the answer as GalleryEvent.AUTHORISATION with the new status in event.status.
| AUTHORISED | Saving is allowed. iOS 14+: add-only access granted. Always the answer on Android 10 and later, where no permission is needed. |
| LIMITED | iOS 14+: the user gave limited Photos access. Adding works; treat it like AUTHORISED. |
| NOT_DETERMINED | The user has not been asked yet. Call requestAuthorisation(). |
| SHOULD_EXPLAIN | Android 6 to 9 only: the user declined once. Explain why your app saves pictures, then call requestAuthorisation() again; the system prompt appears once more. |
| DENIED | iOS: the user declined. Android 6 to 9: the user declined with "Don't ask again", or the permission is missing from the manifest. No prompt appears any more; the user can only allow it in the system settings. |
| RESTRICTED | iOS: access is blocked by Screen Time or device management. The user cannot change it in your app's settings. |
| UNKNOWN | Unsupported platform, the status could not be read (Android without a current activity), or a status iOS adds in the future. |
Call requestAuthorisation() only when the user wants to save, not at app start. When there is nothing left to decide, the call shows no prompt and AUTHORISATION arrives right away with the current status: on Android 10 and later always AUTHORISED, after a final denial DENIED. On Android 6 to 9 the event after a first refusal carries SHOULD_EXPLAIN, not DENIED.
import com.klug.gallery.Gallery; import com.klug.gallery.GalleryEvent; import flash.display.BitmapData; private var gallery:Gallery = Gallery.getInstance(); private var pending:BitmapData; public function save( picture:BitmapData ):void { if ( !gallery.isSupported ) return; switch ( gallery.authorisationStatus() ) { case Gallery.AUTHORISED: case Gallery.LIMITED: gallery.addBitmapData( picture, "png" ); break; case Gallery.SHOULD_EXPLAIN: // Android 6 to 9: declined once keepPending( picture ); explainWhy( requestNow ); // your dialog; calls requestNow() on OK break; case Gallery.DENIED: if ( gallery.canOpenDeviceSettings ) askToOpenSettings(); // your dialog; calls gallery.openDeviceSettings() on OK break; case Gallery.RESTRICTED: showMessage( "Saving to the gallery is blocked on this device." ); break; default: // NOT_DETERMINED, UNKNOWN keepPending( picture ); requestNow(); } } private function keepPending( picture:BitmapData ):void { if ( pending != null ) pending.dispose(); pending = picture.clone(); // your copy survives while the prompt is open } public function requestNow():void { gallery.addEventListener( GalleryEvent.AUTHORISATION, onAuthorisation ); if ( !gallery.requestAuthorisation() ) { gallery.removeEventListener( GalleryEvent.AUTHORISATION, onAuthorisation ); dropPending(); } } private function onAuthorisation( e:GalleryEvent ):void { gallery.removeEventListener( GalleryEvent.AUTHORISATION, onAuthorisation ); if ( pending != null && ( e.status == Gallery.AUTHORISED || e.status == Gallery.LIMITED ) ) gallery.addBitmapData( pending, "png" ); dropPending(); } private function dropPending():void { if ( pending != null ) pending.dispose(); pending = null; }
Open the app settings after a denial
openDeviceSettings() opens your app's page in the system settings: on iOS the app's settings with the Photos entry, on Android the app info page with its permissions. Ask the user first, then open the settings. Read the status again when your app comes back, because no event is sent for changes made there.
import flash.desktop.NativeApplication; import flash.events.Event; public function openSettingsNow():void // called from your dialog { NativeApplication.nativeApplication.addEventListener( Event.ACTIVATE, onBackFromSettings ); if ( !gallery.openDeviceSettings() ) NativeApplication.nativeApplication.removeEventListener( Event.ACTIVATE, onBackFromSettings ); } private function onBackFromSettings( e:Event ):void { NativeApplication.nativeApplication.removeEventListener( Event.ACTIVATE, onBackFromSettings ); if ( gallery.authorisationStatus() == Gallery.AUTHORISED ) showToast( "Thanks, you can save to the gallery now." ); }
Where the files end up
| iOS | The Photos library, as a new photo or video (Recents / Library). KlugGallery does not create an album. With add-only access your app can add items but cannot read the library. |
| Android 10 and later | Through MediaStore into shared storage: pictures in Pictures/<app name>, videos in Movies/<app name>. The entry stays hidden until the file is completely written, so gallery apps never show a half-written file; if writing fails, the entry is removed again. |
| Android 6 to 9 | The same folders, written directly to shared storage (needs the storage permission); the media scanner then adds the file to the gallery. A file with the same name in that folder is overwritten. |
| <app name> | Your app's label as shown on the device, without the characters \ / : * ? " < > |. If the label is empty, the folder is called Pictures. |
Cross-platform apps
The extension contains an ActionScript-only fallback for all other platforms, so the same code compiles and runs everywhere. There isSupported and canOpenDeviceSettings are false, authorisationStatus() returns UNKNOWN, every other method returns false and no events are sent. Branch once on isSupported:
public function savePicture( picture:BitmapData ):void { if ( Gallery.getInstance().isSupported ) save( picture ); // the permission flow above else saveWithFileDialog( picture ); // your desktop code, e.g. File.browseForSave() }
API reference
com.klug.gallery.Gallery
Extends flash.events.EventDispatcher. Singleton; all events are dispatched on this instance.
| getInstance() | public static function getInstance():Gallery Returns the single instance and creates it on the first call. |
| Gallery() | public function Gallery() Constructor. Use getInstance() instead; a second instance throws an Error ("Gallery is a singleton, use Gallery.getInstance()"). |
| isSupported | public function get isSupported():Boolean True on Android and iOS devices, false elsewhere. |
| authorisationStatus() | public function authorisationStatus():String The current status: one of the status constants below. Synchronous. UNKNOWN if unsupported or the native side returns nothing. |
| requestAuthorisation() | public function requestAuthorisation():Boolean Shows the system prompt while the user has not decided yet; otherwise reports the current status without a prompt. Returns true if the request was started (AUTHORISATION follows), false if unsupported or, on Android, if there is no current activity. |
| addBitmapData() | public function addBitmapData(bitmapData:BitmapData, format:String = "png", quality:Number = 1, filename:String = ""):Boolean Encodes the picture as PNG or JPEG ("jpg" / "jpeg") with quality 0..1 (JPEG) and saves it under filename. Returns true if saving was started (exactly one ADD_COMPLETE or ADD_ERROR follows). Returns false if unsupported, bitmapData is null, or the picture could not be read or encoded; in the last cases ADD_ERROR may follow with the reason. |
| addFile() | public function addFile(file:Object):Boolean Copies a photo or video file into the gallery; file is a flash.filesystem.File or a native path String. Video for mp4, mov, m4v (and 3gp), photo otherwise. Returns true if saving was started (exactly one ADD_FILE_COMPLETE or ADD_FILE_ERROR follows). Returns false if unsupported, file is null, or the file does not exist (then ADD_FILE_ERROR "file not found" follows). |
| canOpenDeviceSettings | public function get canOpenDeviceSettings():Boolean True where openDeviceSettings() is available; the same value as isSupported. |
| openDeviceSettings() | public function openDeviceSettings():Boolean Opens your app's page in the system settings. Returns true if the settings were requested; false if unsupported or, on Android, if the page could not be opened. |
| EXTENSION_ID | public static const EXTENSION_ID:String = "com.klug.gallery" |
| VERSION | public static const VERSION:String = "1.0.0" Version of the ActionScript library. |
| AUTHORISED | public static const AUTHORISED:String = "authorised" |
| DENIED | public static const DENIED:String = "denied" |
| NOT_DETERMINED | public static const NOT_DETERMINED:String = "not_determined" |
| SHOULD_EXPLAIN | public static const SHOULD_EXPLAIN:String = "should_explain" Android 6 to 9 only. |
| RESTRICTED | public static const RESTRICTED:String = "restricted" iOS only. |
| LIMITED | public static const LIMITED:String = "limited" iOS 14 and later only. |
| UNKNOWN | public static const UNKNOWN:String = "unknown" |
com.klug.gallery.GalleryEvent
Extends flash.events.Event. Does not bubble and cannot be cancelled.
| GalleryEvent() | public function GalleryEvent(type:String, status:String = "") |
| status | public var status:String The new authorisation status for AUTHORISATION, the error message for ADD_ERROR and ADD_FILE_ERROR, empty for the complete events. |
| clone() | override public function clone():Event Returns a copy with the same type and status. |
| AUTHORISATION | public static const AUTHORISATION:String = "authorisation" |
| ADD_COMPLETE | public static const ADD_COMPLETE:String = "addComplete" |
| ADD_ERROR | public static const ADD_ERROR:String = "addError" |
| ADD_FILE_COMPLETE | public static const ADD_FILE_COMPLETE:String = "addFileComplete" |
| ADD_FILE_ERROR | public static const ADD_FILE_ERROR:String = "addFileError" |
Events
All events are GalleryEvent objects dispatched on the Gallery instance. The type names what happened, status carries the details.
| GalleryEvent.AUTHORISATION | After requestAuthorisation(). status = the new permission status (one of the Gallery status constants). |
| GalleryEvent.ADD_COMPLETE | The picture from addBitmapData() was saved. status is empty. |
| GalleryEvent.ADD_ERROR | The picture could not be saved. status = the error message. |
| GalleryEvent.ADD_FILE_COMPLETE | The file from addFile() was saved. status is empty. |
| GalleryEvent.ADD_FILE_ERROR | The file could not be saved. status = the error message. |
The events carry no request ID. If you save several items at once, each call produces its own event, in the order the saves finish. authorisationStatus() returns its answer directly and sends no event.
Troubleshooting
What do the ADD_ERROR and ADD_FILE_ERROR messages mean?
| file not found | addFile(): the path is empty or no file exists there (both platforms). |
| image | iOS: the pixels could not be turned into an image. |
| encode / encode failed | The PNG or JPEG encoding failed (iOS / Android), usually for lack of memory. |
| MediaStore insert failed | Android 10 and later: the system did not create the gallery entry, e.g. shared storage is unavailable or full. |
| save failed | iOS: Photos rejected the item without a description. |
| any other text | The system's own error description, passed on unchanged. iOS: the Photos error, in the device language, e.g. after access was denied or for a file Photos cannot read. Android: the exception message, e.g. ending in (Permission denied) on Android 9 and older without the storage permission, or about missing space. If Android gives no message, the text is null. |
The iOS prompt never shows again after the user declined
iOS asks only once. After a denial, requestAuthorisation() shows nothing and AUTHORISATION arrives at once with DENIED. Explain the situation and offer openDeviceSettings(), as in the permission guide. While testing, deleting and reinstalling the app resets the answer.
The app crashes on iOS 13 when it asks for access
On iOS 13 the extension asks for regular Photos access, which requires NSPhotoLibraryUsageDescription in InfoAdditions. Add it next to NSPhotoLibraryAddUsageDescription. iOS 14 and later only need the add-only key.
Android 9 and older: no prompt, or saving fails with "Permission denied"
Check both manifest entries from the install section. Without KlugGalleryPermissionActivity, requestAuthorisation() cannot show the prompt and reports the unchanged status (usually NOT_DETERMINED). Without WRITE_EXTERNAL_STORAGE in the manifest, Android refuses the request at once and the status becomes DENIED. Saving without the permission ends with an error message ending in (Permission denied). Android 10 and later need neither entry.
SHOULD_EXPLAIN on Android: is that a denial?
Not yet. The user declined once, and Android allows asking again. Show a short explanation, then call requestAuthorisation(). Only after "Don't ask again" does the status become DENIED.
My video was saved as a photo (or failed)
The type is decided by the file extension only, not by the content. Videos must end in .mp4, .mov or .m4v (or .3gp). Rename files without an extension, or with a temporary one such as .tmp, before calling addFile(). On iOS the content must also be a format Photos can play or show; otherwise ADD_FILE_ERROR arrives with the Photos error.
addFile() reports "file not found" for a file that ships with my app
Files inside the application package (File.applicationDirectory) are not ordinary files on Android. Copy the file to File.applicationStorageDirectory or File.cacheDirectory first and pass the copy.
Saving a very large BitmapData freezes the app for a moment or fails
The pixels are copied during the call; on iOS the encoding to PNG or JPEG also runs before addBitmapData() returns, on Android it runs in the background. A large picture therefore needs a moment and several times its size in memory (4 bytes per pixel for the copy, plus the encoded file). Scale the picture down before saving, prefer JPEG for photos, and dispose the BitmapData right after the call. If memory runs out, ADD_ERROR arrives with an encode or system message.
The JPEG has a black background
JPEG cannot store transparency. Use "png", or draw the picture into a BitmapData created with transparent = false and a background colour, as in the snapshot guide.
A new picture replaced an older one on Android
On Android 9 and older a file with the same name in Pictures/<app name> is overwritten. Use a unique file name, for example with a timestamp, or leave filename empty to get image_<milliseconds>.
isSupported is false on iOS
The native part is built for iOS devices. In the iOS Simulator the ActionScript fallback is used. Test on a device.
I cannot find the saved picture on iOS
KlugGallery adds items to the library, not to an album. Look in Recents, or in the library sorted by date added.
Changelog
1.0.0
- First release for Android and iOS: save a BitmapData as PNG or JPEG with quality and file name, copy photo and video files into the gallery
- iOS: PhotoKit with add-only access on iOS 14 and later
- Android: MediaStore without permission on Android 10 and later; runtime storage permission on Android 6 to 9 through KlugGalleryPermissionActivity; own folders Pictures/<app name> and Movies/<app name>
- Permission status, permission request event and a shortcut to the app settings; one event per result with the error message