← KlugGallery

Documentation

KlugGallery documentation

com.klug.gallery

NO APM NEEDED. Download and enjoy. It can’t get any simpler. Just update the Adobe AIR SDK. If you need help, we are here.

Save photos and videos from your AIR app to the gallery of the device, on iOS and Android: setup, permission flow, the full API and every event and error message.

iOSAndroid Version 1.0.0 Product page · Buy

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 IDcom.klug.gallery
Current version1.0.0
PlatformsAndroid (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 APITwo classes: com.klug.gallery.Gallery and com.klug.gallery.GalleryEvent
Deliverycom.klug.gallery.ane (the extension), the ActionScript library (SWC) for code completion, and this documentation

Requirements

AIR SDK51.x (HARMAN)
iOSiOS 13 or later (add-only access from iOS 14), real devices
AndroidAndroid 6.0 (API 23) or later
iOS descriptorNSPhotoLibraryAddUsageDescription in InfoAdditions; for iOS 13 also NSPhotoLibraryUsageDescription
Android descriptorKlugGalleryPermissionActivity; WRITE_EXTERNAL_STORAGE with maxSdkVersion="28" for Android 6 to 9

Add the extension

Self-contained: no core library or dependency ANEs. One file, one extensionID.

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:

app.xml
<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:

app.xml
<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:

app.xml
<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.

Main.as
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.
quality0 to 1, default 1. Used for JPEG only; PNG is always lossless. On Android the value is clamped to 0..1.
filenameOptional, 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.

Snapshot.as
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>.
PhotoEvery 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.

Video.as
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.

AUTHORISEDSaving is allowed. iOS 14+: add-only access granted. Always the answer on Android 10 and later, where no permission is needed.
LIMITEDiOS 14+: the user gave limited Photos access. Adding works; treat it like AUTHORISED.
NOT_DETERMINEDThe user has not been asked yet. Call requestAuthorisation().
SHOULD_EXPLAINAndroid 6 to 9 only: the user declined once. Explain why your app saves pictures, then call requestAuthorisation() again; the system prompt appears once more.
DENIEDiOS: 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.
RESTRICTEDiOS: access is blocked by Screen Time or device management. The user cannot change it in your app's settings.
UNKNOWNUnsupported 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.

Permission.as
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.

Settings.as
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

iOSThe 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 laterThrough 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 9The 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:

SavePicture.as
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()").
isSupportedpublic 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).
canOpenDeviceSettingspublic 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_IDpublic static const EXTENSION_ID:String = "com.klug.gallery"
VERSIONpublic static const VERSION:String = "1.0.0"
Version of the ActionScript library.
AUTHORISEDpublic static const AUTHORISED:String = "authorised"
DENIEDpublic static const DENIED:String = "denied"
NOT_DETERMINEDpublic static const NOT_DETERMINED:String = "not_determined"
SHOULD_EXPLAINpublic static const SHOULD_EXPLAIN:String = "should_explain"
Android 6 to 9 only.
RESTRICTEDpublic static const RESTRICTED:String = "restricted"
iOS only.
LIMITEDpublic static const LIMITED:String = "limited"
iOS 14 and later only.
UNKNOWNpublic 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 = "")
statuspublic 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.
AUTHORISATIONpublic static const AUTHORISATION:String = "authorisation"
ADD_COMPLETEpublic static const ADD_COMPLETE:String = "addComplete"
ADD_ERRORpublic static const ADD_ERROR:String = "addError"
ADD_FILE_COMPLETEpublic static const ADD_FILE_COMPLETE:String = "addFileComplete"
ADD_FILE_ERRORpublic 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.AUTHORISATIONAfter requestAuthorisation(). status = the new permission status (one of the Gallery status constants).
GalleryEvent.ADD_COMPLETEThe picture from addBitmapData() was saved. status is empty.
GalleryEvent.ADD_ERRORThe picture could not be saved. status = the error message.
GalleryEvent.ADD_FILE_COMPLETEThe file from addFile() was saved. status is empty.
GalleryEvent.ADD_FILE_ERRORThe 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 foundaddFile(): the path is empty or no file exists there (both platforms).
imageiOS: the pixels could not be turned into an image.
encode / encode failedThe PNG or JPEG encoding failed (iOS / Android), usually for lack of memory.
MediaStore insert failedAndroid 10 and later: the system did not create the gallery entry, e.g. shared storage is unavailable or full.
save failediOS: Photos rejected the item without a description.
any other textThe 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