├── .github ├── AAR Source (Android) │ ├── AndroidManifest.xml │ ├── java │ │ └── com │ │ │ └── yasirkula │ │ │ └── unity │ │ │ ├── NativeGallery.java │ │ │ ├── NativeGalleryMediaPickerFragment.java │ │ │ ├── NativeGalleryMediaPickerResultFragment.java │ │ │ ├── NativeGalleryMediaPickerResultOperation.java │ │ │ ├── NativeGalleryMediaReceiver.java │ │ │ ├── NativeGalleryPermissionFragment.java │ │ │ ├── NativeGalleryPermissionReceiver.java │ │ │ └── NativeGalleryUtils.java │ └── proguard.txt ├── README.md └── screenshots │ ├── 1.png │ └── 2.png ├── LICENSE.txt ├── LICENSE.txt.meta ├── Plugins.meta ├── Plugins ├── NativeGallery.meta └── NativeGallery │ ├── Android.meta │ ├── Android │ ├── NGCallbackHelper.cs │ ├── NGCallbackHelper.cs.meta │ ├── NGMediaReceiveCallbackAndroid.cs │ ├── NGMediaReceiveCallbackAndroid.cs.meta │ ├── NGPermissionCallbackAndroid.cs │ ├── NGPermissionCallbackAndroid.cs.meta │ ├── NativeGallery.aar │ └── NativeGallery.aar.meta │ ├── Editor.meta │ ├── Editor │ ├── NGPostProcessBuild.cs │ ├── NGPostProcessBuild.cs.meta │ ├── NativeGallery.Editor.asmdef │ └── NativeGallery.Editor.asmdef.meta │ ├── NativeGallery.Runtime.asmdef │ ├── NativeGallery.Runtime.asmdef.meta │ ├── NativeGallery.cs │ ├── NativeGallery.cs.meta │ ├── README.txt │ ├── README.txt.meta │ ├── iOS.meta │ └── iOS │ ├── NGMediaReceiveCallbackiOS.cs │ ├── NGMediaReceiveCallbackiOS.cs.meta │ ├── NGMediaSaveCallbackiOS.cs │ ├── NGMediaSaveCallbackiOS.cs.meta │ ├── NGPermissionCallbackiOS.cs │ ├── NGPermissionCallbackiOS.cs.meta │ ├── NativeGallery.mm │ └── NativeGallery.mm.meta ├── package.json └── package.json.meta /.github/AAR Source (Android)/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /.github/AAR Source (Android)/java/com/yasirkula/unity/NativeGallery.java: -------------------------------------------------------------------------------- 1 | package com.yasirkula.unity; 2 | 3 | import android.Manifest; 4 | import android.annotation.TargetApi; 5 | import android.app.Activity; 6 | import android.app.Fragment; 7 | import android.app.RemoteAction; 8 | import android.content.ContentUris; 9 | import android.content.ContentValues; 10 | import android.content.Context; 11 | import android.content.Intent; 12 | import android.content.pm.PackageManager; 13 | import android.database.Cursor; 14 | import android.media.ExifInterface; 15 | import android.net.Uri; 16 | import android.os.Build; 17 | import android.os.Bundle; 18 | import android.os.Environment; 19 | import android.provider.MediaStore; 20 | import android.provider.Settings; 21 | import android.util.Log; 22 | import android.webkit.MimeTypeMap; 23 | 24 | import java.io.File; 25 | import java.io.FileOutputStream; 26 | import java.text.SimpleDateFormat; 27 | import java.util.Date; 28 | import java.util.Locale; 29 | 30 | /** 31 | * Created by yasirkula on 22.06.2017. 32 | */ 33 | 34 | public class NativeGallery 35 | { 36 | public static final int MEDIA_TYPE_IMAGE = 1; 37 | public static final int MEDIA_TYPE_VIDEO = 2; 38 | public static final int MEDIA_TYPE_AUDIO = 4; 39 | 40 | public static boolean overwriteExistingMedia = false; 41 | public static boolean mediaSaveOmitDCIM = false; // If set to true, 'directoryName' on Android 29+ must start with either "DCIM/" or ["Pictures/", "Movies/", "Music/", "Alarms/", "Notifications/", "Audiobooks/", "Podcasts/", "Ringtones/"] 42 | public static boolean PermissionFreeMode = false; // true: Permissions for reading/writing media elements won't be requested 43 | 44 | public static String SaveMedia( Context context, int mediaType, String filePath, String directoryName ) 45 | { 46 | File originalFile = new File( filePath ); 47 | if( !originalFile.exists() ) 48 | { 49 | Log.e( "Unity", "Original media file is missing or inaccessible!" ); 50 | return ""; 51 | } 52 | 53 | int pathSeparator = filePath.lastIndexOf( '/' ); 54 | int extensionSeparator = filePath.lastIndexOf( '.' ); 55 | String filename = pathSeparator >= 0 ? filePath.substring( pathSeparator + 1 ) : filePath; 56 | String extension = extensionSeparator >= 0 ? filePath.substring( extensionSeparator + 1 ) : ""; 57 | 58 | // Credit: https://stackoverflow.com/a/31691791/2373034 59 | String mimeType = extension.length() > 0 ? MimeTypeMap.getSingleton().getMimeTypeFromExtension( extension.toLowerCase( Locale.ENGLISH ) ) : null; 60 | 61 | ContentValues values = new ContentValues(); 62 | values.put( MediaStore.MediaColumns.TITLE, filename ); 63 | values.put( MediaStore.MediaColumns.DISPLAY_NAME, filename ); 64 | values.put( MediaStore.MediaColumns.DATE_ADDED, System.currentTimeMillis() / 1000 ); 65 | 66 | if( mimeType != null && mimeType.length() > 0 ) 67 | values.put( MediaStore.MediaColumns.MIME_TYPE, mimeType ); 68 | 69 | if( mediaType == MEDIA_TYPE_IMAGE ) 70 | { 71 | int imageOrientation = NativeGalleryUtils.GetImageOrientation( context, filePath ); 72 | switch( imageOrientation ) 73 | { 74 | case ExifInterface.ORIENTATION_ROTATE_270: 75 | case ExifInterface.ORIENTATION_TRANSVERSE: 76 | { 77 | values.put( MediaStore.Images.Media.ORIENTATION, 270 ); 78 | break; 79 | } 80 | case ExifInterface.ORIENTATION_ROTATE_180: 81 | { 82 | values.put( MediaStore.Images.Media.ORIENTATION, 180 ); 83 | break; 84 | } 85 | case ExifInterface.ORIENTATION_ROTATE_90: 86 | case ExifInterface.ORIENTATION_TRANSPOSE: 87 | { 88 | values.put( MediaStore.Images.Media.ORIENTATION, 90 ); 89 | break; 90 | } 91 | } 92 | } 93 | 94 | Uri externalContentUri; 95 | if( mediaType == MEDIA_TYPE_IMAGE ) 96 | externalContentUri = MediaStore.Images.Media.EXTERNAL_CONTENT_URI; 97 | else if( mediaType == MEDIA_TYPE_VIDEO ) 98 | externalContentUri = MediaStore.Video.Media.EXTERNAL_CONTENT_URI; 99 | else 100 | externalContentUri = MediaStore.Audio.Media.EXTERNAL_CONTENT_URI; 101 | 102 | // Android 10 restricts our access to the raw filesystem, use MediaStore to save media in that case 103 | if( android.os.Build.VERSION.SDK_INT >= 29 ) 104 | { 105 | values.put( MediaStore.MediaColumns.RELATIVE_PATH, mediaSaveOmitDCIM ? ( directoryName + "/" ) : ( ( ( mediaType != MEDIA_TYPE_AUDIO ) ? "DCIM/" : "Music/" ) + directoryName + "/" ) ); 106 | values.put( MediaStore.MediaColumns.DATE_TAKEN, System.currentTimeMillis() ); 107 | 108 | // While using MediaStore to save media, filename collisions are automatically handled by the OS. 109 | // However, there is a hard limit of 32 collisions: https://android.googlesource.com/platform/frameworks/base/+/oreo-release/core/java/android/os/FileUtils.java#618 110 | // When that limit is reached, an "IllegalStateException: Failed to build unique file" exception is thrown. 111 | // If that happens, we'll have a fallback scenario (i == 1 below). In this scenario, we'll simply add a 112 | // timestamp to the filename 113 | for( int i = 0; i < 2; i++ ) 114 | { 115 | values.put( MediaStore.MediaColumns.IS_PENDING, true ); 116 | 117 | if( i == 1 ) 118 | { 119 | String filenameWithoutExtension = ( extension.length() > 0 && filename.length() > extension.length() ) ? filename.substring( 0, filename.length() - extension.length() - 1 ) : filename; 120 | String newFilename = filenameWithoutExtension + " " + new SimpleDateFormat( "yyyy-MM-dd'T'HH.mm.ss" ).format( new Date() ); // ISO 8601 standard 121 | if( extension.length() > 0 ) 122 | newFilename += "." + extension; 123 | 124 | values.put( MediaStore.MediaColumns.TITLE, newFilename ); 125 | values.put( MediaStore.MediaColumns.DISPLAY_NAME, newFilename ); 126 | } 127 | 128 | Uri uri = null; 129 | if( !overwriteExistingMedia ) 130 | uri = context.getContentResolver().insert( externalContentUri, values ); 131 | else 132 | { 133 | Cursor cursor = null; 134 | try 135 | { 136 | String selection = MediaStore.MediaColumns.RELATIVE_PATH + "=? AND " + MediaStore.MediaColumns.DISPLAY_NAME + "=?"; 137 | String[] selectionArgs = new String[] { values.getAsString( MediaStore.MediaColumns.RELATIVE_PATH ), values.getAsString( MediaStore.MediaColumns.DISPLAY_NAME ) }; 138 | cursor = context.getContentResolver().query( externalContentUri, new String[] { "_id" }, selection, selectionArgs, null ); 139 | if( cursor != null && cursor.moveToFirst() ) 140 | { 141 | uri = ContentUris.withAppendedId( externalContentUri, cursor.getLong( cursor.getColumnIndex( "_id" ) ) ); 142 | Log.d( "Unity", "Overwriting existing media" ); 143 | } 144 | } 145 | catch( Exception e ) 146 | { 147 | Log.e( "Unity", "Couldn't overwrite existing media's metadata:", e ); 148 | } 149 | finally 150 | { 151 | if( cursor != null ) 152 | cursor.close(); 153 | } 154 | 155 | if( uri == null ) 156 | uri = context.getContentResolver().insert( externalContentUri, values ); 157 | } 158 | 159 | if( uri != null ) 160 | { 161 | try 162 | { 163 | if( NativeGalleryUtils.WriteFileToStream( originalFile, context.getContentResolver().openOutputStream( uri, "rwt" ) ) ) 164 | { 165 | values.put( MediaStore.MediaColumns.IS_PENDING, false ); 166 | context.getContentResolver().update( uri, values, null, null ); 167 | 168 | Log.d( "Unity", "Saved media to: " + uri.toString() ); 169 | 170 | try 171 | { 172 | // Refresh the Gallery. This actually shouldn't have been necessary as ACTION_MEDIA_SCANNER_SCAN_FILE 173 | // is deprecated with the message "Callers should migrate to inserting items directly into MediaStore, 174 | // where they will be automatically scanned after each mutation" but apparently, some phones just don't 175 | // want to abide by the rules, ugh... (see: https://github.com/yasirkula/UnityNativeGallery/issues/265) 176 | Intent mediaScanIntent = new Intent( Intent.ACTION_MEDIA_SCANNER_SCAN_FILE ); 177 | mediaScanIntent.setData( uri ); 178 | context.sendBroadcast( mediaScanIntent ); 179 | } 180 | catch( Exception e ) 181 | { 182 | Log.e( "Unity", "Exception:", e ); 183 | } 184 | 185 | String path = NativeGalleryUtils.GetPathFromURI( context, uri ); 186 | return path != null && path.length() > 0 ? path : uri.toString(); 187 | } 188 | } 189 | catch( IllegalStateException e ) 190 | { 191 | if( i == 1 ) 192 | Log.e( "Unity", "Exception:", e ); 193 | 194 | context.getContentResolver().delete( uri, null, null ); 195 | } 196 | catch( Exception e ) 197 | { 198 | Log.e( "Unity", "Exception:", e ); 199 | 200 | // Not strongly-typing RecoverableSecurityException here because Android Studio warns that 201 | // it would result in a crash on Android 18 or earlier 202 | if( overwriteExistingMedia && e.getClass().getName().equals( "android.app.RecoverableSecurityException" ) ) 203 | { 204 | try 205 | { 206 | RemoteAction remoteAction = (RemoteAction) e.getClass().getMethod( "getUserAction" ).invoke( e ); 207 | context.startIntentSender( remoteAction.getActionIntent().getIntentSender(), null, 0, 0, 0 ); 208 | } 209 | catch( Exception e2 ) 210 | { 211 | Log.e( "Unity", "RecoverableSecurityException failure:", e2 ); 212 | return ""; 213 | } 214 | 215 | String path = NativeGalleryUtils.GetPathFromURI( context, uri ); 216 | return path != null && path.length() > 0 ? path : uri.toString(); 217 | } 218 | 219 | context.getContentResolver().delete( uri, null, null ); 220 | return ""; 221 | } 222 | } 223 | 224 | if( overwriteExistingMedia ) 225 | break; 226 | } 227 | } 228 | else 229 | { 230 | File directory = new File( mediaSaveOmitDCIM ? Environment.getExternalStorageDirectory() : Environment.getExternalStoragePublicDirectory( Environment.DIRECTORY_DCIM ), directoryName ); 231 | directory.mkdirs(); 232 | 233 | File file; 234 | int fileIndex = 1; 235 | String filenameWithoutExtension = ( extension.length() > 0 && filename.length() > extension.length() ) ? filename.substring( 0, filename.length() - extension.length() - 1 ) : filename; 236 | String newFilename = filename; 237 | do 238 | { 239 | file = new File( directory, newFilename ); 240 | newFilename = filenameWithoutExtension + fileIndex++; 241 | if( extension.length() > 0 ) 242 | newFilename += "." + extension; 243 | 244 | if( overwriteExistingMedia ) 245 | break; 246 | } while( file.exists() ); 247 | 248 | try 249 | { 250 | if( NativeGalleryUtils.WriteFileToStream( originalFile, new FileOutputStream( file ) ) ) 251 | { 252 | values.put( MediaStore.MediaColumns.DATA, file.getAbsolutePath() ); 253 | 254 | if( !overwriteExistingMedia ) 255 | context.getContentResolver().insert( externalContentUri, values ); 256 | else 257 | { 258 | Uri existingMediaUri = null; 259 | Cursor cursor = null; 260 | try 261 | { 262 | cursor = context.getContentResolver().query( externalContentUri, new String[] { "_id" }, MediaStore.MediaColumns.DATA + "=?", new String[] { values.getAsString( MediaStore.MediaColumns.DATA ) }, null ); 263 | if( cursor != null && cursor.moveToFirst() ) 264 | { 265 | existingMediaUri = ContentUris.withAppendedId( externalContentUri, cursor.getLong( cursor.getColumnIndex( "_id" ) ) ); 266 | Log.d( "Unity", "Overwriting existing media" ); 267 | } 268 | } 269 | catch( Exception e ) 270 | { 271 | Log.e( "Unity", "Couldn't overwrite existing media's metadata:", e ); 272 | } 273 | finally 274 | { 275 | if( cursor != null ) 276 | cursor.close(); 277 | } 278 | 279 | if( existingMediaUri == null ) 280 | context.getContentResolver().insert( externalContentUri, values ); 281 | else 282 | context.getContentResolver().update( existingMediaUri, values, null, null ); 283 | } 284 | 285 | Log.d( "Unity", "Saved media to: " + file.getPath() ); 286 | 287 | // Refresh the Gallery 288 | Intent mediaScanIntent = new Intent( Intent.ACTION_MEDIA_SCANNER_SCAN_FILE ); 289 | mediaScanIntent.setData( Uri.fromFile( file ) ); 290 | context.sendBroadcast( mediaScanIntent ); 291 | 292 | return file.getAbsolutePath(); 293 | } 294 | } 295 | catch( Exception e ) 296 | { 297 | Log.e( "Unity", "Exception:", e ); 298 | } 299 | } 300 | 301 | return ""; 302 | } 303 | 304 | public static void MediaDeleteFile( Context context, String path, int mediaType ) 305 | { 306 | if( mediaType == MEDIA_TYPE_IMAGE ) 307 | context.getContentResolver().delete( MediaStore.Images.Media.EXTERNAL_CONTENT_URI, MediaStore.Images.Media.DATA + "=?", new String[] { path } ); 308 | else if( mediaType == MEDIA_TYPE_VIDEO ) 309 | context.getContentResolver().delete( MediaStore.Video.Media.EXTERNAL_CONTENT_URI, MediaStore.Video.Media.DATA + "=?", new String[] { path } ); 310 | else 311 | context.getContentResolver().delete( MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, MediaStore.Audio.Media.DATA + "=?", new String[] { path } ); 312 | } 313 | 314 | public static void PickMedia( Context context, final NativeGalleryMediaReceiver mediaReceiver, int mediaType, boolean selectMultiple, String savePath, String mime, String title ) 315 | { 316 | if( CheckPermission( context, true, mediaType ) != 1 ) 317 | { 318 | if( !selectMultiple ) 319 | mediaReceiver.OnMediaReceived( "" ); 320 | else 321 | mediaReceiver.OnMultipleMediaReceived( "" ); 322 | 323 | return; 324 | } 325 | 326 | Bundle bundle = new Bundle(); 327 | bundle.putInt( NativeGalleryMediaPickerFragment.MEDIA_TYPE_ID, mediaType ); 328 | bundle.putBoolean( NativeGalleryMediaPickerFragment.SELECT_MULTIPLE_ID, selectMultiple ); 329 | bundle.putString( NativeGalleryMediaPickerFragment.SAVE_PATH_ID, savePath ); 330 | bundle.putString( NativeGalleryMediaPickerFragment.MIME_ID, mime ); 331 | bundle.putString( NativeGalleryMediaPickerFragment.TITLE_ID, title ); 332 | 333 | final Fragment request = new NativeGalleryMediaPickerFragment( mediaReceiver ); 334 | request.setArguments( bundle ); 335 | 336 | ( (Activity) context ).getFragmentManager().beginTransaction().add( 0, request ).commitAllowingStateLoss(); 337 | } 338 | 339 | @TargetApi( Build.VERSION_CODES.M ) 340 | public static int CheckPermission( Context context, final boolean readPermission, final int mediaType ) 341 | { 342 | if( Build.VERSION.SDK_INT < Build.VERSION_CODES.M ) 343 | return 1; 344 | 345 | if( PermissionFreeMode ) 346 | return 1; 347 | 348 | if( !readPermission ) 349 | { 350 | if( android.os.Build.VERSION.SDK_INT >= 29 ) // On Android 10 and later, saving to Gallery doesn't require any permissions 351 | return 1; 352 | else if( context.checkSelfPermission( Manifest.permission.WRITE_EXTERNAL_STORAGE ) != PackageManager.PERMISSION_GRANTED ) 353 | return 0; 354 | } 355 | 356 | if( Build.VERSION.SDK_INT < 33 || context.getApplicationInfo().targetSdkVersion < 33 ) 357 | { 358 | if( context.checkSelfPermission( Manifest.permission.READ_EXTERNAL_STORAGE ) != PackageManager.PERMISSION_GRANTED ) 359 | return 0; 360 | } 361 | 362 | return 1; 363 | } 364 | 365 | // Credit: https://github.com/Over17/UnityAndroidPermissions/blob/0dca33e40628f1f279decb67d901fd444b409cd7/src/UnityAndroidPermissions/src/main/java/com/unity3d/plugin/UnityAndroidPermissions.java 366 | public static void RequestPermission( Context context, final NativeGalleryPermissionReceiver permissionReceiver, final boolean readPermission, final int mediaType ) 367 | { 368 | if( CheckPermission( context, readPermission, mediaType ) == 1 ) 369 | { 370 | permissionReceiver.OnPermissionResult( 1 ); 371 | return; 372 | } 373 | 374 | Bundle bundle = new Bundle(); 375 | bundle.putBoolean( NativeGalleryPermissionFragment.READ_PERMISSION_ONLY, readPermission ); 376 | bundle.putInt( NativeGalleryPermissionFragment.MEDIA_TYPE_ID, mediaType ); 377 | 378 | final Fragment request = new NativeGalleryPermissionFragment( permissionReceiver ); 379 | request.setArguments( bundle ); 380 | 381 | ( (Activity) context ).getFragmentManager().beginTransaction().add( 0, request ).commitAllowingStateLoss(); 382 | } 383 | 384 | // Credit: https://stackoverflow.com/a/35456817/2373034 385 | public static void OpenSettings( Context context ) 386 | { 387 | Uri uri = Uri.fromParts( "package", context.getPackageName(), null ); 388 | 389 | Intent intent = new Intent(); 390 | intent.setAction( Settings.ACTION_APPLICATION_DETAILS_SETTINGS ); 391 | intent.setData( uri ); 392 | 393 | context.startActivity( intent ); 394 | } 395 | 396 | public static boolean CanSelectMultipleMedia() 397 | { 398 | return Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR2; 399 | } 400 | 401 | public static boolean CanSelectMultipleMediaTypes() 402 | { 403 | return Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT; 404 | } 405 | 406 | public static String GetMimeTypeFromExtension( String extension ) 407 | { 408 | if( extension == null || extension.length() == 0 ) 409 | return ""; 410 | 411 | String mime = MimeTypeMap.getSingleton().getMimeTypeFromExtension( extension.toLowerCase( Locale.ENGLISH ) ); 412 | return mime != null ? mime : ""; 413 | } 414 | 415 | public static String LoadImageAtPath( Context context, String path, final String temporaryFilePath, final int maxSize ) 416 | { 417 | return NativeGalleryUtils.LoadImageAtPath( context, path, temporaryFilePath, maxSize ); 418 | } 419 | 420 | public static String GetImageProperties( Context context, final String path ) 421 | { 422 | return NativeGalleryUtils.GetImageProperties( context, path ); 423 | } 424 | 425 | @TargetApi( Build.VERSION_CODES.JELLY_BEAN_MR1 ) 426 | public static String GetVideoProperties( Context context, final String path ) 427 | { 428 | return NativeGalleryUtils.GetVideoProperties( context, path ); 429 | } 430 | 431 | @TargetApi( Build.VERSION_CODES.Q ) 432 | public static String GetVideoThumbnail( Context context, final String path, final String savePath, final boolean saveAsJpeg, int maxSize, double captureTime ) 433 | { 434 | return NativeGalleryUtils.GetVideoThumbnail( context, path, savePath, saveAsJpeg, maxSize, captureTime ); 435 | } 436 | } -------------------------------------------------------------------------------- /.github/AAR Source (Android)/java/com/yasirkula/unity/NativeGalleryMediaPickerFragment.java: -------------------------------------------------------------------------------- 1 | package com.yasirkula.unity; 2 | 3 | import android.app.Activity; 4 | import android.app.Fragment; 5 | import android.content.ActivityNotFoundException; 6 | import android.content.Context; 7 | import android.content.Intent; 8 | import android.content.pm.PackageManager; 9 | import android.os.Build; 10 | import android.os.Bundle; 11 | import android.provider.MediaStore; 12 | import android.util.Log; 13 | import android.widget.Toast; 14 | 15 | /** 16 | * Created by yasirkula on 23.02.2018. 17 | */ 18 | 19 | public class NativeGalleryMediaPickerFragment extends Fragment 20 | { 21 | private static final int MEDIA_REQUEST_CODE = 987455; 22 | 23 | public static final String MEDIA_TYPE_ID = "NGMP_MEDIA_TYPE"; 24 | public static final String SELECT_MULTIPLE_ID = "NGMP_MULTIPLE"; 25 | public static final String SAVE_PATH_ID = "NGMP_SAVE_PATH"; 26 | public static final String MIME_ID = "NGMP_MIME"; 27 | public static final String TITLE_ID = "NGMP_TITLE"; 28 | 29 | public static boolean preferGetContent = false; 30 | public static boolean tryPreserveFilenames = false; // When enabled, app's cache will fill more quickly since most of the images will have a unique filename (less chance of overwriting old files) 31 | public static boolean showProgressbar = true; // When enabled, a progressbar will be displayed while selected file(s) are copied (if necessary) to the destination directory 32 | public static boolean useDefaultGalleryApp = false; // false: Intent.createChooser is used to pick the Gallery app 33 | public static boolean GrantPersistableUriPermission = false; // When enabled, on newest Android versions, picked file can still be accessed after the app is restarted. Note that there's a 512-file hard limit: https://issuetracker.google.com/issues/149315521#comment7 34 | 35 | private final NativeGalleryMediaReceiver mediaReceiver; 36 | private boolean selectMultiple; 37 | private String savePathDirectory, savePathFilename; 38 | 39 | public NativeGalleryMediaPickerFragment() 40 | { 41 | mediaReceiver = null; 42 | } 43 | 44 | public NativeGalleryMediaPickerFragment( final NativeGalleryMediaReceiver mediaReceiver ) 45 | { 46 | this.mediaReceiver = mediaReceiver; 47 | } 48 | 49 | @Override 50 | public void onCreate( Bundle savedInstanceState ) 51 | { 52 | super.onCreate( savedInstanceState ); 53 | if( mediaReceiver == null ) 54 | { 55 | Log.e( "Unity", "NativeGalleryMediaPickerFragment.mediaReceiver became null in onCreate!" ); 56 | onActivityResult( MEDIA_REQUEST_CODE, Activity.RESULT_CANCELED, null ); 57 | } 58 | else 59 | { 60 | int mediaType = getArguments().getInt( MEDIA_TYPE_ID ); 61 | String mime = getArguments().getString( MIME_ID ); 62 | String title = getArguments().getString( TITLE_ID ); 63 | selectMultiple = getArguments().getBoolean( SELECT_MULTIPLE_ID ); 64 | String savePath = getArguments().getString( SAVE_PATH_ID ); 65 | 66 | int pathSeparator = savePath.lastIndexOf( '/' ); 67 | savePathFilename = pathSeparator >= 0 ? savePath.substring( pathSeparator + 1 ) : savePath; 68 | savePathDirectory = pathSeparator > 0 ? savePath.substring( 0, pathSeparator ) : getActivity().getCacheDir().getAbsolutePath(); 69 | 70 | int mediaTypeCount = 0; 71 | if( ( mediaType & NativeGallery.MEDIA_TYPE_IMAGE ) == NativeGallery.MEDIA_TYPE_IMAGE ) 72 | mediaTypeCount++; 73 | if( ( mediaType & NativeGallery.MEDIA_TYPE_VIDEO ) == NativeGallery.MEDIA_TYPE_VIDEO ) 74 | mediaTypeCount++; 75 | if( ( mediaType & NativeGallery.MEDIA_TYPE_AUDIO ) == NativeGallery.MEDIA_TYPE_AUDIO ) 76 | mediaTypeCount++; 77 | 78 | Intent intent = null; 79 | if( !preferGetContent && !selectMultiple && mediaTypeCount == 1 && mediaType != NativeGallery.MEDIA_TYPE_AUDIO ) 80 | { 81 | intent = new Intent( Intent.ACTION_PICK ); 82 | 83 | if( mediaType == NativeGallery.MEDIA_TYPE_IMAGE ) 84 | intent.setDataAndType( MediaStore.Images.Media.EXTERNAL_CONTENT_URI, mime ); 85 | else if( mediaType == NativeGallery.MEDIA_TYPE_VIDEO ) 86 | intent.setDataAndType( MediaStore.Video.Media.EXTERNAL_CONTENT_URI, mime ); 87 | else 88 | intent.setDataAndType( MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, mime ); 89 | } 90 | 91 | if( intent == null ) 92 | { 93 | intent = new Intent( mediaTypeCount > 1 && Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT ? Intent.ACTION_OPEN_DOCUMENT : Intent.ACTION_GET_CONTENT ); 94 | intent.addCategory( Intent.CATEGORY_OPENABLE ); 95 | intent.addFlags( Intent.FLAG_GRANT_READ_URI_PERMISSION ); 96 | 97 | if( selectMultiple && Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR2 ) 98 | intent.putExtra( Intent.EXTRA_ALLOW_MULTIPLE, true ); 99 | 100 | if( mediaTypeCount > 1 ) 101 | { 102 | mime = "*/*"; 103 | 104 | if( Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT ) 105 | { 106 | String[] mimetypes = new String[mediaTypeCount]; 107 | int index = 0; 108 | if( ( mediaType & NativeGallery.MEDIA_TYPE_IMAGE ) == NativeGallery.MEDIA_TYPE_IMAGE ) 109 | mimetypes[index++] = "image/*"; 110 | if( ( mediaType & NativeGallery.MEDIA_TYPE_VIDEO ) == NativeGallery.MEDIA_TYPE_VIDEO ) 111 | mimetypes[index++] = "video/*"; 112 | if( ( mediaType & NativeGallery.MEDIA_TYPE_AUDIO ) == NativeGallery.MEDIA_TYPE_AUDIO ) 113 | mimetypes[index++] = "audio/*"; 114 | 115 | intent.putExtra( Intent.EXTRA_MIME_TYPES, mimetypes ); 116 | } 117 | } 118 | 119 | intent.setType( mime ); 120 | } 121 | 122 | if( ShouldGrantPersistableUriPermission( getActivity() ) ) 123 | intent.addFlags( Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION ); 124 | 125 | if( title != null && title.length() > 0 ) 126 | intent.putExtra( Intent.EXTRA_TITLE, title ); 127 | 128 | try 129 | { 130 | // MIUI devices have issues with Intent.createChooser on at least Android 11 (https://stackoverflow.com/questions/67785661/taking-and-picking-photos-on-poco-x3-with-android-11-does-not-work) 131 | if( useDefaultGalleryApp || ( Build.VERSION.SDK_INT == 30 && NativeGalleryUtils.IsXiaomiOrMIUI() ) ) 132 | startActivityForResult( intent, MEDIA_REQUEST_CODE ); 133 | else 134 | startActivityForResult( Intent.createChooser( intent, title ), MEDIA_REQUEST_CODE ); 135 | } 136 | catch( ActivityNotFoundException e ) 137 | { 138 | Toast.makeText( getActivity(), "No apps can perform this action.", Toast.LENGTH_LONG ).show(); 139 | onActivityResult( MEDIA_REQUEST_CODE, Activity.RESULT_CANCELED, null ); 140 | } 141 | } 142 | } 143 | 144 | public static boolean ShouldGrantPersistableUriPermission( Context context ) 145 | { 146 | return GrantPersistableUriPermission && Build.VERSION.SDK_INT >= 33 && context.getApplicationInfo().targetSdkVersion >= 33; 147 | } 148 | 149 | @Override 150 | public void onActivityResult( int requestCode, int resultCode, Intent data ) 151 | { 152 | if( requestCode != MEDIA_REQUEST_CODE ) 153 | return; 154 | 155 | NativeGalleryMediaPickerResultFragment resultFragment = null; 156 | 157 | if( mediaReceiver == null ) 158 | Log.d( "Unity", "NativeGalleryMediaPickerFragment.mediaReceiver became null in onActivityResult!" ); 159 | else if( resultCode != Activity.RESULT_OK || data == null ) 160 | { 161 | if( !selectMultiple ) 162 | mediaReceiver.OnMediaReceived( "" ); 163 | else 164 | mediaReceiver.OnMultipleMediaReceived( "" ); 165 | } 166 | else 167 | { 168 | NativeGalleryMediaPickerResultOperation resultOperation = new NativeGalleryMediaPickerResultOperation( getActivity(), mediaReceiver, data, selectMultiple, savePathDirectory, savePathFilename ); 169 | if( showProgressbar ) 170 | resultFragment = new NativeGalleryMediaPickerResultFragment( resultOperation ); 171 | else 172 | { 173 | resultOperation.execute(); 174 | resultOperation.sendResultToUnity(); 175 | } 176 | } 177 | 178 | if( resultFragment == null ) 179 | getFragmentManager().beginTransaction().remove( this ).commitAllowingStateLoss(); 180 | else 181 | getFragmentManager().beginTransaction().remove( this ).add( 0, resultFragment ).commitAllowingStateLoss(); 182 | } 183 | } -------------------------------------------------------------------------------- /.github/AAR Source (Android)/java/com/yasirkula/unity/NativeGalleryMediaPickerResultFragment.java: -------------------------------------------------------------------------------- 1 | package com.yasirkula.unity; 2 | 3 | import android.app.AlertDialog; 4 | import android.app.Dialog; 5 | import android.app.DialogFragment; 6 | import android.content.DialogInterface; 7 | import android.graphics.Color; 8 | import android.os.Bundle; 9 | import android.os.Handler; 10 | import android.os.Looper; 11 | import android.view.Gravity; 12 | import android.widget.LinearLayout; 13 | import android.widget.ProgressBar; 14 | import android.widget.TextView; 15 | 16 | // Handler usage reference: https://stackoverflow.com/a/6242292/2373034 17 | public class NativeGalleryMediaPickerResultFragment extends DialogFragment 18 | { 19 | public static int uiUpdateInterval = 100; 20 | public static String progressBarLabel = "Please wait..."; 21 | 22 | private final NativeGalleryMediaPickerResultOperation resultOperation; 23 | 24 | private ProgressBar progressBar; 25 | 26 | private final Handler uiUpdateHandler = new Handler( Looper.getMainLooper() ); 27 | private final Runnable progressBarUpdateTask = new Runnable() 28 | { 29 | @Override 30 | public void run() 31 | { 32 | if( resultOperation.finished ) 33 | { 34 | resultOperation.sendResultToUnity(); 35 | dismissAllowingStateLoss(); 36 | } 37 | else 38 | { 39 | try 40 | { 41 | if( progressBar != null ) 42 | { 43 | if( resultOperation.progress >= 0 ) 44 | { 45 | if( progressBar.isIndeterminate() ) 46 | progressBar.setIndeterminate( false ); 47 | 48 | progressBar.setProgress( resultOperation.progress ); 49 | } 50 | else if( !progressBar.isIndeterminate() ) 51 | progressBar.setIndeterminate( true ); 52 | } 53 | } 54 | finally 55 | { 56 | uiUpdateHandler.postDelayed( progressBarUpdateTask, uiUpdateInterval ); 57 | } 58 | } 59 | } 60 | }; 61 | 62 | public NativeGalleryMediaPickerResultFragment() 63 | { 64 | resultOperation = null; 65 | } 66 | 67 | public NativeGalleryMediaPickerResultFragment( final NativeGalleryMediaPickerResultOperation resultOperation ) 68 | { 69 | this.resultOperation = resultOperation; 70 | } 71 | 72 | @Override 73 | public void onCreate( Bundle savedInstanceState ) 74 | { 75 | super.onCreate( savedInstanceState ); 76 | setRetainInstance( true ); // Required to preserve threads and stuff in case the configuration changes (e.g. orientation change) 77 | 78 | new Thread( new Runnable() 79 | { 80 | @Override 81 | public void run() 82 | { 83 | resultOperation.execute(); 84 | } 85 | } ).start(); 86 | } 87 | 88 | @Override 89 | public Dialog onCreateDialog( Bundle savedInstanceState ) 90 | { 91 | // Credit: https://stackoverflow.com/a/49272722/2373034 92 | LinearLayout.LayoutParams layoutParams = new LinearLayout.LayoutParams( LinearLayout.LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.WRAP_CONTENT ); 93 | layoutParams.gravity = Gravity.CENTER; 94 | 95 | LinearLayout layout = new LinearLayout( getActivity() ); 96 | layout.setOrientation( LinearLayout.VERTICAL ); 97 | layout.setPadding( 30, 30, 30, 30 ); 98 | layout.setGravity( Gravity.CENTER ); 99 | layout.setLayoutParams( layoutParams ); 100 | 101 | layoutParams = new LinearLayout.LayoutParams( LinearLayout.LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.WRAP_CONTENT ); 102 | layoutParams.gravity = Gravity.CENTER; 103 | layoutParams.width = (int) ( 175 * getActivity().getResources().getDisplayMetrics().density ); 104 | 105 | progressBar = new ProgressBar( getActivity(), null, android.R.attr.progressBarStyleHorizontal ); 106 | progressBar.setIndeterminate( true ); 107 | progressBar.setPadding( 0, 30, 0, 0 ); 108 | progressBar.setLayoutParams( layoutParams ); 109 | 110 | if( progressBarLabel != null && progressBarLabel.length() > 0 ) 111 | { 112 | layoutParams = new LinearLayout.LayoutParams( LinearLayout.LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.WRAP_CONTENT ); 113 | layoutParams.gravity = Gravity.CENTER; 114 | 115 | TextView progressBarText = new TextView( getActivity() ); 116 | progressBarText.setText( progressBarLabel ); 117 | progressBarText.setTextColor( Color.BLACK ); 118 | progressBarText.setTextSize( 20 ); 119 | progressBarText.setLayoutParams( layoutParams ); 120 | 121 | layout.addView( progressBarText ); 122 | } 123 | 124 | layout.addView( progressBar ); 125 | 126 | AlertDialog dialog = new AlertDialog.Builder( getActivity() ) 127 | .setNegativeButton( android.R.string.cancel, new DialogInterface.OnClickListener() 128 | { 129 | @Override 130 | public void onClick( DialogInterface dialog, int which ) 131 | { 132 | resultOperation.cancel(); 133 | resultOperation.sendResultToUnity(); 134 | 135 | dismissAllowingStateLoss(); 136 | } 137 | } ) 138 | .setCancelable( false ) 139 | .setView( layout ).create(); 140 | 141 | dialog.setCancelable( false ); 142 | dialog.setCanceledOnTouchOutside( false ); 143 | 144 | return dialog; 145 | } 146 | 147 | @Override 148 | public void onActivityCreated( Bundle savedInstanceState ) 149 | { 150 | super.onActivityCreated( savedInstanceState ); 151 | progressBarUpdateTask.run(); 152 | } 153 | 154 | @Override 155 | public void onDetach() 156 | { 157 | progressBar = null; 158 | uiUpdateHandler.removeCallbacks( progressBarUpdateTask ); 159 | 160 | super.onDetach(); 161 | } 162 | 163 | @Override 164 | public void onDismiss( DialogInterface dialog ) 165 | { 166 | super.onDismiss( dialog ); 167 | resultOperation.sendResultToUnity(); 168 | } 169 | 170 | @Override 171 | public void onDestroy() 172 | { 173 | super.onDestroy(); 174 | resultOperation.sendResultToUnity(); 175 | } 176 | } -------------------------------------------------------------------------------- /.github/AAR Source (Android)/java/com/yasirkula/unity/NativeGalleryMediaPickerResultOperation.java: -------------------------------------------------------------------------------- 1 | package com.yasirkula.unity; 2 | 3 | import android.content.ContentResolver; 4 | import android.content.Context; 5 | import android.content.Intent; 6 | import android.database.Cursor; 7 | import android.net.Uri; 8 | import android.os.Build; 9 | import android.provider.OpenableColumns; 10 | import android.util.Log; 11 | import android.webkit.MimeTypeMap; 12 | 13 | import java.io.File; 14 | import java.io.FileInputStream; 15 | import java.io.FileOutputStream; 16 | import java.io.InputStream; 17 | import java.io.OutputStream; 18 | import java.util.ArrayList; 19 | 20 | public class NativeGalleryMediaPickerResultOperation 21 | { 22 | private final Context context; 23 | private final NativeGalleryMediaReceiver mediaReceiver; 24 | private final Intent data; 25 | private final boolean selectMultiple; 26 | private final String savePathDirectory, savePathFilename; 27 | private ArrayList savedFiles; 28 | 29 | public boolean finished, sentResult; 30 | public int progress; 31 | 32 | private boolean cancelled; 33 | private String unityResult; 34 | 35 | public NativeGalleryMediaPickerResultOperation( final Context context, final NativeGalleryMediaReceiver mediaReceiver, final Intent data, final boolean selectMultiple, final String savePathDirectory, final String savePathFilename ) 36 | { 37 | this.context = context; 38 | this.mediaReceiver = mediaReceiver; 39 | this.data = data; 40 | this.selectMultiple = selectMultiple; 41 | this.savePathDirectory = savePathDirectory; 42 | this.savePathFilename = savePathFilename; 43 | } 44 | 45 | public void execute() 46 | { 47 | unityResult = ""; 48 | progress = -1; 49 | 50 | try 51 | { 52 | if( !selectMultiple || data.getClipData() == null ) 53 | { 54 | String _unityResult = getPathFromURI( data.getData() ); 55 | if( _unityResult != null && _unityResult.length() > 0 && new File( _unityResult ).exists() ) 56 | unityResult = _unityResult; 57 | 58 | Log.d( "Unity", "NativeGalleryMediaPickerResultOperation: " + _unityResult ); 59 | } 60 | else 61 | { 62 | boolean isFirstResult = true; 63 | for( int i = 0, count = data.getClipData().getItemCount(); i < count; i++ ) 64 | { 65 | if( cancelled ) 66 | return; 67 | 68 | String _unityResult = getPathFromURI( data.getClipData().getItemAt( i ).getUri() ); 69 | if( _unityResult != null && _unityResult.length() > 0 && new File( _unityResult ).exists() ) 70 | { 71 | if( isFirstResult ) 72 | { 73 | unityResult += _unityResult; 74 | isFirstResult = false; 75 | } 76 | else 77 | unityResult += ">" + _unityResult; 78 | } 79 | 80 | Log.d( "Unity", "NativeGalleryMediaPickerResultOperation: " + _unityResult ); 81 | } 82 | } 83 | } 84 | catch( Exception e ) 85 | { 86 | Log.e( "Unity", "Exception:", e ); 87 | } 88 | finally 89 | { 90 | progress = 100; 91 | finished = true; 92 | } 93 | } 94 | 95 | public void cancel() 96 | { 97 | if( cancelled || finished ) 98 | return; 99 | 100 | Log.d( "Unity", "Cancelled NativeGalleryMediaPickerResultOperation!" ); 101 | 102 | cancelled = true; 103 | unityResult = ""; 104 | } 105 | 106 | public void sendResultToUnity() 107 | { 108 | if( sentResult ) 109 | return; 110 | 111 | sentResult = true; 112 | 113 | if( mediaReceiver == null ) 114 | Log.d( "Unity", "NativeGalleryMediaPickerResultOperation.mediaReceiver became null in sendResultToUnity!" ); 115 | else 116 | { 117 | if( selectMultiple ) 118 | mediaReceiver.OnMultipleMediaReceived( unityResult ); 119 | else 120 | mediaReceiver.OnMediaReceived( unityResult ); 121 | } 122 | } 123 | 124 | private String getPathFromURI( Uri uri ) 125 | { 126 | if( uri == null ) 127 | return null; 128 | 129 | Log.d( "Unity", "Selected media uri: " + uri.toString() ); 130 | 131 | String path = NativeGalleryUtils.GetPathFromURI( context, uri ); 132 | if( path != null && path.length() > 0 ) 133 | { 134 | // Check if file is accessible 135 | FileInputStream inputStream = null; 136 | try 137 | { 138 | inputStream = new FileInputStream( new File( path ) ); 139 | inputStream.read(); 140 | 141 | if( NativeGalleryMediaPickerFragment.ShouldGrantPersistableUriPermission( context ) ) 142 | context.getContentResolver().takePersistableUriPermission( uri, Intent.FLAG_GRANT_READ_URI_PERMISSION ); 143 | 144 | return path; 145 | } 146 | catch( Exception e ) 147 | { 148 | Log.e( "Unity", "Media uri isn't accessible via File API: " + uri, e ); 149 | } 150 | finally 151 | { 152 | if( inputStream != null ) 153 | { 154 | try 155 | { 156 | inputStream.close(); 157 | } 158 | catch( Exception e ) 159 | { 160 | } 161 | } 162 | } 163 | } 164 | 165 | // File path couldn't be determined, copy the file to an accessible temporary location 166 | return copyToTempFile( uri ); 167 | } 168 | 169 | private String copyToTempFile( Uri uri ) 170 | { 171 | // Credit: https://developer.android.com/training/secure-file-sharing/retrieve-info.html#RetrieveFileInfo 172 | ContentResolver resolver = context.getContentResolver(); 173 | Cursor returnCursor = null; 174 | String filename = null; 175 | long fileSize = -1, copiedBytes = 0; 176 | 177 | try 178 | { 179 | returnCursor = resolver.query( uri, null, null, null, null ); 180 | if( returnCursor != null && returnCursor.moveToFirst() ) 181 | { 182 | filename = returnCursor.getString( returnCursor.getColumnIndex( OpenableColumns.DISPLAY_NAME ) ); 183 | fileSize = returnCursor.getLong( returnCursor.getColumnIndex( OpenableColumns.SIZE ) ); 184 | } 185 | } 186 | catch( Exception e ) 187 | { 188 | Log.e( "Unity", "Exception:", e ); 189 | } 190 | finally 191 | { 192 | if( returnCursor != null ) 193 | returnCursor.close(); 194 | } 195 | 196 | if( filename == null || filename.length() < 3 ) 197 | filename = "temp"; 198 | 199 | String extension = null; 200 | int filenameExtensionIndex = filename.lastIndexOf( '.' ); 201 | if( filenameExtensionIndex > 0 && filenameExtensionIndex < filename.length() - 1 ) 202 | extension = filename.substring( filenameExtensionIndex ); 203 | else 204 | { 205 | String mime = resolver.getType( uri ); 206 | if( mime != null ) 207 | { 208 | String mimeExtension = MimeTypeMap.getSingleton().getExtensionFromMimeType( mime ); 209 | if( mimeExtension != null && mimeExtension.length() > 0 ) 210 | extension = "." + mimeExtension; 211 | } 212 | } 213 | 214 | if( extension == null ) 215 | extension = ".tmp"; 216 | 217 | if( !NativeGalleryMediaPickerFragment.tryPreserveFilenames ) 218 | filename = savePathFilename; 219 | else if( filename.endsWith( extension ) ) 220 | filename = filename.substring( 0, filename.length() - extension.length() ); 221 | 222 | try 223 | { 224 | InputStream input = resolver.openInputStream( uri ); 225 | if( input == null ) 226 | { 227 | Log.w( "Unity", "Couldn't open input stream: " + uri ); 228 | return null; 229 | } 230 | 231 | if( fileSize < 0 ) 232 | { 233 | try 234 | { 235 | fileSize = input.available(); 236 | } 237 | catch( Exception e ) 238 | { 239 | } 240 | 241 | if( fileSize < 0 ) 242 | fileSize = 0; 243 | } 244 | 245 | String fullName = filename + extension; 246 | if( savedFiles != null ) 247 | { 248 | int n = 1; 249 | for( int i = 0; i < savedFiles.size(); i++ ) 250 | { 251 | if( savedFiles.get( i ).equals( fullName ) ) 252 | { 253 | n++; 254 | fullName = filename + n + extension; 255 | i = -1; 256 | } 257 | } 258 | } 259 | 260 | File tempFile = new File( savePathDirectory, fullName ); 261 | OutputStream output = null; 262 | try 263 | { 264 | output = new FileOutputStream( tempFile, false ); 265 | progress = ( fileSize > 0 ) ? 0 : -1; 266 | 267 | byte[] buf = new byte[4096]; 268 | int len; 269 | while( ( len = input.read( buf ) ) > 0 ) 270 | { 271 | if( cancelled ) 272 | break; 273 | 274 | output.write( buf, 0, len ); 275 | 276 | if( fileSize > 0 ) 277 | { 278 | copiedBytes += len; 279 | 280 | progress = (int) ( ( (double) copiedBytes / fileSize ) * 100 ); 281 | if( progress > 100 ) 282 | progress = 100; 283 | } 284 | } 285 | 286 | if( cancelled ) 287 | { 288 | output.close(); 289 | output = null; 290 | 291 | tempFile.delete(); 292 | } 293 | else if( fileSize > 0 ) 294 | progress = 100; 295 | 296 | if( selectMultiple ) 297 | { 298 | if( savedFiles == null ) 299 | savedFiles = new ArrayList(); 300 | 301 | savedFiles.add( fullName ); 302 | } 303 | 304 | Log.d( "Unity", "Copied media from " + uri + " to: " + tempFile.getAbsolutePath() ); 305 | return tempFile.getAbsolutePath(); 306 | } 307 | finally 308 | { 309 | if( output != null ) 310 | output.close(); 311 | 312 | input.close(); 313 | } 314 | } 315 | catch( Exception e ) 316 | { 317 | Log.e( "Unity", "Exception:", e ); 318 | } 319 | 320 | return null; 321 | } 322 | } -------------------------------------------------------------------------------- /.github/AAR Source (Android)/java/com/yasirkula/unity/NativeGalleryMediaReceiver.java: -------------------------------------------------------------------------------- 1 | package com.yasirkula.unity; 2 | 3 | /** 4 | * Created by yasirkula on 23.02.2018. 5 | */ 6 | 7 | public interface NativeGalleryMediaReceiver 8 | { 9 | void OnMediaReceived( String path ); 10 | void OnMultipleMediaReceived( String paths ); 11 | } 12 | -------------------------------------------------------------------------------- /.github/AAR Source (Android)/java/com/yasirkula/unity/NativeGalleryPermissionFragment.java: -------------------------------------------------------------------------------- 1 | package com.yasirkula.unity; 2 | 3 | // Original work Copyright (c) 2017 Yury Habets 4 | // Modified work Copyright 2018 yasirkula 5 | // 6 | // Permission is hereby granted, free of charge, to any person obtaining a copy 7 | // of this software and associated documentation files (the "Software"), to deal 8 | // in the Software without restriction, including without limitation the rights 9 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | // copies of the Software, and to permit persons to whom the Software is 11 | // furnished to do so, subject to the following conditions: 12 | // 13 | // The above copyright notice and this permission notice shall be included in 14 | // all copies or substantial portions of the Software. 15 | // 16 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | // SOFTWARE. 23 | 24 | import android.Manifest; 25 | import android.annotation.TargetApi; 26 | import android.app.Fragment; 27 | import android.content.Intent; 28 | import android.content.pm.PackageManager; 29 | import android.os.Build; 30 | import android.os.Bundle; 31 | import android.util.Log; 32 | 33 | import java.util.ArrayList; 34 | 35 | @TargetApi( Build.VERSION_CODES.M ) 36 | public class NativeGalleryPermissionFragment extends Fragment 37 | { 38 | public static final String READ_PERMISSION_ONLY = "NG_ReadOnly"; 39 | public static final String MEDIA_TYPE_ID = "NG_MediaType"; 40 | private static final int PERMISSIONS_REQUEST_CODE = 123655; 41 | 42 | private final NativeGalleryPermissionReceiver permissionReceiver; 43 | 44 | public NativeGalleryPermissionFragment() 45 | { 46 | permissionReceiver = null; 47 | } 48 | 49 | public NativeGalleryPermissionFragment( final NativeGalleryPermissionReceiver permissionReceiver ) 50 | { 51 | this.permissionReceiver = permissionReceiver; 52 | } 53 | 54 | @Override 55 | public void onCreate( Bundle savedInstanceState ) 56 | { 57 | super.onCreate( savedInstanceState ); 58 | if( permissionReceiver == null ) 59 | onRequestPermissionsResult( PERMISSIONS_REQUEST_CODE, new String[0], new int[0] ); 60 | else 61 | { 62 | boolean readPermissionOnly = getArguments().getBoolean( READ_PERMISSION_ONLY ); 63 | if( !readPermissionOnly && Build.VERSION.SDK_INT < 30 ) 64 | requestPermissions( new String[] { Manifest.permission.WRITE_EXTERNAL_STORAGE, Manifest.permission.READ_EXTERNAL_STORAGE }, PERMISSIONS_REQUEST_CODE ); 65 | else if( Build.VERSION.SDK_INT < 33 || getActivity().getApplicationInfo().targetSdkVersion < 33 ) 66 | requestPermissions( new String[] { Manifest.permission.READ_EXTERNAL_STORAGE }, PERMISSIONS_REQUEST_CODE ); 67 | else 68 | onRequestPermissionsResult( PERMISSIONS_REQUEST_CODE, new String[0], new int[0] ); 69 | } 70 | } 71 | 72 | @Override 73 | public void onRequestPermissionsResult( int requestCode, String[] permissions, int[] grantResults ) 74 | { 75 | if( requestCode != PERMISSIONS_REQUEST_CODE ) 76 | return; 77 | 78 | if( permissionReceiver == null ) 79 | { 80 | Log.e( "Unity", "Fragment data got reset while asking permissions!" ); 81 | 82 | getFragmentManager().beginTransaction().remove( this ).commitAllowingStateLoss(); 83 | return; 84 | } 85 | 86 | // 0 -> denied, must go to settings 87 | // 1 -> granted 88 | // 2 -> denied, can ask again 89 | int result = 1; 90 | if( permissions.length == 0 || grantResults.length == 0 ) 91 | result = 2; 92 | else 93 | { 94 | for( int i = 0; i < permissions.length && i < grantResults.length; ++i ) 95 | { 96 | if( grantResults[i] == PackageManager.PERMISSION_DENIED ) 97 | { 98 | if( !shouldShowRequestPermissionRationale( permissions[i] ) ) 99 | { 100 | result = 0; 101 | break; 102 | } 103 | 104 | result = 2; 105 | } 106 | } 107 | } 108 | 109 | permissionReceiver.OnPermissionResult( result ); 110 | getFragmentManager().beginTransaction().remove( this ).commitAllowingStateLoss(); 111 | 112 | // Resolves a bug in Unity 2019 where the calling activity 113 | // doesn't resume automatically after the fragment finishes 114 | // Credit: https://stackoverflow.com/a/12409215/2373034 115 | try 116 | { 117 | Intent resumeUnityActivity = new Intent( getActivity(), getActivity().getClass() ); 118 | resumeUnityActivity.setFlags( Intent.FLAG_ACTIVITY_REORDER_TO_FRONT ); 119 | getActivity().startActivityIfNeeded( resumeUnityActivity, 0 ); 120 | } 121 | catch( Exception e ) 122 | { 123 | Log.e( "Unity", "Exception (resume):", e ); 124 | } 125 | } 126 | } -------------------------------------------------------------------------------- /.github/AAR Source (Android)/java/com/yasirkula/unity/NativeGalleryPermissionReceiver.java: -------------------------------------------------------------------------------- 1 | package com.yasirkula.unity; 2 | 3 | /** 4 | * Created by yasirkula on 19.02.2018. 5 | */ 6 | 7 | public interface NativeGalleryPermissionReceiver 8 | { 9 | void OnPermissionResult( int result ); 10 | } 11 | -------------------------------------------------------------------------------- /.github/AAR Source (Android)/java/com/yasirkula/unity/NativeGalleryUtils.java: -------------------------------------------------------------------------------- 1 | package com.yasirkula.unity; 2 | 3 | import android.annotation.TargetApi; 4 | import android.content.ContentUris; 5 | import android.content.Context; 6 | import android.database.Cursor; 7 | import android.graphics.Bitmap; 8 | import android.graphics.BitmapFactory; 9 | import android.graphics.Matrix; 10 | import android.media.ExifInterface; 11 | import android.media.MediaMetadataRetriever; 12 | import android.media.ThumbnailUtils; 13 | import android.net.Uri; 14 | import android.os.Build; 15 | import android.os.Environment; 16 | import android.provider.DocumentsContract; 17 | import android.provider.MediaStore; 18 | import android.util.Log; 19 | import android.util.Size; 20 | 21 | import java.io.BufferedReader; 22 | import java.io.File; 23 | import java.io.FileInputStream; 24 | import java.io.FileOutputStream; 25 | import java.io.InputStream; 26 | import java.io.InputStreamReader; 27 | import java.io.OutputStream; 28 | 29 | public class NativeGalleryUtils 30 | { 31 | private static String secondaryStoragePath = null; 32 | private static int isXiaomiOrMIUI = 0; // 1: true, -1: false 33 | 34 | public static boolean IsXiaomiOrMIUI() 35 | { 36 | if( isXiaomiOrMIUI > 0 ) 37 | return true; 38 | else if( isXiaomiOrMIUI < 0 ) 39 | return false; 40 | 41 | if( "xiaomi".equalsIgnoreCase( android.os.Build.MANUFACTURER ) ) 42 | { 43 | isXiaomiOrMIUI = 1; 44 | return true; 45 | } 46 | 47 | // Check if device is using MIUI 48 | // Credit: https://gist.github.com/Muyangmin/e8ec1002c930d8df3df46b306d03315d 49 | String line; 50 | BufferedReader inputStream = null; 51 | try 52 | { 53 | java.lang.Process process = Runtime.getRuntime().exec( "getprop ro.miui.ui.version.name" ); 54 | inputStream = new BufferedReader( new InputStreamReader( process.getInputStream() ), 1024 ); 55 | line = inputStream.readLine(); 56 | 57 | if( line != null && line.length() > 0 ) 58 | { 59 | isXiaomiOrMIUI = 1; 60 | return true; 61 | } 62 | else 63 | { 64 | isXiaomiOrMIUI = -1; 65 | return false; 66 | } 67 | } 68 | catch( Exception e ) 69 | { 70 | isXiaomiOrMIUI = -1; 71 | return false; 72 | } 73 | finally 74 | { 75 | if( inputStream != null ) 76 | { 77 | try 78 | { 79 | inputStream.close(); 80 | } 81 | catch( Exception e ) 82 | { 83 | } 84 | } 85 | } 86 | } 87 | 88 | // Credit: https://stackoverflow.com/a/36714242/2373034 89 | public static String GetPathFromURI( Context context, Uri uri ) 90 | { 91 | if( uri == null ) 92 | return null; 93 | 94 | String selection = null; 95 | String[] selectionArgs = null; 96 | 97 | try 98 | { 99 | if( Build.VERSION.SDK_INT >= 19 && DocumentsContract.isDocumentUri( context.getApplicationContext(), uri ) ) 100 | { 101 | if( "com.android.externalstorage.documents".equals( uri.getAuthority() ) ) 102 | { 103 | final String docId = DocumentsContract.getDocumentId( uri ); 104 | final String[] split = docId.split( ":" ); 105 | 106 | if( "primary".equalsIgnoreCase( split[0] ) ) 107 | return Environment.getExternalStorageDirectory() + File.separator + split[1]; 108 | else if( "raw".equalsIgnoreCase( split[0] ) ) // https://stackoverflow.com/a/51874578/2373034 109 | return split[1]; 110 | 111 | return GetSecondaryStoragePathFor( split[1] ); 112 | } 113 | else if( "com.android.providers.downloads.documents".equals( uri.getAuthority() ) ) 114 | { 115 | final String id = DocumentsContract.getDocumentId( uri ); 116 | if( id.startsWith( "raw:" ) ) // https://stackoverflow.com/a/51874578/2373034 117 | return id.substring( 4 ); 118 | else if( id.indexOf( ':' ) < 0 ) // Don't attempt to parse stuff like "msf:NUMBER" (newer Android versions) 119 | uri = ContentUris.withAppendedId( Uri.parse( "content://downloads/public_downloads" ), Long.parseLong( id ) ); 120 | else 121 | return null; 122 | } 123 | else if( "com.android.providers.media.documents".equals( uri.getAuthority() ) ) 124 | { 125 | final String docId = DocumentsContract.getDocumentId( uri ); 126 | final String[] split = docId.split( ":" ); 127 | final String type = split[0]; 128 | if( "image".equals( type ) ) 129 | uri = MediaStore.Images.Media.EXTERNAL_CONTENT_URI; 130 | else if( "video".equals( type ) ) 131 | uri = MediaStore.Video.Media.EXTERNAL_CONTENT_URI; 132 | else if( "audio".equals( type ) ) 133 | uri = MediaStore.Audio.Media.EXTERNAL_CONTENT_URI; 134 | else if( "raw".equals( type ) ) // https://stackoverflow.com/a/51874578/2373034 135 | return split[1]; 136 | 137 | selection = "_id=?"; 138 | selectionArgs = new String[] { split[1] }; 139 | } 140 | } 141 | 142 | if( "content".equalsIgnoreCase( uri.getScheme() ) ) 143 | { 144 | String[] projection = { MediaStore.Images.Media.DATA }; 145 | Cursor cursor = null; 146 | 147 | try 148 | { 149 | cursor = context.getContentResolver().query( uri, projection, selection, selectionArgs, null ); 150 | if( cursor != null ) 151 | { 152 | int column_index = cursor.getColumnIndexOrThrow( MediaStore.Images.Media.DATA ); 153 | if( cursor.moveToFirst() ) 154 | { 155 | String columnValue = cursor.getString( column_index ); 156 | if( columnValue != null && columnValue.length() > 0 ) 157 | return columnValue; 158 | } 159 | } 160 | } 161 | catch( Exception e ) 162 | { 163 | } 164 | finally 165 | { 166 | if( cursor != null ) 167 | cursor.close(); 168 | } 169 | } 170 | else if( "file".equalsIgnoreCase( uri.getScheme() ) ) 171 | return uri.getPath(); 172 | 173 | // File path couldn't be determined 174 | return null; 175 | } 176 | catch( Exception e ) 177 | { 178 | Log.e( "Unity", "Exception:", e ); 179 | return null; 180 | } 181 | } 182 | 183 | private static String GetSecondaryStoragePathFor( String localPath ) 184 | { 185 | if( secondaryStoragePath == null ) 186 | { 187 | String primaryPath = Environment.getExternalStorageDirectory().getAbsolutePath(); 188 | 189 | // Try paths saved at system environments 190 | // Credit: https://stackoverflow.com/a/32088396/2373034 191 | String strSDCardPath = System.getenv( "SECONDARY_STORAGE" ); 192 | if( strSDCardPath == null || strSDCardPath.length() == 0 ) 193 | strSDCardPath = System.getenv( "EXTERNAL_SDCARD_STORAGE" ); 194 | 195 | if( strSDCardPath != null && strSDCardPath.length() > 0 ) 196 | { 197 | if( !strSDCardPath.contains( ":" ) ) 198 | strSDCardPath += ":"; 199 | 200 | String[] externalPaths = strSDCardPath.split( ":" ); 201 | for( int i = 0; i < externalPaths.length; i++ ) 202 | { 203 | String path = externalPaths[i]; 204 | if( path != null && path.length() > 0 ) 205 | { 206 | File file = new File( path ); 207 | if( file.exists() && file.isDirectory() && file.canRead() && !file.getAbsolutePath().equalsIgnoreCase( primaryPath ) ) 208 | { 209 | String absolutePath = file.getAbsolutePath() + File.separator + localPath; 210 | if( new File( absolutePath ).exists() ) 211 | { 212 | secondaryStoragePath = file.getAbsolutePath(); 213 | return absolutePath; 214 | } 215 | } 216 | } 217 | } 218 | } 219 | 220 | // Try most common possible paths 221 | // Credit: https://gist.github.com/PauloLuan/4bcecc086095bce28e22 222 | String[] possibleRoots = new String[] { "/storage", "/mnt", "/storage/removable", 223 | "/removable", "/data", "/mnt/media_rw", "/mnt/sdcard0" }; 224 | for( String root : possibleRoots ) 225 | { 226 | try 227 | { 228 | File fileList[] = new File( root ).listFiles(); 229 | for( File file : fileList ) 230 | { 231 | if( file.exists() && file.isDirectory() && file.canRead() && !file.getAbsolutePath().equalsIgnoreCase( primaryPath ) ) 232 | { 233 | String absolutePath = file.getAbsolutePath() + File.separator + localPath; 234 | if( new File( absolutePath ).exists() ) 235 | { 236 | secondaryStoragePath = file.getAbsolutePath(); 237 | return absolutePath; 238 | } 239 | } 240 | } 241 | } 242 | catch( Exception e ) 243 | { 244 | } 245 | } 246 | 247 | secondaryStoragePath = "_NulL_"; 248 | } 249 | else if( !secondaryStoragePath.equals( "_NulL_" ) ) 250 | return secondaryStoragePath + File.separator + localPath; 251 | 252 | return null; 253 | } 254 | 255 | public static boolean WriteFileToStream( File file, OutputStream out ) 256 | { 257 | try 258 | { 259 | InputStream in = new FileInputStream( file ); 260 | try 261 | { 262 | byte[] buf = new byte[1024]; 263 | int len; 264 | while( ( len = in.read( buf ) ) > 0 ) 265 | out.write( buf, 0, len ); 266 | } 267 | finally 268 | { 269 | try 270 | { 271 | in.close(); 272 | } 273 | catch( Exception e ) 274 | { 275 | Log.e( "Unity", "Exception:", e ); 276 | } 277 | } 278 | } 279 | catch( Exception e ) 280 | { 281 | Log.e( "Unity", "Exception:", e ); 282 | return false; 283 | } 284 | finally 285 | { 286 | try 287 | { 288 | out.close(); 289 | } 290 | catch( Exception e ) 291 | { 292 | Log.e( "Unity", "Exception:", e ); 293 | } 294 | } 295 | 296 | return true; 297 | } 298 | 299 | private static BitmapFactory.Options GetImageMetadata( final String path ) 300 | { 301 | try 302 | { 303 | BitmapFactory.Options result = new BitmapFactory.Options(); 304 | result.inJustDecodeBounds = true; 305 | BitmapFactory.decodeFile( path, result ); 306 | 307 | return result; 308 | } 309 | catch( Exception e ) 310 | { 311 | Log.e( "Unity", "Exception:", e ); 312 | return null; 313 | } 314 | } 315 | 316 | // Credit: https://stackoverflow.com/a/30572852/2373034 317 | public static int GetImageOrientation( Context context, final String path ) 318 | { 319 | try 320 | { 321 | ExifInterface exif = new ExifInterface( path ); 322 | int orientationEXIF = exif.getAttributeInt( ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_UNDEFINED ); 323 | if( orientationEXIF != ExifInterface.ORIENTATION_UNDEFINED ) 324 | return orientationEXIF; 325 | } 326 | catch( Exception e ) 327 | { 328 | } 329 | 330 | Cursor cursor = null; 331 | try 332 | { 333 | cursor = context.getContentResolver().query( Uri.fromFile( new File( path ) ), new String[] { MediaStore.Images.Media.ORIENTATION }, null, null, null ); 334 | if( cursor != null && cursor.moveToFirst() ) 335 | { 336 | int orientation = cursor.getInt( cursor.getColumnIndex( MediaStore.Images.Media.ORIENTATION ) ); 337 | if( orientation == 90 ) 338 | return ExifInterface.ORIENTATION_ROTATE_90; 339 | if( orientation == 180 ) 340 | return ExifInterface.ORIENTATION_ROTATE_180; 341 | if( orientation == 270 ) 342 | return ExifInterface.ORIENTATION_ROTATE_270; 343 | 344 | return ExifInterface.ORIENTATION_NORMAL; 345 | } 346 | } 347 | catch( Exception e ) 348 | { 349 | } 350 | finally 351 | { 352 | if( cursor != null ) 353 | cursor.close(); 354 | } 355 | 356 | return ExifInterface.ORIENTATION_UNDEFINED; 357 | } 358 | 359 | // Credit: https://gist.github.com/aviadmini/4be34097dfdb842ae066fae48501ed41 360 | private static Matrix GetImageOrientationCorrectionMatrix( final int orientation, final float scale ) 361 | { 362 | Matrix matrix = new Matrix(); 363 | 364 | switch( orientation ) 365 | { 366 | case ExifInterface.ORIENTATION_ROTATE_270: 367 | { 368 | matrix.postRotate( 270 ); 369 | matrix.postScale( scale, scale ); 370 | 371 | break; 372 | } 373 | case ExifInterface.ORIENTATION_ROTATE_180: 374 | { 375 | matrix.postRotate( 180 ); 376 | matrix.postScale( scale, scale ); 377 | 378 | break; 379 | } 380 | case ExifInterface.ORIENTATION_ROTATE_90: 381 | { 382 | matrix.postRotate( 90 ); 383 | matrix.postScale( scale, scale ); 384 | 385 | break; 386 | } 387 | case ExifInterface.ORIENTATION_FLIP_HORIZONTAL: 388 | { 389 | matrix.postScale( -scale, scale ); 390 | break; 391 | } 392 | case ExifInterface.ORIENTATION_FLIP_VERTICAL: 393 | { 394 | matrix.postScale( scale, -scale ); 395 | break; 396 | } 397 | case ExifInterface.ORIENTATION_TRANSPOSE: 398 | { 399 | matrix.postRotate( 90 ); 400 | matrix.postScale( -scale, scale ); 401 | 402 | break; 403 | } 404 | case ExifInterface.ORIENTATION_TRANSVERSE: 405 | { 406 | matrix.postRotate( 270 ); 407 | matrix.postScale( -scale, scale ); 408 | 409 | break; 410 | } 411 | default: 412 | { 413 | matrix.postScale( scale, scale ); 414 | break; 415 | } 416 | } 417 | 418 | return matrix; 419 | } 420 | 421 | public static String LoadImageAtPath( Context context, String path, final String temporaryFilePath, final int maxSize ) 422 | { 423 | BitmapFactory.Options metadata = GetImageMetadata( path ); 424 | if( metadata == null ) 425 | return path; 426 | 427 | boolean shouldCreateNewBitmap = false; 428 | if( metadata.outWidth > maxSize || metadata.outHeight > maxSize ) 429 | shouldCreateNewBitmap = true; 430 | 431 | if( metadata.outMimeType != null && !metadata.outMimeType.equals( "image/jpeg" ) && !metadata.outMimeType.equals( "image/png" ) ) 432 | shouldCreateNewBitmap = true; 433 | 434 | int orientation = GetImageOrientation( context, path ); 435 | if( orientation != ExifInterface.ORIENTATION_NORMAL && orientation != ExifInterface.ORIENTATION_UNDEFINED ) 436 | shouldCreateNewBitmap = true; 437 | 438 | if( shouldCreateNewBitmap ) 439 | { 440 | Bitmap bitmap = null; 441 | FileOutputStream out = null; 442 | 443 | try 444 | { 445 | // Credit: https://developer.android.com/topic/performance/graphics/load-bitmap.html 446 | int sampleSize = 1; 447 | int halfHeight = metadata.outHeight / 2; 448 | int halfWidth = metadata.outWidth / 2; 449 | while( ( halfHeight / sampleSize ) >= maxSize || ( halfWidth / sampleSize ) >= maxSize ) 450 | sampleSize *= 2; 451 | 452 | BitmapFactory.Options options = new BitmapFactory.Options(); 453 | options.inSampleSize = sampleSize; 454 | options.inJustDecodeBounds = false; 455 | bitmap = BitmapFactory.decodeFile( path, options ); 456 | 457 | float scaleX = 1f, scaleY = 1f; 458 | if( bitmap.getWidth() > maxSize ) 459 | scaleX = maxSize / (float) bitmap.getWidth(); 460 | if( bitmap.getHeight() > maxSize ) 461 | scaleY = maxSize / (float) bitmap.getHeight(); 462 | 463 | // Create a new bitmap if it should be scaled down or if its orientation is wrong 464 | float scale = scaleX < scaleY ? scaleX : scaleY; 465 | if( scale < 1f || ( orientation != ExifInterface.ORIENTATION_NORMAL && orientation != ExifInterface.ORIENTATION_UNDEFINED ) ) 466 | { 467 | Matrix transformationMatrix = GetImageOrientationCorrectionMatrix( orientation, scale ); 468 | Bitmap transformedBitmap = Bitmap.createBitmap( bitmap, 0, 0, bitmap.getWidth(), bitmap.getHeight(), transformationMatrix, true ); 469 | if( transformedBitmap != bitmap ) 470 | { 471 | bitmap.recycle(); 472 | bitmap = transformedBitmap; 473 | } 474 | } 475 | 476 | out = new FileOutputStream( temporaryFilePath ); 477 | if( metadata.outMimeType == null || !metadata.outMimeType.equals( "image/jpeg" ) ) 478 | bitmap.compress( Bitmap.CompressFormat.PNG, 100, out ); 479 | else 480 | bitmap.compress( Bitmap.CompressFormat.JPEG, 100, out ); 481 | 482 | path = temporaryFilePath; 483 | } 484 | catch( Exception e ) 485 | { 486 | Log.e( "Unity", "Exception:", e ); 487 | 488 | try 489 | { 490 | File temporaryFile = new File( temporaryFilePath ); 491 | if( temporaryFile.exists() ) 492 | temporaryFile.delete(); 493 | } 494 | catch( Exception e2 ) 495 | { 496 | } 497 | } 498 | finally 499 | { 500 | if( bitmap != null ) 501 | bitmap.recycle(); 502 | 503 | try 504 | { 505 | if( out != null ) 506 | out.close(); 507 | } 508 | catch( Exception e ) 509 | { 510 | } 511 | } 512 | } 513 | 514 | return path; 515 | } 516 | 517 | public static String GetImageProperties( Context context, final String path ) 518 | { 519 | BitmapFactory.Options metadata = GetImageMetadata( path ); 520 | if( metadata == null ) 521 | return ""; 522 | 523 | int width = metadata.outWidth; 524 | int height = metadata.outHeight; 525 | 526 | String mimeType = metadata.outMimeType; 527 | if( mimeType == null ) 528 | mimeType = ""; 529 | 530 | int orientationUnity; 531 | int orientation = GetImageOrientation( context, path ); 532 | if( orientation == ExifInterface.ORIENTATION_UNDEFINED ) 533 | orientationUnity = -1; 534 | else if( orientation == ExifInterface.ORIENTATION_NORMAL ) 535 | orientationUnity = 0; 536 | else if( orientation == ExifInterface.ORIENTATION_ROTATE_90 ) 537 | orientationUnity = 1; 538 | else if( orientation == ExifInterface.ORIENTATION_ROTATE_180 ) 539 | orientationUnity = 2; 540 | else if( orientation == ExifInterface.ORIENTATION_ROTATE_270 ) 541 | orientationUnity = 3; 542 | else if( orientation == ExifInterface.ORIENTATION_FLIP_HORIZONTAL ) 543 | orientationUnity = 4; 544 | else if( orientation == ExifInterface.ORIENTATION_TRANSPOSE ) 545 | orientationUnity = 5; 546 | else if( orientation == ExifInterface.ORIENTATION_FLIP_VERTICAL ) 547 | orientationUnity = 6; 548 | else if( orientation == ExifInterface.ORIENTATION_TRANSVERSE ) 549 | orientationUnity = 7; 550 | else 551 | orientationUnity = -1; 552 | 553 | if( orientation == ExifInterface.ORIENTATION_ROTATE_90 || orientation == ExifInterface.ORIENTATION_ROTATE_270 || 554 | orientation == ExifInterface.ORIENTATION_TRANSPOSE || orientation == ExifInterface.ORIENTATION_TRANSVERSE ) 555 | { 556 | int temp = width; 557 | width = height; 558 | height = temp; 559 | } 560 | 561 | return width + ">" + height + ">" + mimeType + ">" + orientationUnity; 562 | } 563 | 564 | @TargetApi( Build.VERSION_CODES.JELLY_BEAN_MR1 ) 565 | public static String GetVideoProperties( Context context, final String path ) 566 | { 567 | MediaMetadataRetriever metadataRetriever = new MediaMetadataRetriever(); 568 | try 569 | { 570 | metadataRetriever.setDataSource( path ); 571 | 572 | String width = metadataRetriever.extractMetadata( MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH ); 573 | String height = metadataRetriever.extractMetadata( MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT ); 574 | String duration = metadataRetriever.extractMetadata( MediaMetadataRetriever.METADATA_KEY_DURATION ); 575 | String rotation = "0"; 576 | if( Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1 ) 577 | rotation = metadataRetriever.extractMetadata( MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION ); 578 | 579 | if( width == null ) 580 | width = "0"; 581 | if( height == null ) 582 | height = "0"; 583 | if( duration == null ) 584 | duration = "0"; 585 | if( rotation == null ) 586 | rotation = "0"; 587 | 588 | return width + ">" + height + ">" + duration + ">" + rotation; 589 | } 590 | catch( Exception e ) 591 | { 592 | Log.e( "Unity", "Exception:", e ); 593 | return ""; 594 | } 595 | finally 596 | { 597 | try 598 | { 599 | metadataRetriever.release(); 600 | } 601 | catch( Exception e ) 602 | { 603 | } 604 | } 605 | } 606 | 607 | @TargetApi( Build.VERSION_CODES.Q ) 608 | public static String GetVideoThumbnail( Context context, final String path, final String savePath, final boolean saveAsJpeg, int maxSize, double captureTime ) 609 | { 610 | Bitmap bitmap = null; 611 | FileOutputStream out = null; 612 | 613 | try 614 | { 615 | if( captureTime < 0.0 && maxSize <= 1024 ) 616 | { 617 | try 618 | { 619 | if( Build.VERSION.SDK_INT < Build.VERSION_CODES.Q ) 620 | bitmap = ThumbnailUtils.createVideoThumbnail( path, maxSize > 512 ? MediaStore.Video.Thumbnails.FULL_SCREEN_KIND : MediaStore.Video.Thumbnails.MINI_KIND ); 621 | else 622 | bitmap = ThumbnailUtils.createVideoThumbnail( new File( path ), maxSize > 512 ? new Size( 1024, 786 ) : new Size( 512, 384 ), null ); 623 | } 624 | catch( Exception e ) 625 | { 626 | Log.e( "Unity", "Exception:", e ); 627 | } 628 | } 629 | 630 | if( bitmap == null ) 631 | { 632 | MediaMetadataRetriever metadataRetriever = new MediaMetadataRetriever(); 633 | try 634 | { 635 | metadataRetriever.setDataSource( path ); 636 | 637 | try 638 | { 639 | int width = Integer.parseInt( metadataRetriever.extractMetadata( MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH ) ); 640 | int height = Integer.parseInt( metadataRetriever.extractMetadata( MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT ) ); 641 | if( maxSize > width && maxSize > height ) 642 | maxSize = width > height ? width : height; 643 | } 644 | catch( Exception e ) 645 | { 646 | } 647 | 648 | if( captureTime < 0.0 ) 649 | captureTime = 0.0; 650 | else 651 | { 652 | try 653 | { 654 | double duration = Long.parseLong( metadataRetriever.extractMetadata( MediaMetadataRetriever.METADATA_KEY_DURATION ) ) / 1000.0; 655 | if( captureTime > duration ) 656 | captureTime = duration; 657 | } 658 | catch( Exception e ) 659 | { 660 | } 661 | } 662 | 663 | long frameTime = (long) ( captureTime * 1000000.0 ); 664 | if( Build.VERSION.SDK_INT < 27 ) 665 | bitmap = metadataRetriever.getFrameAtTime( frameTime, MediaMetadataRetriever.OPTION_CLOSEST_SYNC ); 666 | else 667 | bitmap = metadataRetriever.getScaledFrameAtTime( frameTime, MediaMetadataRetriever.OPTION_CLOSEST_SYNC, maxSize, maxSize ); 668 | } 669 | finally 670 | { 671 | try 672 | { 673 | metadataRetriever.release(); 674 | } 675 | catch( Exception e ) 676 | { 677 | } 678 | } 679 | } 680 | 681 | if( bitmap == null ) 682 | return ""; 683 | 684 | out = new FileOutputStream( savePath ); 685 | if( saveAsJpeg ) 686 | bitmap.compress( Bitmap.CompressFormat.JPEG, 100, out ); 687 | else 688 | bitmap.compress( Bitmap.CompressFormat.PNG, 100, out ); 689 | 690 | return savePath; 691 | } 692 | catch( Exception e ) 693 | { 694 | Log.e( "Unity", "Exception:", e ); 695 | return ""; 696 | } 697 | finally 698 | { 699 | if( bitmap != null ) 700 | bitmap.recycle(); 701 | 702 | try 703 | { 704 | if( out != null ) 705 | out.close(); 706 | } 707 | catch( Exception e ) 708 | { 709 | } 710 | } 711 | } 712 | } -------------------------------------------------------------------------------- /.github/AAR Source (Android)/proguard.txt: -------------------------------------------------------------------------------- 1 | -keep class com.yasirkula.unity.* { *; } -------------------------------------------------------------------------------- /.github/README.md: -------------------------------------------------------------------------------- 1 | # Unity Native Gallery Plugin 2 | 3 | **Available on Asset Store:** https://assetstore.unity.com/packages/tools/integration/native-gallery-for-android-ios-112630 4 | 5 | **Forum Thread:** https://forum.unity.com/threads/native-gallery-for-android-ios-open-source.519619/ 6 | 7 | **Discord:** https://discord.gg/UJJt549AaV 8 | 9 | **[GitHub Sponsors ☕](https://github.com/sponsors/yasirkula)** 10 | 11 | This plugin helps you save your images and/or videos to device **Gallery** on Android and **Photos** on iOS (other platforms aren't supported). It is also possible to pick an image or video from Gallery/Photos. 12 | 13 | ## INSTALLATION 14 | 15 | There are 5 ways to install this plugin: 16 | 17 | - import [NativeGallery.unitypackage](https://github.com/yasirkula/UnityNativeGallery/releases) via *Assets-Import Package* 18 | - clone/[download](https://github.com/yasirkula/UnityNativeGallery/archive/master.zip) this repository and move the *Plugins* folder to your Unity project's *Assets* folder 19 | - import it from [Asset Store](https://assetstore.unity.com/packages/tools/integration/native-gallery-for-android-ios-112630) 20 | - *(via Package Manager)* click the + button and install the package from the following git URL: 21 | - `https://github.com/yasirkula/UnityNativeGallery.git` 22 | - *(via [OpenUPM](https://openupm.com))* after installing [openupm-cli](https://github.com/openupm/openupm-cli), run the following command: 23 | - `openupm add com.yasirkula.nativegallery` 24 | 25 | ### Android Setup 26 | 27 | NativeGallery no longer requires any manual setup on Android. 28 | 29 | ### iOS Setup 30 | 31 | **IMPORTANT:** If you are targeting iOS 14 or later, you need to build your app with Xcode 12 or later to avoid any permission issues. 32 | 33 | There are two ways to set up the plugin on iOS: 34 | 35 | **a. Automated Setup for iOS** 36 | 37 | - *(optional)* change the values of **Photo Library Usage Description** and **Photo Library Additions Usage Description** at *Project Settings/yasirkula/Native Gallery* 38 | - *(Unity 2017.4 or earlier)* if your minimum *Deployment Target* (iOS Version) is at least 8.0, set the value of **Deployment Target Is 8.0 Or Above** to *true* at *Project Settings/yasirkula/Native Gallery* 39 | 40 | **b. Manual Setup for iOS** 41 | 42 | - see: https://github.com/yasirkula/UnityNativeGallery/wiki/Manual-Setup-for-iOS 43 | 44 | ## FAQ 45 | 46 | - **How can I fetch the path of the saved image or the original path of the picked image on iOS?** 47 | 48 | You can't. On iOS, these files are stored in an internal directory that we have no access to (I don't think there is even a way to fetch that internal path). 49 | 50 | - **Plugin doesn't work in a Windows/Mac/Linux build** 51 | 52 | Only Android & iOS platforms are supported. Editor functionality is for preview purposes only and uses Unity's [Editor-only API](https://docs.unity3d.com/ScriptReference/EditorUtility.OpenFilePanelWithFilters.html). 53 | 54 | - **Can't access the Gallery, it says "java.lang.ClassNotFoundException: com.yasirkula.unity.NativeGallery" in Logcat** 55 | 56 | If you are sure that your plugin is up-to-date, then enable **Custom Proguard File** option from *Player Settings* and add the following line to that file: `-keep class com.yasirkula.unity.* { *; }` 57 | 58 | - **I save the picked image's path for later use but I can no longer access it after restarting the app** 59 | 60 | This happens on Android 33+ devices when *Target API Level* is set to 33 or later. The recommended solution is to copy the image to [persistentDataPath](https://docs.unity3d.com/ScriptReference/Application-persistentDataPath.html) because otherwise, the source image can be deleted by the user from Gallery, deleted by the operating system to free up space or overwritten by NativeGallery in the next *PickImage* call (if NativeGallery can't determine the image's source file path, then it copies the picked image to a fixed location in temporaryCachePath and thus, the image can easily be overwritten). If you still would like to persist access to the image, you can call the following code in your *Awake* function but be aware that this fix has a [hard limit of 512 files](https://issuetracker.google.com/issues/149315521#comment7): 61 | 62 | ```csharp 63 | #if !UNITY_EDITOR && UNITY_ANDROID 64 | using( AndroidJavaClass ajc = new AndroidJavaClass( "com.yasirkula.unity.NativeGalleryMediaPickerFragment" ) ) 65 | ajc.SetStatic( "GrantPersistableUriPermission", true ); 66 | #endif 67 | ``` 68 | 69 | - **Android build fails, it says "error: attribute android:requestLegacyExternalStorage not found" in Console** 70 | 71 | `android:requestLegacyExternalStorage` attribute in _AndroidManifest.xml_ fixes a rare UnauthorizedAccessException on Android 10 but requires you to update your Android SDK to at least **SDK 29**. If this isn't possible for you, you should open *NativeGallery.aar* with WinRAR or 7-Zip and then remove the `` tag from _AndroidManifest.xml_. 72 | 73 | - **Nothing happens when I try to access the Gallery on Android** 74 | 75 | Make sure that you've set the **Write Permission** to **External (SDCard)** in *Player Settings*. 76 | 77 | - **NativeGallery functions return Permission.Denied even though I've set "Write Permission" to "External (SDCard)"** 78 | 79 | Declare the `WRITE_EXTERNAL_STORAGE` permission manually in your **Plugins/Android/AndroidManifest.xml** with the `tools:node="replace"` attribute as follows: ``. 80 | 81 | - **Saving image/video doesn't work properly** 82 | 83 | Make sure that the *filename* parameter of the Save function includes the file's extension, as well 84 | 85 | ## HOW TO 86 | 87 | ### A. Saving Media To Gallery/Photos 88 | 89 | `NativeGallery.SaveImageToGallery( byte[] mediaBytes, string album, string filename, MediaSaveCallback callback = null )`: use this function if you have the raw bytes of the image. 90 | - On Android, your images/videos are saved at **DCIM/album/filename**. On iOS 14+, the image/video will be saved to the default Photos album (i.e. *album* parameter will be ignored). On earlier iOS versions, the image/video will be saved to the target album. Make sure that the *filename* parameter includes the file's extension, as well 91 | - **MediaSaveCallback** takes `bool success` and `string path` parameters. If the image/video is saved successfully, *success* becomes *true*. On Android, *path* stores where the image/video was saved to (is *null* on iOS). If the raw filepath can't be determined, an abstract Storage Access Framework path will be returned (*File.Exists* returns *false* for that path) 92 | 93 | **IMPORTANT:** NativeGallery will never overwrite existing media on the Gallery. If there is a name conflict, NativeGallery will ensure a unique filename. So don't put `{0}` in *filename* anymore (for new users, putting {0} in filename was recommended in order to ensure unique filenames in earlier versions, this is no longer necessary). 94 | 95 | `NativeGallery.SaveImageToGallery( string existingMediaPath, string album, string filename, MediaSaveCallback callback = null )`: use this function if the image is already saved on disk. Enter the file's path to **existingMediaPath**. 96 | 97 | `NativeGallery.SaveImageToGallery( Texture2D image, string album, string filename, MediaSaveCallback callback = null )`: use this function to easily save a **Texture2D** to Gallery/Photos. If filename ends with "*.jpeg*" or "*.jpg*", texture will be saved as JPEG; otherwise, it will be saved as PNG. 98 | 99 | `NativeGallery.SaveVideoToGallery( byte[] mediaBytes, string album, string filename, MediaSaveCallback callback = null )`: use this function if you have the raw bytes of the video. This function works similar to its *SaveImageToGallery* equivalent. 100 | 101 | `NativeGallery.SaveVideoToGallery( string existingMediaPath, string album, string filename, MediaSaveCallback callback = null )`: use this function if the video is already saved on disk. This function works similar to its *SaveImageToGallery* equivalent. 102 | 103 | ### B. Retrieving Media From Gallery/Photos 104 | 105 | `NativeGallery.GetImageFromGallery( MediaPickCallback callback, string title = "", string mime = "image/*" )`: prompts the user to select an image from Gallery/Photos. 106 | - This operation is **asynchronous**! After user selects an image or cancels the operation, the **callback** is called (on main thread). **MediaPickCallback** takes a *string* parameter which stores the path of the selected image, or *null* if nothing is selected 107 | - **title** determines the title of the image picker dialog on Android. Has no effect on iOS 108 | - **mime** filters the available images on Android. For example, to request a *JPEG* image from the user, mime can be set as "image/jpeg". Setting multiple mime types is not possible (in that case, you should leave mime as "image/\*"). Has no effect on iOS 109 | 110 | `NativeGallery.GetVideoFromGallery( MediaPickCallback callback, string title = "", string mime = "video/*" )`: prompts the user to select a video from Gallery/Photos. This function works similar to its *GetImageFromGallery* equivalent. 111 | 112 | `NativeGallery.GetAudioFromGallery( MediaPickCallback callback, string title = "", string mime = "audio/*" )`: prompts the user to select an audio file. This function works similar to its *GetImageFromGallery* equivalent. Works on Android only. 113 | 114 | `NativeGallery.GetMixedMediaFromGallery( MediaPickCallback callback, MediaType mediaTypes, string title = "" )`: prompts the user to select an image/video/audio file. This function is available on Android 19 and later and all iOS versions. Selecting audio files is not supported on iOS. 115 | - **mediaTypes** is the bitwise OR'ed media types that will be displayed in the file picker dialog (e.g. to pick an image or video, use `MediaType.Image | MediaType.Video`) 116 | 117 | `NativeGallery.GetImagesFromGallery( MediaPickMultipleCallback callback, string title = "", string mime = "image/*" )`: prompts the user to select one or more images from Gallery/Photos. **MediaPickMultipleCallback** takes a *string[]* parameter which stores the path(s) of the selected image(s)/video(s), or *null* if nothing is selected. Selecting multiple files from gallery is only available on *Android 18* and later and *iOS 14* and later. Call *CanSelectMultipleFilesFromGallery()* to see if this feature is available. 118 | 119 | `NativeGallery.GetVideosFromGallery( MediaPickMultipleCallback callback, string title = "", string mime = "video/*" )`: prompts the user to select one or more videos from Gallery/Photos. This function works similar to its *GetImagesFromGallery* equivalent. 120 | 121 | `NativeGallery.GetAudiosFromGallery( MediaPickMultipleCallback callback, string title = "", string mime = "audio/*" )`: prompts the user to select one or more audio files. This function works similar to its *GetImagesFromGallery* equivalent. Works on Android only. 122 | 123 | `NativeGallery.GetMixedMediasFromGallery( MediaPickMultipleCallback callback, MediaType mediaTypes, string title = "" )`: prompts the user to select one or more image/video/audio files. Selecting audio files is not supported on iOS. 124 | 125 | `NativeGallery.CanSelectMultipleFilesFromGallery()`: returns *true* if selecting multiple images/videos from Gallery/Photos is possible on this device. 126 | 127 | `NativeGallery.CanSelectMultipleMediaTypesFromGallery()`: returns *true* if *GetMixedMediaFromGallery*/*GetMixedMediasFromGallery* functions are supported on this device. 128 | 129 | `NativeGallery.IsMediaPickerBusy()`: returns *true* if the user is currently picking media from Gallery/Photos. In that case, another GetImageFromGallery, GetVideoFromGallery or GetAudioFromGallery request will simply be ignored. 130 | 131 | Most of these functions automatically call *NativeGallery.RequestPermissionAsync*. More details available below. 132 | 133 | ### C. Runtime Permissions 134 | 135 | Beginning with *6.0 Marshmallow*, Android apps must request runtime permissions before accessing certain services, similar to iOS. Note that NativeGallery doesn't require any permissions for picking images/videos from Photos on iOS 11+, picking images/videos from Gallery on Android 34+ and saving images/videos to Gallery on Android 29+, so no permission dialog will be shown in these cases and the permission functions will return *Permission.Granted*. 136 | 137 | There are two functions to handle permissions with this plugin: 138 | 139 | `bool NativeGallery.CheckPermission( PermissionType permissionType, MediaType mediaTypes )`: checks whether the app has access to Gallery/Photos or not. **PermissionType** can be either **Read** (for *GetImageFromGallery/GetVideoFromGallery* functions) or **Write** (for *SaveImageToGallery/SaveVideoToGallery* functions). 140 | - **mediaTypes** determines for which media type(s) we're checking the permission for. Has no effect on iOS 141 | 142 | `void NativeGallery.RequestPermissionAsync( PermissionCallback callback, PermissionType permissionType, MediaType mediaTypes )`: requests permission to access Gallery/Photos from the user and returns the result asynchronously. It is recommended to show a brief explanation before asking the permission so that user understands why the permission is needed and doesn't click Deny or worse, "Don't ask again". Note that the SaveImageToGallery/SaveVideoToGallery and GetImageFromGallery/GetVideoFromGallery functions call RequestPermissionAsync internally and execute only if the permission is granted. 143 | - **PermissionCallback** takes `NativeGallery.Permission permission` parameter 144 | 145 | **NativeGallery.Permission** is an enum that can take 3 values: 146 | - **Granted**: we have the permission to access Gallery/Photos 147 | - **ShouldAsk**: permission is denied but we can ask the user for permission once again. On Android, as long as the user doesn't select "Don't ask again" while denying the permission, ShouldAsk is returned 148 | - **Denied**: we don't have permission and we can't ask the user for permission. In this case, user has to give the permission from Settings. This happens when user denies the permission on iOS (can't request permission again on iOS), when user selects "Don't ask again" while denying the permission on Android or when user is not allowed to give that permission (parental controls etc.) 149 | 150 | `Task NativeGallery.RequestPermissionAsync( PermissionType permissionType, MediaType mediaTypes )`: Task-based overload of *RequestPermissionAsync*. 151 | 152 | `NativeGallery.OpenSettings()`: opens the settings for this app, from where the user can manually grant permission in case current permission state is *Permission.Denied* (on Android, the necessary permission is named *Storage* and on iOS, the necessary permission is named *Photos*). 153 | 154 | ### D. Utility Functions 155 | 156 | `NativeGallery.ImageProperties NativeGallery.GetImageProperties( string imagePath )`: returns an *ImageProperties* instance that holds the width, height, mime type and EXIF orientation information of an image file without creating a *Texture2D* object. Mime type will be *null*, if it can't be determined 157 | 158 | `NativeGallery.VideoProperties NativeGallery.GetVideoProperties( string videoPath )`: returns a *VideoProperties* instance that holds the width, height, duration (in milliseconds) and rotation information of a video file. To play a video in correct orientation, you should rotate it by *rotation* degrees clockwise. For a 90-degree or 270-degree rotated video, values of *width* and *height* should be swapped to get the display size of the video. 159 | 160 | `NativeGallery.MediaType NativeGallery.GetMediaTypeOfFile( string path )`: returns the media type of the file at the specified path: *Image*, *Video*, *Audio* or neither of these (if media type can't be determined) 161 | 162 | `Texture2D NativeGallery.LoadImageAtPath( string imagePath, int maxSize = -1, bool markTextureNonReadable = true, bool generateMipmaps = true, bool linearColorSpace = false )`: creates a Texture2D from the specified image file in correct orientation and returns it. Returns *null*, if something goes wrong. 163 | - **maxSize** determines the maximum size of the returned Texture2D in pixels. Larger textures will be down-scaled. If untouched, its value will be set to *SystemInfo.maxTextureSize*. It is recommended to set a proper maxSize for better performance 164 | - **markTextureNonReadable** marks the generated texture as non-readable for better memory usage. If you plan to modify the texture later (e.g. *GetPixels*/*SetPixels*), set its value to *false* 165 | - **generateMipmaps** determines whether texture should have mipmaps or not 166 | - **linearColorSpace** determines whether texture should be in linear color space or sRGB color space 167 | 168 | `async Task NativeGallery.LoadImageAtPathAsync( string imagePath, int maxSize = -1, bool markTextureNonReadable = true )`: asynchronous variant of *LoadImageAtPath*. Whether or not the returned Texture2D has mipmaps enabled depends on *UnityWebRequestTexture*'s implementation on the target Unity version. Note that it isn't possible to load multiple images simultaneously using this function. 169 | 170 | `Texture2D NativeGallery.GetVideoThumbnail( string videoPath, int maxSize = -1, double captureTimeInSeconds = -1.0, bool markTextureNonReadable = true, bool generateMipmaps = true, bool linearColorSpace = false )`: creates a Texture2D thumbnail from a video file and returns it. Returns *null*, if something goes wrong. 171 | - **maxSize** determines the maximum size of the returned Texture2D in pixels. Larger thumbnails will be down-scaled. If untouched, its value will be set to *SystemInfo.maxTextureSize*. It is recommended to set a proper maxSize for better performance 172 | - **captureTimeInSeconds** determines the frame of the video that the thumbnail is captured from. If untouched, OS will decide this value 173 | - **markTextureNonReadable** (see *LoadImageAtPath*) 174 | 175 | `async Task NativeGallery.GetVideoThumbnailAsync( string videoPath, int maxSize = -1, double captureTimeInSeconds = -1.0, bool markTextureNonReadable = true )`: asynchronous variant of *GetVideoThumbnail*. Whether or not the returned Texture2D has mipmaps enabled depends on *UnityWebRequestTexture*'s implementation on the target Unity version. Note that it isn't possible to generate multiple video thumbnails simultaneously using this function. 176 | 177 | ## EXAMPLE CODE 178 | 179 | The following code has three functions: 180 | - if you click the left one-third of the screen, it captures the screenshot of the game and saves it to Gallery/Photos 181 | - if you click the middle one-third of the screen, it picks an image from Gallery/Photos and puts it on a temporary quad that is placed in front of the camera 182 | - if you click the right one-third of the screen, it picks a video from Gallery/Photos and plays it 183 | 184 | ```csharp 185 | void Update() 186 | { 187 | if( Input.GetMouseButtonDown( 0 ) ) 188 | { 189 | if( Input.mousePosition.x < Screen.width / 3 ) 190 | { 191 | // Take a screenshot and save it to Gallery/Photos 192 | StartCoroutine( TakeScreenshotAndSave() ); 193 | } 194 | else 195 | { 196 | // Don't attempt to pick media from Gallery/Photos if 197 | // another media pick operation is already in progress 198 | if( NativeGallery.IsMediaPickerBusy() ) 199 | return; 200 | 201 | if( Input.mousePosition.x < Screen.width * 2 / 3 ) 202 | { 203 | // Pick a PNG image from Gallery/Photos 204 | // If the selected image's width and/or height is greater than 512px, down-scale the image 205 | PickImage( 512 ); 206 | } 207 | else 208 | { 209 | // Pick a video from Gallery/Photos 210 | PickVideo(); 211 | } 212 | } 213 | } 214 | } 215 | 216 | private IEnumerator TakeScreenshotAndSave() 217 | { 218 | yield return new WaitForEndOfFrame(); 219 | 220 | Texture2D ss = new Texture2D( Screen.width, Screen.height, TextureFormat.RGB24, false ); 221 | ss.ReadPixels( new Rect( 0, 0, Screen.width, Screen.height ), 0, 0 ); 222 | ss.Apply(); 223 | 224 | // Save the screenshot to Gallery/Photos 225 | NativeGallery.SaveImageToGallery( ss, "GalleryTest", "Image.png", ( success, path ) => Debug.Log( "Media save result: " + success + " " + path ) ); 226 | 227 | // To avoid memory leaks 228 | Destroy( ss ); 229 | } 230 | 231 | private void PickImage( int maxSize ) 232 | { 233 | NativeGallery.GetImageFromGallery( ( path ) => 234 | { 235 | Debug.Log( "Image path: " + path ); 236 | if( path != null ) 237 | { 238 | // Create Texture from selected image 239 | Texture2D texture = NativeGallery.LoadImageAtPath( path, maxSize ); 240 | if( texture == null ) 241 | { 242 | Debug.Log( "Couldn't load texture from " + path ); 243 | return; 244 | } 245 | 246 | // Assign texture to a temporary quad and destroy it after 5 seconds 247 | GameObject quad = GameObject.CreatePrimitive( PrimitiveType.Quad ); 248 | quad.transform.position = Camera.main.transform.position + Camera.main.transform.forward * 2.5f; 249 | quad.transform.forward = Camera.main.transform.forward; 250 | quad.transform.localScale = new Vector3( 1f, texture.height / (float) texture.width, 1f ); 251 | 252 | Material material = quad.GetComponent().material; 253 | if( !material.shader.isSupported ) // happens when Standard shader is not included in the build 254 | material.shader = Shader.Find( "Legacy Shaders/Diffuse" ); 255 | 256 | material.mainTexture = texture; 257 | 258 | Destroy( quad, 5f ); 259 | 260 | // If a procedural texture is not destroyed manually, 261 | // it will only be freed after a scene change 262 | Destroy( texture, 5f ); 263 | } 264 | } ); 265 | } 266 | 267 | private void PickVideo() 268 | { 269 | NativeGallery.GetVideoFromGallery( ( path ) => 270 | { 271 | Debug.Log( "Video path: " + path ); 272 | if( path != null ) 273 | { 274 | // Play the selected video 275 | Handheld.PlayFullScreenMovie( "file://" + path ); 276 | } 277 | }, "Select a video" ); 278 | } 279 | 280 | // Example code doesn't use this function but it is here for reference 281 | private void PickImageOrVideo() 282 | { 283 | if( NativeGallery.CanSelectMultipleMediaTypesFromGallery() ) 284 | { 285 | NativeGallery.GetMixedMediaFromGallery( ( path ) => 286 | { 287 | Debug.Log( "Media path: " + path ); 288 | if( path != null ) 289 | { 290 | // Determine if user has picked an image, video or neither of these 291 | switch( NativeGallery.GetMediaTypeOfFile( path ) ) 292 | { 293 | case NativeGallery.MediaType.Image: Debug.Log( "Picked image" ); break; 294 | case NativeGallery.MediaType.Video: Debug.Log( "Picked video" ); break; 295 | default: Debug.Log( "Probably picked something else" ); break; 296 | } 297 | } 298 | }, NativeGallery.MediaType.Image | NativeGallery.MediaType.Video, "Select an image or video" ); 299 | } 300 | } 301 | ``` 302 | -------------------------------------------------------------------------------- /.github/screenshots/1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yasirkula/UnityNativeGallery/e05fc5a14fe582e4938d2597fc1401ca3fceafb0/.github/screenshots/1.png -------------------------------------------------------------------------------- /.github/screenshots/2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yasirkula/UnityNativeGallery/e05fc5a14fe582e4938d2597fc1401ca3fceafb0/.github/screenshots/2.png -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Süleyman Yasir KULA 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /LICENSE.txt.meta: -------------------------------------------------------------------------------- 1 | fileFormatVersion: 2 2 | guid: cdacafaa3be33f3419753d3d2e4c1cbe 3 | TextScriptImporter: 4 | externalObjects: {} 5 | userData: 6 | assetBundleName: 7 | assetBundleVariant: 8 | -------------------------------------------------------------------------------- /Plugins.meta: -------------------------------------------------------------------------------- 1 | fileFormatVersion: 2 2 | guid: 0ac5665ccd0528b45a13ea71d631dc4b 3 | folderAsset: yes 4 | DefaultImporter: 5 | externalObjects: {} 6 | userData: 7 | assetBundleName: 8 | assetBundleVariant: 9 | -------------------------------------------------------------------------------- /Plugins/NativeGallery.meta: -------------------------------------------------------------------------------- 1 | fileFormatVersion: 2 2 | guid: 5e05ed2bddbccb94e9650efb5742e452 3 | folderAsset: yes 4 | timeCreated: 1518877529 5 | licenseType: Free 6 | DefaultImporter: 7 | userData: 8 | assetBundleName: 9 | assetBundleVariant: 10 | -------------------------------------------------------------------------------- /Plugins/NativeGallery/Android.meta: -------------------------------------------------------------------------------- 1 | fileFormatVersion: 2 2 | guid: 0a607dcda26e7614f86300c6ca717295 3 | folderAsset: yes 4 | timeCreated: 1498722617 5 | licenseType: Pro 6 | DefaultImporter: 7 | userData: 8 | assetBundleName: 9 | assetBundleVariant: 10 | -------------------------------------------------------------------------------- /Plugins/NativeGallery/Android/NGCallbackHelper.cs: -------------------------------------------------------------------------------- 1 | #if UNITY_EDITOR || UNITY_ANDROID 2 | using System; 3 | using UnityEngine; 4 | 5 | namespace NativeGalleryNamespace 6 | { 7 | public class NGCallbackHelper : MonoBehaviour 8 | { 9 | private bool autoDestroyWithCallback; 10 | private Action mainThreadAction = null; 11 | 12 | public static NGCallbackHelper Create( bool autoDestroyWithCallback ) 13 | { 14 | NGCallbackHelper result = new GameObject( "NGCallbackHelper" ).AddComponent(); 15 | result.autoDestroyWithCallback = autoDestroyWithCallback; 16 | DontDestroyOnLoad( result.gameObject ); 17 | return result; 18 | } 19 | 20 | public void CallOnMainThread( Action function ) 21 | { 22 | lock( this ) 23 | { 24 | mainThreadAction += function; 25 | } 26 | } 27 | 28 | private void Update() 29 | { 30 | if( mainThreadAction != null ) 31 | { 32 | try 33 | { 34 | Action temp; 35 | lock( this ) 36 | { 37 | temp = mainThreadAction; 38 | mainThreadAction = null; 39 | } 40 | 41 | temp(); 42 | } 43 | finally 44 | { 45 | if( autoDestroyWithCallback ) 46 | Destroy( gameObject ); 47 | } 48 | } 49 | } 50 | } 51 | } 52 | #endif -------------------------------------------------------------------------------- /Plugins/NativeGallery/Android/NGCallbackHelper.cs.meta: -------------------------------------------------------------------------------- 1 | fileFormatVersion: 2 2 | guid: 2d517fd0f2f85f24698df2775bee58e9 3 | timeCreated: 1544889149 4 | licenseType: Free 5 | MonoImporter: 6 | serializedVersion: 2 7 | defaultReferences: [] 8 | executionOrder: 0 9 | icon: {instanceID: 0} 10 | userData: 11 | assetBundleName: 12 | assetBundleVariant: 13 | -------------------------------------------------------------------------------- /Plugins/NativeGallery/Android/NGMediaReceiveCallbackAndroid.cs: -------------------------------------------------------------------------------- 1 | #if UNITY_EDITOR || UNITY_ANDROID 2 | using UnityEngine; 3 | 4 | namespace NativeGalleryNamespace 5 | { 6 | public class NGMediaReceiveCallbackAndroid : AndroidJavaProxy 7 | { 8 | private readonly NativeGallery.MediaPickCallback callback; 9 | private readonly NativeGallery.MediaPickMultipleCallback callbackMultiple; 10 | 11 | private readonly NGCallbackHelper callbackHelper; 12 | 13 | public NGMediaReceiveCallbackAndroid( NativeGallery.MediaPickCallback callback, NativeGallery.MediaPickMultipleCallback callbackMultiple ) : base( "com.yasirkula.unity.NativeGalleryMediaReceiver" ) 14 | { 15 | this.callback = callback; 16 | this.callbackMultiple = callbackMultiple; 17 | callbackHelper = NGCallbackHelper.Create( true ); 18 | } 19 | 20 | [UnityEngine.Scripting.Preserve] 21 | public void OnMediaReceived( string path ) 22 | { 23 | callbackHelper.CallOnMainThread( () => callback( !string.IsNullOrEmpty( path ) ? path : null ) ); 24 | } 25 | 26 | [UnityEngine.Scripting.Preserve] 27 | public void OnMultipleMediaReceived( string paths ) 28 | { 29 | string[] result = null; 30 | if( !string.IsNullOrEmpty( paths ) ) 31 | { 32 | string[] pathsSplit = paths.Split( '>' ); 33 | 34 | int validPathCount = 0; 35 | for( int i = 0; i < pathsSplit.Length; i++ ) 36 | { 37 | if( !string.IsNullOrEmpty( pathsSplit[i] ) ) 38 | validPathCount++; 39 | } 40 | 41 | if( validPathCount == 0 ) 42 | pathsSplit = new string[0]; 43 | else if( validPathCount != pathsSplit.Length ) 44 | { 45 | string[] validPaths = new string[validPathCount]; 46 | for( int i = 0, j = 0; i < pathsSplit.Length; i++ ) 47 | { 48 | if( !string.IsNullOrEmpty( pathsSplit[i] ) ) 49 | validPaths[j++] = pathsSplit[i]; 50 | } 51 | 52 | pathsSplit = validPaths; 53 | } 54 | 55 | result = pathsSplit; 56 | } 57 | 58 | callbackHelper.CallOnMainThread( () => callbackMultiple( ( result != null && result.Length > 0 ) ? result : null ) ); 59 | } 60 | } 61 | } 62 | #endif -------------------------------------------------------------------------------- /Plugins/NativeGallery/Android/NGMediaReceiveCallbackAndroid.cs.meta: -------------------------------------------------------------------------------- 1 | fileFormatVersion: 2 2 | guid: 4c18d702b07a63945968db47201b95c9 3 | timeCreated: 1519060539 4 | licenseType: Free 5 | MonoImporter: 6 | serializedVersion: 2 7 | defaultReferences: [] 8 | executionOrder: 0 9 | icon: {instanceID: 0} 10 | userData: 11 | assetBundleName: 12 | assetBundleVariant: 13 | -------------------------------------------------------------------------------- /Plugins/NativeGallery/Android/NGPermissionCallbackAndroid.cs: -------------------------------------------------------------------------------- 1 | #if UNITY_EDITOR || UNITY_ANDROID 2 | using UnityEngine; 3 | 4 | namespace NativeGalleryNamespace 5 | { 6 | public class NGPermissionCallbackAndroid : AndroidJavaProxy 7 | { 8 | private readonly NativeGallery.PermissionCallback callback; 9 | private readonly NGCallbackHelper callbackHelper; 10 | 11 | public NGPermissionCallbackAndroid( NativeGallery.PermissionCallback callback ) : base( "com.yasirkula.unity.NativeGalleryPermissionReceiver" ) 12 | { 13 | this.callback = callback; 14 | callbackHelper = NGCallbackHelper.Create( true ); 15 | } 16 | 17 | [UnityEngine.Scripting.Preserve] 18 | public void OnPermissionResult( int result ) 19 | { 20 | callbackHelper.CallOnMainThread( () => callback( (NativeGallery.Permission) result ) ); 21 | } 22 | } 23 | } 24 | #endif -------------------------------------------------------------------------------- /Plugins/NativeGallery/Android/NGPermissionCallbackAndroid.cs.meta: -------------------------------------------------------------------------------- 1 | fileFormatVersion: 2 2 | guid: a07afac614af1294d8e72a3c083be028 3 | timeCreated: 1519060539 4 | licenseType: Free 5 | MonoImporter: 6 | serializedVersion: 2 7 | defaultReferences: [] 8 | executionOrder: 0 9 | icon: {instanceID: 0} 10 | userData: 11 | assetBundleName: 12 | assetBundleVariant: 13 | -------------------------------------------------------------------------------- /Plugins/NativeGallery/Android/NativeGallery.aar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yasirkula/UnityNativeGallery/e05fc5a14fe582e4938d2597fc1401ca3fceafb0/Plugins/NativeGallery/Android/NativeGallery.aar -------------------------------------------------------------------------------- /Plugins/NativeGallery/Android/NativeGallery.aar.meta: -------------------------------------------------------------------------------- 1 | fileFormatVersion: 2 2 | guid: db4d55e1212537e4baa84cac66eb6645 3 | timeCreated: 1569764737 4 | licenseType: Free 5 | PluginImporter: 6 | serializedVersion: 2 7 | iconMap: {} 8 | executionOrder: {} 9 | isPreloaded: 0 10 | isOverridable: 0 11 | platformData: 12 | data: 13 | first: 14 | Android: Android 15 | second: 16 | enabled: 1 17 | settings: {} 18 | data: 19 | first: 20 | Any: 21 | second: 22 | enabled: 0 23 | settings: {} 24 | data: 25 | first: 26 | Editor: Editor 27 | second: 28 | enabled: 0 29 | settings: 30 | DefaultValueInitialized: true 31 | userData: 32 | assetBundleName: 33 | assetBundleVariant: 34 | -------------------------------------------------------------------------------- /Plugins/NativeGallery/Editor.meta: -------------------------------------------------------------------------------- 1 | fileFormatVersion: 2 2 | guid: 19fc6b8ce781591438a952d8aa9104f8 3 | folderAsset: yes 4 | timeCreated: 1521452097 5 | licenseType: Free 6 | DefaultImporter: 7 | userData: 8 | assetBundleName: 9 | assetBundleVariant: 10 | -------------------------------------------------------------------------------- /Plugins/NativeGallery/Editor/NGPostProcessBuild.cs: -------------------------------------------------------------------------------- 1 | using System.IO; 2 | using UnityEditor; 3 | using UnityEngine; 4 | #if UNITY_IOS 5 | using UnityEditor.Callbacks; 6 | using UnityEditor.iOS.Xcode; 7 | #endif 8 | 9 | namespace NativeGalleryNamespace 10 | { 11 | [System.Serializable] 12 | public class Settings 13 | { 14 | private const string SAVE_PATH = "ProjectSettings/NativeGallery.json"; 15 | 16 | public bool AutomatedSetup = true; 17 | public string PhotoLibraryUsageDescription = "The app requires access to Photos to interact with it."; 18 | public string PhotoLibraryAdditionsUsageDescription = "The app requires access to Photos to save media to it."; 19 | public bool DontAskLimitedPhotosPermissionAutomaticallyOnIos14 = true; // See: https://mackuba.eu/2020/07/07/photo-library-changes-ios-14/ 20 | 21 | private static Settings m_instance = null; 22 | public static Settings Instance 23 | { 24 | get 25 | { 26 | if( m_instance == null ) 27 | { 28 | try 29 | { 30 | if( File.Exists( SAVE_PATH ) ) 31 | m_instance = JsonUtility.FromJson( File.ReadAllText( SAVE_PATH ) ); 32 | else 33 | m_instance = new Settings(); 34 | } 35 | catch( System.Exception e ) 36 | { 37 | Debug.LogException( e ); 38 | m_instance = new Settings(); 39 | } 40 | } 41 | 42 | return m_instance; 43 | } 44 | } 45 | 46 | public void Save() 47 | { 48 | File.WriteAllText( SAVE_PATH, JsonUtility.ToJson( this, true ) ); 49 | } 50 | 51 | [SettingsProvider] 52 | public static SettingsProvider CreatePreferencesGUI() 53 | { 54 | return new SettingsProvider( "Project/yasirkula/Native Gallery", SettingsScope.Project ) 55 | { 56 | guiHandler = ( searchContext ) => PreferencesGUI(), 57 | keywords = new System.Collections.Generic.HashSet() { "Native", "Gallery", "Android", "iOS" } 58 | }; 59 | } 60 | 61 | public static void PreferencesGUI() 62 | { 63 | EditorGUI.BeginChangeCheck(); 64 | 65 | Instance.AutomatedSetup = EditorGUILayout.Toggle( "Automated Setup", Instance.AutomatedSetup ); 66 | 67 | EditorGUI.BeginDisabledGroup( !Instance.AutomatedSetup ); 68 | Instance.PhotoLibraryUsageDescription = EditorGUILayout.DelayedTextField( "Photo Library Usage Description", Instance.PhotoLibraryUsageDescription ); 69 | Instance.PhotoLibraryAdditionsUsageDescription = EditorGUILayout.DelayedTextField( "Photo Library Additions Usage Description", Instance.PhotoLibraryAdditionsUsageDescription ); 70 | Instance.DontAskLimitedPhotosPermissionAutomaticallyOnIos14 = EditorGUILayout.Toggle( new GUIContent( "Don't Ask Limited Photos Permission Automatically", "See: https://mackuba.eu/2020/07/07/photo-library-changes-ios-14/. It's recommended to keep this setting enabled" ), Instance.DontAskLimitedPhotosPermissionAutomaticallyOnIos14 ); 71 | EditorGUI.EndDisabledGroup(); 72 | 73 | if( EditorGUI.EndChangeCheck() ) 74 | Instance.Save(); 75 | } 76 | } 77 | 78 | public class NGPostProcessBuild 79 | { 80 | #if UNITY_IOS 81 | [PostProcessBuild( 1 )] 82 | public static void OnPostprocessBuild( BuildTarget target, string buildPath ) 83 | { 84 | if( !Settings.Instance.AutomatedSetup ) 85 | return; 86 | 87 | if( target == BuildTarget.iOS ) 88 | { 89 | string pbxProjectPath = PBXProject.GetPBXProjectPath( buildPath ); 90 | string plistPath = Path.Combine( buildPath, "Info.plist" ); 91 | 92 | PBXProject pbxProject = new PBXProject(); 93 | pbxProject.ReadFromFile( pbxProjectPath ); 94 | 95 | string targetGUID = pbxProject.GetUnityFrameworkTargetGuid(); 96 | pbxProject.AddFrameworkToProject( targetGUID, "PhotosUI.framework", true ); 97 | pbxProject.AddFrameworkToProject( targetGUID, "Photos.framework", false ); 98 | pbxProject.AddFrameworkToProject( targetGUID, "MobileCoreServices.framework", false ); 99 | pbxProject.AddFrameworkToProject( targetGUID, "ImageIO.framework", false ); 100 | 101 | File.WriteAllText( pbxProjectPath, pbxProject.WriteToString() ); 102 | 103 | PlistDocument plist = new PlistDocument(); 104 | plist.ReadFromString( File.ReadAllText( plistPath ) ); 105 | 106 | PlistElementDict rootDict = plist.root; 107 | if( !string.IsNullOrEmpty( Settings.Instance.PhotoLibraryUsageDescription ) ) 108 | rootDict.SetString( "NSPhotoLibraryUsageDescription", Settings.Instance.PhotoLibraryUsageDescription ); 109 | if( !string.IsNullOrEmpty( Settings.Instance.PhotoLibraryAdditionsUsageDescription ) ) 110 | rootDict.SetString( "NSPhotoLibraryAddUsageDescription", Settings.Instance.PhotoLibraryAdditionsUsageDescription ); 111 | if( Settings.Instance.DontAskLimitedPhotosPermissionAutomaticallyOnIos14 ) 112 | rootDict.SetBoolean( "PHPhotoLibraryPreventAutomaticLimitedAccessAlert", true ); 113 | 114 | File.WriteAllText( plistPath, plist.WriteToString() ); 115 | } 116 | } 117 | #endif 118 | } 119 | } -------------------------------------------------------------------------------- /Plugins/NativeGallery/Editor/NGPostProcessBuild.cs.meta: -------------------------------------------------------------------------------- 1 | fileFormatVersion: 2 2 | guid: dff1540cf22bfb749a2422f445cf9427 3 | timeCreated: 1521452119 4 | licenseType: Free 5 | MonoImporter: 6 | serializedVersion: 2 7 | defaultReferences: [] 8 | executionOrder: 0 9 | icon: {instanceID: 0} 10 | userData: 11 | assetBundleName: 12 | assetBundleVariant: 13 | -------------------------------------------------------------------------------- /Plugins/NativeGallery/Editor/NativeGallery.Editor.asmdef: -------------------------------------------------------------------------------- 1 | { 2 | "name": "NativeGallery.Editor", 3 | "references": [], 4 | "includePlatforms": [ 5 | "Editor" 6 | ], 7 | "excludePlatforms": [], 8 | "allowUnsafeCode": false, 9 | "overrideReferences": false, 10 | "precompiledReferences": [], 11 | "autoReferenced": true, 12 | "defineConstraints": [], 13 | "versionDefines": [], 14 | "noEngineReferences": false 15 | } -------------------------------------------------------------------------------- /Plugins/NativeGallery/Editor/NativeGallery.Editor.asmdef.meta: -------------------------------------------------------------------------------- 1 | fileFormatVersion: 2 2 | guid: 3dffc8e654f00c545a82d0a5274d51eb 3 | AssemblyDefinitionImporter: 4 | externalObjects: {} 5 | userData: 6 | assetBundleName: 7 | assetBundleVariant: 8 | -------------------------------------------------------------------------------- /Plugins/NativeGallery/NativeGallery.Runtime.asmdef: -------------------------------------------------------------------------------- 1 | { 2 | "name": "NativeGallery.Runtime" 3 | } 4 | -------------------------------------------------------------------------------- /Plugins/NativeGallery/NativeGallery.Runtime.asmdef.meta: -------------------------------------------------------------------------------- 1 | fileFormatVersion: 2 2 | guid: 6e5063adab271564ba0098a06a8cebda 3 | AssemblyDefinitionImporter: 4 | externalObjects: {} 5 | userData: 6 | assetBundleName: 7 | assetBundleVariant: 8 | -------------------------------------------------------------------------------- /Plugins/NativeGallery/NativeGallery.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Globalization; 3 | using System.IO; 4 | using UnityEngine; 5 | using System.Threading.Tasks; 6 | using UnityEngine.Networking; 7 | #if UNITY_ANDROID || UNITY_IOS 8 | using NativeGalleryNamespace; 9 | #endif 10 | using Object = UnityEngine.Object; 11 | 12 | public static class NativeGallery 13 | { 14 | public struct ImageProperties 15 | { 16 | public readonly int width; 17 | public readonly int height; 18 | public readonly string mimeType; 19 | public readonly ImageOrientation orientation; 20 | 21 | public ImageProperties( int width, int height, string mimeType, ImageOrientation orientation ) 22 | { 23 | this.width = width; 24 | this.height = height; 25 | this.mimeType = mimeType; 26 | this.orientation = orientation; 27 | } 28 | } 29 | 30 | public struct VideoProperties 31 | { 32 | public readonly int width; 33 | public readonly int height; 34 | public readonly long duration; 35 | public readonly float rotation; 36 | 37 | public VideoProperties( int width, int height, long duration, float rotation ) 38 | { 39 | this.width = width; 40 | this.height = height; 41 | this.duration = duration; 42 | this.rotation = rotation; 43 | } 44 | } 45 | 46 | public enum PermissionType { Read = 0, Write = 1 }; 47 | public enum Permission { Denied = 0, Granted = 1, ShouldAsk = 2 }; 48 | 49 | [Flags] 50 | public enum MediaType { Image = 1, Video = 2, Audio = 4 }; 51 | 52 | // EXIF orientation: http://sylvana.net/jpegcrop/exif_orientation.html (indices are reordered) 53 | public enum ImageOrientation { Unknown = -1, Normal = 0, Rotate90 = 1, Rotate180 = 2, Rotate270 = 3, FlipHorizontal = 4, Transpose = 5, FlipVertical = 6, Transverse = 7 }; 54 | 55 | public delegate void PermissionCallback( Permission permission ); 56 | public delegate void MediaSaveCallback( bool success, string path ); 57 | public delegate void MediaPickCallback( string path ); 58 | public delegate void MediaPickMultipleCallback( string[] paths ); 59 | 60 | #region Platform Specific Elements 61 | #if !UNITY_EDITOR && UNITY_ANDROID 62 | private static AndroidJavaClass m_ajc = null; 63 | private static AndroidJavaClass AJC 64 | { 65 | get 66 | { 67 | if( m_ajc == null ) 68 | m_ajc = new AndroidJavaClass( "com.yasirkula.unity.NativeGallery" ); 69 | 70 | return m_ajc; 71 | } 72 | } 73 | 74 | private static AndroidJavaObject m_context = null; 75 | private static AndroidJavaObject Context 76 | { 77 | get 78 | { 79 | if( m_context == null ) 80 | { 81 | using( AndroidJavaObject unityClass = new AndroidJavaClass( "com.unity3d.player.UnityPlayer" ) ) 82 | { 83 | m_context = unityClass.GetStatic( "currentActivity" ); 84 | } 85 | } 86 | 87 | return m_context; 88 | } 89 | } 90 | #elif !UNITY_EDITOR && UNITY_IOS 91 | [System.Runtime.InteropServices.DllImport( "__Internal" )] 92 | private static extern int _NativeGallery_CheckPermission( int readPermission, int permissionFreeMode ); 93 | 94 | [System.Runtime.InteropServices.DllImport( "__Internal" )] 95 | private static extern void _NativeGallery_RequestPermission( int readPermission, int permissionFreeMode ); 96 | 97 | [System.Runtime.InteropServices.DllImport( "__Internal" )] 98 | private static extern void _NativeGallery_ShowLimitedLibraryPicker(); 99 | 100 | [System.Runtime.InteropServices.DllImport( "__Internal" )] 101 | private static extern void _NativeGallery_OpenSettings(); 102 | 103 | [System.Runtime.InteropServices.DllImport( "__Internal" )] 104 | private static extern int _NativeGallery_CanPickMultipleMedia(); 105 | 106 | [System.Runtime.InteropServices.DllImport( "__Internal" )] 107 | private static extern int _NativeGallery_GetMediaTypeFromExtension( string extension ); 108 | 109 | [System.Runtime.InteropServices.DllImport( "__Internal" )] 110 | private static extern void _NativeGallery_ImageWriteToAlbum( string path, string album, int permissionFreeMode ); 111 | 112 | [System.Runtime.InteropServices.DllImport( "__Internal" )] 113 | private static extern void _NativeGallery_VideoWriteToAlbum( string path, string album, int permissionFreeMode ); 114 | 115 | [System.Runtime.InteropServices.DllImport( "__Internal" )] 116 | private static extern void _NativeGallery_PickMedia( string mediaSavePath, int mediaType, int permissionFreeMode, int selectionLimit ); 117 | 118 | [System.Runtime.InteropServices.DllImport( "__Internal" )] 119 | private static extern string _NativeGallery_GetImageProperties( string path ); 120 | 121 | [System.Runtime.InteropServices.DllImport( "__Internal" )] 122 | private static extern string _NativeGallery_GetVideoProperties( string path ); 123 | 124 | [System.Runtime.InteropServices.DllImport( "__Internal" )] 125 | private static extern string _NativeGallery_GetVideoThumbnail( string path, string thumbnailSavePath, int maxSize, double captureTimeInSeconds ); 126 | 127 | [System.Runtime.InteropServices.DllImport( "__Internal" )] 128 | private static extern string _NativeGallery_LoadImageAtPath( string path, string temporaryFilePath, int maxSize ); 129 | #endif 130 | 131 | #if !UNITY_EDITOR && ( UNITY_ANDROID || UNITY_IOS ) 132 | private static string m_temporaryImagePath = null; 133 | private static string TemporaryImagePath 134 | { 135 | get 136 | { 137 | if( m_temporaryImagePath == null ) 138 | { 139 | m_temporaryImagePath = Path.Combine( Application.temporaryCachePath, "tmpImg" ); 140 | Directory.CreateDirectory( Application.temporaryCachePath ); 141 | } 142 | 143 | return m_temporaryImagePath; 144 | } 145 | } 146 | 147 | private static string m_selectedMediaPath = null; 148 | private static string SelectedMediaPath 149 | { 150 | get 151 | { 152 | if( m_selectedMediaPath == null ) 153 | { 154 | m_selectedMediaPath = Path.Combine( Application.temporaryCachePath, "pickedMedia" ); 155 | Directory.CreateDirectory( Application.temporaryCachePath ); 156 | } 157 | 158 | return m_selectedMediaPath; 159 | } 160 | } 161 | #endif 162 | #endregion 163 | 164 | #region Runtime Permissions 165 | // PermissionFreeMode was initially planned to be a toggleable setting on iOS but it has its own issues when set to false, so its value is forced to true. 166 | // These issues are: 167 | // - Presented permission dialog will have a "Select Photos" option on iOS 14+ but clicking it will freeze and eventually crash the app (I'm guessing that 168 | // this is caused by how permissions are handled synchronously in NativeGallery) 169 | // - While saving images/videos to Photos, iOS 14+ users would see the "Select Photos" option (which is irrelevant in this context, hence confusing) and 170 | // the user must grant full Photos access in order to save the image/video to a custom album 171 | // The only downside of having PermissionFreeMode = true is that, on iOS 14+, images/videos will be saved to the default Photos album rather than the 172 | // provided custom album 173 | private const bool PermissionFreeMode = true; 174 | 175 | public static bool CheckPermission( PermissionType permissionType, MediaType mediaTypes ) 176 | { 177 | #if !UNITY_EDITOR && UNITY_ANDROID 178 | return AJC.CallStatic( "CheckPermission", Context, permissionType == PermissionType.Read, (int) mediaTypes ) == 1; 179 | #elif !UNITY_EDITOR && UNITY_IOS 180 | return ProcessPermission( (Permission) _NativeGallery_CheckPermission( permissionType == PermissionType.Read ? 1 : 0, PermissionFreeMode ? 1 : 0 ) ) == Permission.Granted; 181 | #else 182 | return true; 183 | #endif 184 | } 185 | 186 | public static void RequestPermissionAsync( PermissionCallback callback, PermissionType permissionType, MediaType mediaTypes ) 187 | { 188 | #if !UNITY_EDITOR && UNITY_ANDROID 189 | NGPermissionCallbackAndroid nativeCallback = new( callback ); 190 | AJC.CallStatic( "RequestPermission", Context, nativeCallback, permissionType == PermissionType.Read, (int) mediaTypes ); 191 | #elif !UNITY_EDITOR && UNITY_IOS 192 | NGPermissionCallbackiOS.Initialize( ( result ) => callback( ProcessPermission( result ) ) ); 193 | _NativeGallery_RequestPermission( permissionType == PermissionType.Read ? 1 : 0, PermissionFreeMode ? 1 : 0 ); 194 | #else 195 | callback( Permission.Granted ); 196 | #endif 197 | } 198 | 199 | public static Task RequestPermissionAsync( PermissionType permissionType, MediaType mediaTypes ) 200 | { 201 | TaskCompletionSource tcs = new TaskCompletionSource(); 202 | RequestPermissionAsync( ( permission ) => tcs.SetResult( permission ), permissionType, mediaTypes ); 203 | return tcs.Task; 204 | } 205 | 206 | private static Permission ProcessPermission( Permission permission ) 207 | { 208 | // result == 3: LimitedAccess permission on iOS, no need to handle it when PermissionFreeMode is set to true 209 | return ( PermissionFreeMode && (int) permission == 3 ) ? Permission.Granted : permission; 210 | } 211 | 212 | // This function isn't needed when PermissionFreeMode is set to true 213 | private static void TryExtendLimitedAccessPermission() 214 | { 215 | if( IsMediaPickerBusy() ) 216 | return; 217 | 218 | #if !UNITY_EDITOR && UNITY_IOS 219 | _NativeGallery_ShowLimitedLibraryPicker(); 220 | #endif 221 | } 222 | 223 | public static void OpenSettings() 224 | { 225 | #if !UNITY_EDITOR && UNITY_ANDROID 226 | AJC.CallStatic( "OpenSettings", Context ); 227 | #elif !UNITY_EDITOR && UNITY_IOS 228 | _NativeGallery_OpenSettings(); 229 | #endif 230 | } 231 | #endregion 232 | 233 | #region Save Functions 234 | public static void SaveImageToGallery( byte[] mediaBytes, string album, string filename, MediaSaveCallback callback = null ) 235 | { 236 | SaveToGallery( mediaBytes, album, filename, MediaType.Image, callback ); 237 | } 238 | 239 | public static void SaveImageToGallery( string existingMediaPath, string album, string filename, MediaSaveCallback callback = null ) 240 | { 241 | SaveToGallery( existingMediaPath, album, filename, MediaType.Image, callback ); 242 | } 243 | 244 | public static void SaveImageToGallery( Texture2D image, string album, string filename, MediaSaveCallback callback = null ) 245 | { 246 | if( image == null ) 247 | throw new ArgumentException( "Parameter 'image' is null!" ); 248 | 249 | if( filename.EndsWith( ".jpeg", StringComparison.OrdinalIgnoreCase ) || filename.EndsWith( ".jpg", StringComparison.OrdinalIgnoreCase ) ) 250 | SaveToGallery( GetTextureBytes( image, true ), album, filename, MediaType.Image, callback ); 251 | else if( filename.EndsWith( ".png", StringComparison.OrdinalIgnoreCase ) ) 252 | SaveToGallery( GetTextureBytes( image, false ), album, filename, MediaType.Image, callback ); 253 | else 254 | SaveToGallery( GetTextureBytes( image, false ), album, filename + ".png", MediaType.Image, callback ); 255 | } 256 | 257 | public static void SaveVideoToGallery( byte[] mediaBytes, string album, string filename, MediaSaveCallback callback = null ) 258 | { 259 | SaveToGallery( mediaBytes, album, filename, MediaType.Video, callback ); 260 | } 261 | 262 | public static void SaveVideoToGallery( string existingMediaPath, string album, string filename, MediaSaveCallback callback = null ) 263 | { 264 | SaveToGallery( existingMediaPath, album, filename, MediaType.Video, callback ); 265 | } 266 | 267 | private static void SaveAudioToGallery( byte[] mediaBytes, string album, string filename, MediaSaveCallback callback = null ) 268 | { 269 | SaveToGallery( mediaBytes, album, filename, MediaType.Audio, callback ); 270 | } 271 | 272 | private static void SaveAudioToGallery( string existingMediaPath, string album, string filename, MediaSaveCallback callback = null ) 273 | { 274 | SaveToGallery( existingMediaPath, album, filename, MediaType.Audio, callback ); 275 | } 276 | #endregion 277 | 278 | #region Load Functions 279 | public static bool CanSelectMultipleFilesFromGallery() 280 | { 281 | #if !UNITY_EDITOR && UNITY_ANDROID 282 | return AJC.CallStatic( "CanSelectMultipleMedia" ); 283 | #elif !UNITY_EDITOR && UNITY_IOS 284 | return _NativeGallery_CanPickMultipleMedia() == 1; 285 | #else 286 | return false; 287 | #endif 288 | } 289 | 290 | public static bool CanSelectMultipleMediaTypesFromGallery() 291 | { 292 | #if UNITY_EDITOR 293 | return true; 294 | #elif UNITY_ANDROID 295 | return AJC.CallStatic( "CanSelectMultipleMediaTypes" ); 296 | #elif UNITY_IOS 297 | return true; 298 | #else 299 | return false; 300 | #endif 301 | } 302 | 303 | public static void GetImageFromGallery( MediaPickCallback callback, string title = "", string mime = "image/*" ) 304 | { 305 | GetMediaFromGallery( callback, MediaType.Image, mime, title ); 306 | } 307 | 308 | public static void GetVideoFromGallery( MediaPickCallback callback, string title = "", string mime = "video/*" ) 309 | { 310 | GetMediaFromGallery( callback, MediaType.Video, mime, title ); 311 | } 312 | 313 | public static void GetAudioFromGallery( MediaPickCallback callback, string title = "", string mime = "audio/*" ) 314 | { 315 | GetMediaFromGallery( callback, MediaType.Audio, mime, title ); 316 | } 317 | 318 | public static void GetMixedMediaFromGallery( MediaPickCallback callback, MediaType mediaTypes, string title = "" ) 319 | { 320 | GetMediaFromGallery( callback, mediaTypes, "*/*", title ); 321 | } 322 | 323 | public static void GetImagesFromGallery( MediaPickMultipleCallback callback, string title = "", string mime = "image/*" ) 324 | { 325 | GetMultipleMediaFromGallery( callback, MediaType.Image, mime, title ); 326 | } 327 | 328 | public static void GetVideosFromGallery( MediaPickMultipleCallback callback, string title = "", string mime = "video/*" ) 329 | { 330 | GetMultipleMediaFromGallery( callback, MediaType.Video, mime, title ); 331 | } 332 | 333 | public static void GetAudiosFromGallery( MediaPickMultipleCallback callback, string title = "", string mime = "audio/*" ) 334 | { 335 | GetMultipleMediaFromGallery( callback, MediaType.Audio, mime, title ); 336 | } 337 | 338 | public static void GetMixedMediasFromGallery( MediaPickMultipleCallback callback, MediaType mediaTypes, string title = "" ) 339 | { 340 | GetMultipleMediaFromGallery( callback, mediaTypes, "*/*", title ); 341 | } 342 | 343 | public static bool IsMediaPickerBusy() 344 | { 345 | #if !UNITY_EDITOR && UNITY_IOS 346 | return NGMediaReceiveCallbackiOS.IsBusy; 347 | #else 348 | return false; 349 | #endif 350 | } 351 | 352 | public static MediaType GetMediaTypeOfFile( string path ) 353 | { 354 | if( string.IsNullOrEmpty( path ) ) 355 | return (MediaType) 0; 356 | 357 | string extension = Path.GetExtension( path ); 358 | if( string.IsNullOrEmpty( extension ) ) 359 | return (MediaType) 0; 360 | 361 | if( extension[0] == '.' ) 362 | { 363 | if( extension.Length == 1 ) 364 | return (MediaType) 0; 365 | 366 | extension = extension.Substring( 1 ); 367 | } 368 | 369 | #if UNITY_EDITOR 370 | extension = extension.ToLowerInvariant(); 371 | if( extension == "png" || extension == "jpg" || extension == "jpeg" || extension == "gif" || extension == "bmp" || extension == "tiff" ) 372 | return MediaType.Image; 373 | else if( extension == "mp4" || extension == "mov" || extension == "wav" || extension == "avi" ) 374 | return MediaType.Video; 375 | else if( extension == "mp3" || extension == "aac" || extension == "flac" ) 376 | return MediaType.Audio; 377 | 378 | return (MediaType) 0; 379 | #elif UNITY_ANDROID 380 | string mime = AJC.CallStatic( "GetMimeTypeFromExtension", extension.ToLowerInvariant() ); 381 | if( string.IsNullOrEmpty( mime ) ) 382 | return (MediaType) 0; 383 | else if( mime.StartsWith( "image/" ) ) 384 | return MediaType.Image; 385 | else if( mime.StartsWith( "video/" ) ) 386 | return MediaType.Video; 387 | else if( mime.StartsWith( "audio/" ) ) 388 | return MediaType.Audio; 389 | else 390 | return (MediaType) 0; 391 | #elif UNITY_IOS 392 | return (MediaType) _NativeGallery_GetMediaTypeFromExtension( extension.ToLowerInvariant() ); 393 | #else 394 | return (MediaType) 0; 395 | #endif 396 | } 397 | #endregion 398 | 399 | #region Internal Functions 400 | private static void SaveToGallery( byte[] mediaBytes, string album, string filename, MediaType mediaType, MediaSaveCallback callback ) 401 | { 402 | if( mediaBytes == null || mediaBytes.Length == 0 ) 403 | throw new ArgumentException( "Parameter 'mediaBytes' is null or empty!" ); 404 | 405 | if( album == null || album.Length == 0 ) 406 | throw new ArgumentException( "Parameter 'album' is null or empty!" ); 407 | 408 | if( filename == null || filename.Length == 0 ) 409 | throw new ArgumentException( "Parameter 'filename' is null or empty!" ); 410 | 411 | if( string.IsNullOrEmpty( Path.GetExtension( filename ) ) ) 412 | Debug.LogWarning( "'filename' doesn't have an extension, this might result in unexpected behaviour!" ); 413 | 414 | RequestPermissionAsync( ( permission ) => 415 | { 416 | if( permission != Permission.Granted ) 417 | { 418 | callback?.Invoke( false, null ); 419 | return; 420 | } 421 | 422 | string path = GetTemporarySavePath( filename ); 423 | #if UNITY_EDITOR 424 | Debug.Log( "SaveToGallery called successfully in the Editor" ); 425 | #else 426 | File.WriteAllBytes( path, mediaBytes ); 427 | #endif 428 | 429 | SaveToGalleryInternal( path, album, mediaType, callback ); 430 | }, PermissionType.Write, mediaType ); 431 | } 432 | 433 | private static void SaveToGallery( string existingMediaPath, string album, string filename, MediaType mediaType, MediaSaveCallback callback ) 434 | { 435 | if( !File.Exists( existingMediaPath ) ) 436 | throw new FileNotFoundException( "File not found at " + existingMediaPath ); 437 | 438 | if( album == null || album.Length == 0 ) 439 | throw new ArgumentException( "Parameter 'album' is null or empty!" ); 440 | 441 | if( filename == null || filename.Length == 0 ) 442 | throw new ArgumentException( "Parameter 'filename' is null or empty!" ); 443 | 444 | if( string.IsNullOrEmpty( Path.GetExtension( filename ) ) ) 445 | { 446 | string originalExtension = Path.GetExtension( existingMediaPath ); 447 | if( string.IsNullOrEmpty( originalExtension ) ) 448 | Debug.LogWarning( "'filename' doesn't have an extension, this might result in unexpected behaviour!" ); 449 | else 450 | filename += originalExtension; 451 | } 452 | 453 | RequestPermissionAsync( ( permission ) => 454 | { 455 | if( permission != Permission.Granted ) 456 | { 457 | callback?.Invoke( false, null ); 458 | return; 459 | } 460 | 461 | string path = GetTemporarySavePath( filename ); 462 | #if UNITY_EDITOR 463 | Debug.Log( "SaveToGallery called successfully in the Editor" ); 464 | #else 465 | File.Copy( existingMediaPath, path, true ); 466 | #endif 467 | 468 | SaveToGalleryInternal( path, album, mediaType, callback ); 469 | }, PermissionType.Write, mediaType ); 470 | } 471 | 472 | private static void SaveToGalleryInternal( string path, string album, MediaType mediaType, MediaSaveCallback callback ) 473 | { 474 | #if !UNITY_EDITOR && UNITY_ANDROID 475 | string savePath = AJC.CallStatic( "SaveMedia", Context, (int) mediaType, path, album ); 476 | 477 | File.Delete( path ); 478 | 479 | if( callback != null ) 480 | callback( !string.IsNullOrEmpty( savePath ), savePath ); 481 | #elif !UNITY_EDITOR && UNITY_IOS 482 | if( mediaType == MediaType.Audio ) 483 | { 484 | Debug.LogError( "Saving audio files is not supported on iOS" ); 485 | 486 | if( callback != null ) 487 | callback( false, null ); 488 | 489 | return; 490 | } 491 | 492 | Debug.Log( "Saving to Pictures: " + Path.GetFileName( path ) ); 493 | 494 | NGMediaSaveCallbackiOS.Initialize( callback ); 495 | if( mediaType == MediaType.Image ) 496 | _NativeGallery_ImageWriteToAlbum( path, album, PermissionFreeMode ? 1 : 0 ); 497 | else if( mediaType == MediaType.Video ) 498 | _NativeGallery_VideoWriteToAlbum( path, album, PermissionFreeMode ? 1 : 0 ); 499 | #else 500 | if( callback != null ) 501 | callback( true, null ); 502 | #endif 503 | } 504 | 505 | private static string GetTemporarySavePath( string filename ) 506 | { 507 | string saveDir = Path.Combine( Application.persistentDataPath, "NGallery" ); 508 | Directory.CreateDirectory( saveDir ); 509 | 510 | #if !UNITY_EDITOR && UNITY_IOS 511 | // Ensure a unique temporary filename on iOS: 512 | // iOS internally copies images/videos to Photos directory of the system, 513 | // but the process is async. The redundant file is deleted by objective-c code 514 | // automatically after the media is saved but while it is being saved, the file 515 | // should NOT be overwritten. Therefore, always ensure a unique filename on iOS 516 | string path = Path.Combine( saveDir, filename ); 517 | if( File.Exists( path ) ) 518 | { 519 | int fileIndex = 0; 520 | string filenameWithoutExtension = Path.GetFileNameWithoutExtension( filename ); 521 | string extension = Path.GetExtension( filename ); 522 | 523 | do 524 | { 525 | path = Path.Combine( saveDir, string.Concat( filenameWithoutExtension, ++fileIndex, extension ) ); 526 | } while( File.Exists( path ) ); 527 | } 528 | 529 | return path; 530 | #else 531 | return Path.Combine( saveDir, filename ); 532 | #endif 533 | } 534 | 535 | private static void GetMediaFromGallery( MediaPickCallback callback, MediaType mediaType, string mime, string title ) 536 | { 537 | RequestPermissionAsync( ( permission ) => 538 | { 539 | if( permission != Permission.Granted || IsMediaPickerBusy() ) 540 | { 541 | callback?.Invoke( null ); 542 | return; 543 | } 544 | 545 | #if UNITY_EDITOR 546 | System.Collections.Generic.List editorFilters = new System.Collections.Generic.List( 4 ); 547 | 548 | if( ( mediaType & MediaType.Image ) == MediaType.Image ) 549 | { 550 | editorFilters.Add( "Image files" ); 551 | editorFilters.Add( "png,jpg,jpeg" ); 552 | } 553 | 554 | if( ( mediaType & MediaType.Video ) == MediaType.Video ) 555 | { 556 | editorFilters.Add( "Video files" ); 557 | editorFilters.Add( "mp4,mov,webm,avi" ); 558 | } 559 | 560 | if( ( mediaType & MediaType.Audio ) == MediaType.Audio ) 561 | { 562 | editorFilters.Add( "Audio files" ); 563 | editorFilters.Add( "mp3,wav,aac,flac" ); 564 | } 565 | 566 | editorFilters.Add( "All files" ); 567 | editorFilters.Add( "*" ); 568 | 569 | string pickedFile = UnityEditor.EditorUtility.OpenFilePanelWithFilters( "Select file", "", editorFilters.ToArray() ); 570 | 571 | if( callback != null ) 572 | callback( pickedFile != "" ? pickedFile : null ); 573 | #elif UNITY_ANDROID 574 | AJC.CallStatic( "PickMedia", Context, new NGMediaReceiveCallbackAndroid( callback, null ), (int) mediaType, false, SelectedMediaPath, mime, title ); 575 | #elif UNITY_IOS 576 | if( mediaType == MediaType.Audio ) 577 | { 578 | Debug.LogError( "Picking audio files is not supported on iOS" ); 579 | 580 | if( callback != null ) // Selecting audio files is not supported on iOS 581 | callback( null ); 582 | } 583 | else 584 | { 585 | NGMediaReceiveCallbackiOS.Initialize( callback, null ); 586 | _NativeGallery_PickMedia( SelectedMediaPath, (int) ( mediaType & ~MediaType.Audio ), PermissionFreeMode ? 1 : 0, 1 ); 587 | } 588 | #else 589 | if( callback != null ) 590 | callback( null ); 591 | #endif 592 | }, PermissionType.Read, mediaType ); 593 | } 594 | 595 | private static void GetMultipleMediaFromGallery( MediaPickMultipleCallback callback, MediaType mediaType, string mime, string title ) 596 | { 597 | RequestPermissionAsync( ( permission ) => 598 | { 599 | if( permission != Permission.Granted || IsMediaPickerBusy() ) 600 | { 601 | callback?.Invoke( null ); 602 | return; 603 | } 604 | 605 | if( CanSelectMultipleFilesFromGallery() ) 606 | { 607 | #if !UNITY_EDITOR && UNITY_ANDROID 608 | AJC.CallStatic( "PickMedia", Context, new NGMediaReceiveCallbackAndroid( null, callback ), (int) mediaType, true, SelectedMediaPath, mime, title ); 609 | #elif !UNITY_EDITOR && UNITY_IOS 610 | if( mediaType == MediaType.Audio ) 611 | { 612 | Debug.LogError( "Picking audio files is not supported on iOS" ); 613 | 614 | if( callback != null ) // Selecting audio files is not supported on iOS 615 | callback( null ); 616 | } 617 | else 618 | { 619 | NGMediaReceiveCallbackiOS.Initialize( null, callback ); 620 | _NativeGallery_PickMedia( SelectedMediaPath, (int) ( mediaType & ~MediaType.Audio ), PermissionFreeMode ? 1 : 0, 0 ); 621 | } 622 | #else 623 | if( callback != null ) 624 | callback( null ); 625 | #endif 626 | } 627 | else if( callback != null ) 628 | callback( null ); 629 | }, PermissionType.Read, mediaType ); 630 | } 631 | 632 | private static byte[] GetTextureBytes( Texture2D texture, bool isJpeg ) 633 | { 634 | try 635 | { 636 | return isJpeg ? texture.EncodeToJPG( 100 ) : texture.EncodeToPNG(); 637 | } 638 | catch( UnityException ) 639 | { 640 | return GetTextureBytesFromCopy( texture, isJpeg ); 641 | } 642 | catch( ArgumentException ) 643 | { 644 | return GetTextureBytesFromCopy( texture, isJpeg ); 645 | } 646 | 647 | #pragma warning disable 0162 648 | return null; 649 | #pragma warning restore 0162 650 | } 651 | 652 | private static byte[] GetTextureBytesFromCopy( Texture2D texture, bool isJpeg ) 653 | { 654 | // Texture is marked as non-readable, create a readable copy and save it instead 655 | Debug.LogWarning( "Saving non-readable textures is slower than saving readable textures" ); 656 | 657 | Texture2D sourceTexReadable = null; 658 | RenderTexture rt = RenderTexture.GetTemporary( texture.width, texture.height ); 659 | RenderTexture activeRT = RenderTexture.active; 660 | 661 | try 662 | { 663 | Graphics.Blit( texture, rt ); 664 | RenderTexture.active = rt; 665 | 666 | sourceTexReadable = new Texture2D( texture.width, texture.height, isJpeg ? TextureFormat.RGB24 : TextureFormat.RGBA32, false ); 667 | sourceTexReadable.ReadPixels( new Rect( 0, 0, texture.width, texture.height ), 0, 0, false ); 668 | sourceTexReadable.Apply( false, false ); 669 | } 670 | catch( Exception e ) 671 | { 672 | Debug.LogException( e ); 673 | 674 | Object.DestroyImmediate( sourceTexReadable ); 675 | return null; 676 | } 677 | finally 678 | { 679 | RenderTexture.active = activeRT; 680 | RenderTexture.ReleaseTemporary( rt ); 681 | } 682 | 683 | try 684 | { 685 | return isJpeg ? sourceTexReadable.EncodeToJPG( 100 ) : sourceTexReadable.EncodeToPNG(); 686 | } 687 | catch( Exception e ) 688 | { 689 | Debug.LogException( e ); 690 | return null; 691 | } 692 | finally 693 | { 694 | Object.DestroyImmediate( sourceTexReadable ); 695 | } 696 | } 697 | 698 | #if UNITY_ANDROID 699 | private static async Task TryCallNativeAndroidFunctionOnSeparateThread( Func function ) 700 | { 701 | T result = default( T ); 702 | bool hasResult = false; 703 | 704 | await Task.Run( () => 705 | { 706 | if( AndroidJNI.AttachCurrentThread() != 0 ) 707 | Debug.LogWarning( "Couldn't attach JNI thread, calling native function on the main thread" ); 708 | else 709 | { 710 | try 711 | { 712 | result = function(); 713 | hasResult = true; 714 | } 715 | finally 716 | { 717 | AndroidJNI.DetachCurrentThread(); 718 | } 719 | } 720 | } ); 721 | 722 | return hasResult ? result : function(); 723 | } 724 | #endif 725 | #endregion 726 | 727 | #region Utility Functions 728 | public static Texture2D LoadImageAtPath( string imagePath, int maxSize = -1, bool markTextureNonReadable = true, bool generateMipmaps = true, bool linearColorSpace = false ) 729 | { 730 | if( string.IsNullOrEmpty( imagePath ) ) 731 | throw new ArgumentException( "Parameter 'imagePath' is null or empty!" ); 732 | 733 | if( !File.Exists( imagePath ) ) 734 | throw new FileNotFoundException( "File not found at " + imagePath ); 735 | 736 | if( maxSize <= 0 ) 737 | maxSize = SystemInfo.maxTextureSize; 738 | 739 | #if !UNITY_EDITOR && UNITY_ANDROID 740 | string loadPath = AJC.CallStatic( "LoadImageAtPath", Context, imagePath, TemporaryImagePath, maxSize ); 741 | #elif !UNITY_EDITOR && UNITY_IOS 742 | string loadPath = _NativeGallery_LoadImageAtPath( imagePath, TemporaryImagePath, maxSize ); 743 | #else 744 | string loadPath = imagePath; 745 | #endif 746 | 747 | string extension = Path.GetExtension( imagePath ).ToLowerInvariant(); 748 | TextureFormat format = ( extension == ".jpg" || extension == ".jpeg" ) ? TextureFormat.RGB24 : TextureFormat.RGBA32; 749 | 750 | Texture2D result = new Texture2D( 2, 2, format, generateMipmaps, linearColorSpace ); 751 | 752 | try 753 | { 754 | if( !result.LoadImage( File.ReadAllBytes( loadPath ), markTextureNonReadable ) ) 755 | { 756 | Debug.LogWarning( "Couldn't load image at path: " + loadPath ); 757 | 758 | Object.DestroyImmediate( result ); 759 | return null; 760 | } 761 | } 762 | catch( Exception e ) 763 | { 764 | Debug.LogException( e ); 765 | 766 | Object.DestroyImmediate( result ); 767 | return null; 768 | } 769 | finally 770 | { 771 | if( loadPath != imagePath ) 772 | { 773 | try 774 | { 775 | File.Delete( loadPath ); 776 | } 777 | catch { } 778 | } 779 | } 780 | 781 | return result; 782 | } 783 | 784 | public static async Task LoadImageAtPathAsync( string imagePath, int maxSize = -1, bool markTextureNonReadable = true ) 785 | { 786 | if( string.IsNullOrEmpty( imagePath ) ) 787 | throw new ArgumentException( "Parameter 'imagePath' is null or empty!" ); 788 | 789 | if( !File.Exists( imagePath ) ) 790 | throw new FileNotFoundException( "File not found at " + imagePath ); 791 | 792 | if( maxSize <= 0 ) 793 | maxSize = SystemInfo.maxTextureSize; 794 | 795 | #if !UNITY_EDITOR && UNITY_ANDROID 796 | string temporaryImagePath = TemporaryImagePath; // Must be accessed from main thread 797 | string loadPath = await TryCallNativeAndroidFunctionOnSeparateThread( () => AJC.CallStatic( "LoadImageAtPath", Context, imagePath, temporaryImagePath, maxSize ) ); 798 | #elif !UNITY_EDITOR && UNITY_IOS 799 | string temporaryImagePath = TemporaryImagePath; // Must be accessed from main thread 800 | string loadPath = await Task.Run( () => _NativeGallery_LoadImageAtPath( imagePath, temporaryImagePath, maxSize ) ); 801 | #else 802 | string loadPath = imagePath; 803 | #endif 804 | 805 | Texture2D result = null; 806 | 807 | using( UnityWebRequest www = UnityWebRequestTexture.GetTexture( "file://" + loadPath, markTextureNonReadable ) ) 808 | { 809 | UnityWebRequestAsyncOperation asyncOperation = www.SendWebRequest(); 810 | while( !asyncOperation.isDone ) 811 | await Task.Yield(); 812 | 813 | if( www.result != UnityWebRequest.Result.Success ) 814 | Debug.LogWarning( "Couldn't use UnityWebRequest to load image, falling back to LoadImage: " + www.error ); 815 | else 816 | result = DownloadHandlerTexture.GetContent( www ); 817 | } 818 | 819 | if( !result ) // Fallback to Texture2D.LoadImage if something goes wrong 820 | { 821 | string extension = Path.GetExtension( imagePath ).ToLowerInvariant(); 822 | TextureFormat format = ( extension == ".jpg" || extension == ".jpeg" ) ? TextureFormat.RGB24 : TextureFormat.RGBA32; 823 | 824 | result = new Texture2D( 2, 2, format, true, false ); 825 | 826 | try 827 | { 828 | if( !result.LoadImage( File.ReadAllBytes( loadPath ), markTextureNonReadable ) ) 829 | { 830 | Debug.LogWarning( "Couldn't load image at path: " + loadPath ); 831 | 832 | Object.DestroyImmediate( result ); 833 | return null; 834 | } 835 | } 836 | catch( Exception e ) 837 | { 838 | Debug.LogException( e ); 839 | 840 | Object.DestroyImmediate( result ); 841 | return null; 842 | } 843 | finally 844 | { 845 | if( loadPath != imagePath ) 846 | { 847 | try 848 | { 849 | File.Delete( loadPath ); 850 | } 851 | catch { } 852 | } 853 | } 854 | } 855 | 856 | return result; 857 | } 858 | 859 | public static Texture2D GetVideoThumbnail( string videoPath, int maxSize = -1, double captureTimeInSeconds = -1.0, bool markTextureNonReadable = true, bool generateMipmaps = true, bool linearColorSpace = false ) 860 | { 861 | if( maxSize <= 0 ) 862 | maxSize = SystemInfo.maxTextureSize; 863 | 864 | #if !UNITY_EDITOR && UNITY_ANDROID 865 | string thumbnailPath = AJC.CallStatic( "GetVideoThumbnail", Context, videoPath, TemporaryImagePath + ".png", false, maxSize, captureTimeInSeconds ); 866 | #elif !UNITY_EDITOR && UNITY_IOS 867 | string thumbnailPath = _NativeGallery_GetVideoThumbnail( videoPath, TemporaryImagePath + ".png", maxSize, captureTimeInSeconds ); 868 | #else 869 | string thumbnailPath = null; 870 | #endif 871 | 872 | if( !string.IsNullOrEmpty( thumbnailPath ) ) 873 | return LoadImageAtPath( thumbnailPath, maxSize, markTextureNonReadable, generateMipmaps, linearColorSpace ); 874 | else 875 | return null; 876 | } 877 | 878 | public static async Task GetVideoThumbnailAsync( string videoPath, int maxSize = -1, double captureTimeInSeconds = -1.0, bool markTextureNonReadable = true ) 879 | { 880 | if( maxSize <= 0 ) 881 | maxSize = SystemInfo.maxTextureSize; 882 | 883 | #if !UNITY_EDITOR && UNITY_ANDROID 884 | string temporaryImagePath = TemporaryImagePath; // Must be accessed from main thread 885 | string thumbnailPath = await TryCallNativeAndroidFunctionOnSeparateThread( () => AJC.CallStatic( "GetVideoThumbnail", Context, videoPath, temporaryImagePath + ".png", false, maxSize, captureTimeInSeconds ) ); 886 | #elif !UNITY_EDITOR && UNITY_IOS 887 | string temporaryImagePath = TemporaryImagePath; // Must be accessed from main thread 888 | string thumbnailPath = await Task.Run( () => _NativeGallery_GetVideoThumbnail( videoPath, temporaryImagePath + ".png", maxSize, captureTimeInSeconds ) ); 889 | #else 890 | string thumbnailPath = null; 891 | #endif 892 | 893 | if( !string.IsNullOrEmpty( thumbnailPath ) ) 894 | return await LoadImageAtPathAsync( thumbnailPath, maxSize, markTextureNonReadable ); 895 | else 896 | return null; 897 | } 898 | 899 | public static ImageProperties GetImageProperties( string imagePath ) 900 | { 901 | if( !File.Exists( imagePath ) ) 902 | throw new FileNotFoundException( "File not found at " + imagePath ); 903 | 904 | #if !UNITY_EDITOR && UNITY_ANDROID 905 | string value = AJC.CallStatic( "GetImageProperties", Context, imagePath ); 906 | #elif !UNITY_EDITOR && UNITY_IOS 907 | string value = _NativeGallery_GetImageProperties( imagePath ); 908 | #else 909 | string value = null; 910 | #endif 911 | 912 | int width = 0, height = 0; 913 | string mimeType = null; 914 | ImageOrientation orientation = ImageOrientation.Unknown; 915 | if( !string.IsNullOrEmpty( value ) ) 916 | { 917 | string[] properties = value.Split( '>' ); 918 | if( properties != null && properties.Length >= 4 ) 919 | { 920 | if( !int.TryParse( properties[0].Trim(), out width ) ) 921 | width = 0; 922 | if( !int.TryParse( properties[1].Trim(), out height ) ) 923 | height = 0; 924 | 925 | mimeType = properties[2].Trim(); 926 | if( mimeType.Length == 0 ) 927 | { 928 | string extension = Path.GetExtension( imagePath ).ToLowerInvariant(); 929 | if( extension == ".png" ) 930 | mimeType = "image/png"; 931 | else if( extension == ".jpg" || extension == ".jpeg" ) 932 | mimeType = "image/jpeg"; 933 | else if( extension == ".gif" ) 934 | mimeType = "image/gif"; 935 | else if( extension == ".bmp" ) 936 | mimeType = "image/bmp"; 937 | else 938 | mimeType = null; 939 | } 940 | 941 | int orientationInt; 942 | if( int.TryParse( properties[3].Trim(), out orientationInt ) ) 943 | orientation = (ImageOrientation) orientationInt; 944 | } 945 | } 946 | 947 | return new ImageProperties( width, height, mimeType, orientation ); 948 | } 949 | 950 | public static VideoProperties GetVideoProperties( string videoPath ) 951 | { 952 | if( !File.Exists( videoPath ) ) 953 | throw new FileNotFoundException( "File not found at " + videoPath ); 954 | 955 | #if !UNITY_EDITOR && UNITY_ANDROID 956 | string value = AJC.CallStatic( "GetVideoProperties", Context, videoPath ); 957 | #elif !UNITY_EDITOR && UNITY_IOS 958 | string value = _NativeGallery_GetVideoProperties( videoPath ); 959 | #else 960 | string value = null; 961 | #endif 962 | 963 | int width = 0, height = 0; 964 | long duration = 0L; 965 | float rotation = 0f; 966 | if( !string.IsNullOrEmpty( value ) ) 967 | { 968 | string[] properties = value.Split( '>' ); 969 | if( properties != null && properties.Length >= 4 ) 970 | { 971 | if( !int.TryParse( properties[0].Trim(), out width ) ) 972 | width = 0; 973 | if( !int.TryParse( properties[1].Trim(), out height ) ) 974 | height = 0; 975 | if( !long.TryParse( properties[2].Trim(), out duration ) ) 976 | duration = 0L; 977 | if( !float.TryParse( properties[3].Trim().Replace( ',', '.' ), NumberStyles.Float, CultureInfo.InvariantCulture, out rotation ) ) 978 | rotation = 0f; 979 | } 980 | } 981 | 982 | if( rotation == -90f ) 983 | rotation = 270f; 984 | 985 | return new VideoProperties( width, height, duration, rotation ); 986 | } 987 | #endregion 988 | } -------------------------------------------------------------------------------- /Plugins/NativeGallery/NativeGallery.cs.meta: -------------------------------------------------------------------------------- 1 | fileFormatVersion: 2 2 | guid: ce1403606c3629046a0147d3e705f7cc 3 | timeCreated: 1498722610 4 | licenseType: Pro 5 | MonoImporter: 6 | serializedVersion: 2 7 | defaultReferences: [] 8 | executionOrder: 0 9 | icon: {instanceID: 0} 10 | userData: 11 | assetBundleName: 12 | assetBundleVariant: 13 | -------------------------------------------------------------------------------- /Plugins/NativeGallery/README.txt: -------------------------------------------------------------------------------- 1 | = Native Gallery for Android & iOS (v1.9.1) = 2 | 3 | Documentation: https://github.com/yasirkula/UnityNativeGallery 4 | FAQ: https://github.com/yasirkula/UnityNativeGallery#faq 5 | Example code: https://github.com/yasirkula/UnityNativeGallery#example-code 6 | E-mail: yasirkula@gmail.com -------------------------------------------------------------------------------- /Plugins/NativeGallery/README.txt.meta: -------------------------------------------------------------------------------- 1 | fileFormatVersion: 2 2 | guid: be769f45b807c40459e5bafb18e887d6 3 | timeCreated: 1563308465 4 | licenseType: Free 5 | TextScriptImporter: 6 | userData: 7 | assetBundleName: 8 | assetBundleVariant: 9 | -------------------------------------------------------------------------------- /Plugins/NativeGallery/iOS.meta: -------------------------------------------------------------------------------- 1 | fileFormatVersion: 2 2 | guid: 9c623599351a41a4c84c20f73c9d8976 3 | folderAsset: yes 4 | timeCreated: 1498722622 5 | licenseType: Pro 6 | DefaultImporter: 7 | userData: 8 | assetBundleName: 9 | assetBundleVariant: 10 | -------------------------------------------------------------------------------- /Plugins/NativeGallery/iOS/NGMediaReceiveCallbackiOS.cs: -------------------------------------------------------------------------------- 1 | #if UNITY_EDITOR || UNITY_IOS 2 | using UnityEngine; 3 | 4 | namespace NativeGalleryNamespace 5 | { 6 | public class NGMediaReceiveCallbackiOS : MonoBehaviour 7 | { 8 | private static NGMediaReceiveCallbackiOS instance; 9 | 10 | private NativeGallery.MediaPickCallback callback; 11 | private NativeGallery.MediaPickMultipleCallback callbackMultiple; 12 | 13 | private float nextBusyCheckTime; 14 | 15 | public static bool IsBusy { get; private set; } 16 | 17 | [System.Runtime.InteropServices.DllImport( "__Internal" )] 18 | private static extern int _NativeGallery_IsMediaPickerBusy(); 19 | 20 | public static void Initialize( NativeGallery.MediaPickCallback callback, NativeGallery.MediaPickMultipleCallback callbackMultiple ) 21 | { 22 | if( IsBusy ) 23 | return; 24 | 25 | if( instance == null ) 26 | { 27 | instance = new GameObject( "NGMediaReceiveCallbackiOS" ).AddComponent(); 28 | DontDestroyOnLoad( instance.gameObject ); 29 | } 30 | 31 | instance.callback = callback; 32 | instance.callbackMultiple = callbackMultiple; 33 | 34 | instance.nextBusyCheckTime = Time.realtimeSinceStartup + 1f; 35 | IsBusy = true; 36 | } 37 | 38 | private void Update() 39 | { 40 | if( IsBusy ) 41 | { 42 | if( Time.realtimeSinceStartup >= nextBusyCheckTime ) 43 | { 44 | nextBusyCheckTime = Time.realtimeSinceStartup + 1f; 45 | 46 | if( _NativeGallery_IsMediaPickerBusy() == 0 ) 47 | { 48 | IsBusy = false; 49 | 50 | NativeGallery.MediaPickCallback _callback = callback; 51 | callback = null; 52 | 53 | NativeGallery.MediaPickMultipleCallback _callbackMultiple = callbackMultiple; 54 | callbackMultiple = null; 55 | 56 | if( _callback != null ) 57 | _callback( null ); 58 | 59 | if( _callbackMultiple != null ) 60 | _callbackMultiple( null ); 61 | } 62 | } 63 | } 64 | } 65 | 66 | [UnityEngine.Scripting.Preserve] 67 | public void OnMediaReceived( string path ) 68 | { 69 | IsBusy = false; 70 | 71 | if( string.IsNullOrEmpty( path ) ) 72 | path = null; 73 | 74 | NativeGallery.MediaPickCallback _callback = callback; 75 | callback = null; 76 | 77 | if( _callback != null ) 78 | _callback( path ); 79 | } 80 | 81 | [UnityEngine.Scripting.Preserve] 82 | public void OnMultipleMediaReceived( string paths ) 83 | { 84 | IsBusy = false; 85 | 86 | string[] _paths = SplitPaths( paths ); 87 | if( _paths != null && _paths.Length == 0 ) 88 | _paths = null; 89 | 90 | NativeGallery.MediaPickMultipleCallback _callbackMultiple = callbackMultiple; 91 | callbackMultiple = null; 92 | 93 | if( _callbackMultiple != null ) 94 | _callbackMultiple( _paths ); 95 | } 96 | 97 | private string[] SplitPaths( string paths ) 98 | { 99 | string[] result = null; 100 | if( !string.IsNullOrEmpty( paths ) ) 101 | { 102 | string[] pathsSplit = paths.Split( '>' ); 103 | 104 | int validPathCount = 0; 105 | for( int i = 0; i < pathsSplit.Length; i++ ) 106 | { 107 | if( !string.IsNullOrEmpty( pathsSplit[i] ) ) 108 | validPathCount++; 109 | } 110 | 111 | if( validPathCount == 0 ) 112 | pathsSplit = new string[0]; 113 | else if( validPathCount != pathsSplit.Length ) 114 | { 115 | string[] validPaths = new string[validPathCount]; 116 | for( int i = 0, j = 0; i < pathsSplit.Length; i++ ) 117 | { 118 | if( !string.IsNullOrEmpty( pathsSplit[i] ) ) 119 | validPaths[j++] = pathsSplit[i]; 120 | } 121 | 122 | pathsSplit = validPaths; 123 | } 124 | 125 | result = pathsSplit; 126 | } 127 | 128 | return result; 129 | } 130 | } 131 | } 132 | #endif -------------------------------------------------------------------------------- /Plugins/NativeGallery/iOS/NGMediaReceiveCallbackiOS.cs.meta: -------------------------------------------------------------------------------- 1 | fileFormatVersion: 2 2 | guid: 71fb861c149c2d1428544c601e52a33c 3 | timeCreated: 1519060539 4 | licenseType: Free 5 | MonoImporter: 6 | serializedVersion: 2 7 | defaultReferences: [] 8 | executionOrder: 0 9 | icon: {instanceID: 0} 10 | userData: 11 | assetBundleName: 12 | assetBundleVariant: 13 | -------------------------------------------------------------------------------- /Plugins/NativeGallery/iOS/NGMediaSaveCallbackiOS.cs: -------------------------------------------------------------------------------- 1 | #if UNITY_EDITOR || UNITY_IOS 2 | using UnityEngine; 3 | 4 | namespace NativeGalleryNamespace 5 | { 6 | public class NGMediaSaveCallbackiOS : MonoBehaviour 7 | { 8 | private static NGMediaSaveCallbackiOS instance; 9 | private NativeGallery.MediaSaveCallback callback; 10 | 11 | public static void Initialize( NativeGallery.MediaSaveCallback callback ) 12 | { 13 | if( instance == null ) 14 | { 15 | instance = new GameObject( "NGMediaSaveCallbackiOS" ).AddComponent(); 16 | DontDestroyOnLoad( instance.gameObject ); 17 | } 18 | else if( instance.callback != null ) 19 | instance.callback( false, null ); 20 | 21 | instance.callback = callback; 22 | } 23 | 24 | [UnityEngine.Scripting.Preserve] 25 | public void OnMediaSaveCompleted( string message ) 26 | { 27 | NativeGallery.MediaSaveCallback _callback = callback; 28 | callback = null; 29 | 30 | if( _callback != null ) 31 | _callback( true, null ); 32 | } 33 | 34 | [UnityEngine.Scripting.Preserve] 35 | public void OnMediaSaveFailed( string error ) 36 | { 37 | NativeGallery.MediaSaveCallback _callback = callback; 38 | callback = null; 39 | 40 | if( _callback != null ) 41 | _callback( false, null ); 42 | } 43 | } 44 | } 45 | #endif -------------------------------------------------------------------------------- /Plugins/NativeGallery/iOS/NGMediaSaveCallbackiOS.cs.meta: -------------------------------------------------------------------------------- 1 | fileFormatVersion: 2 2 | guid: 9cbb865d0913a0d47bb6d2eb3ad04c4f 3 | timeCreated: 1519060539 4 | licenseType: Free 5 | MonoImporter: 6 | serializedVersion: 2 7 | defaultReferences: [] 8 | executionOrder: 0 9 | icon: {instanceID: 0} 10 | userData: 11 | assetBundleName: 12 | assetBundleVariant: 13 | -------------------------------------------------------------------------------- /Plugins/NativeGallery/iOS/NGPermissionCallbackiOS.cs: -------------------------------------------------------------------------------- 1 | #if UNITY_EDITOR || UNITY_IOS 2 | using UnityEngine; 3 | 4 | namespace NativeGalleryNamespace 5 | { 6 | public class NGPermissionCallbackiOS : MonoBehaviour 7 | { 8 | private static NGPermissionCallbackiOS instance; 9 | private NativeGallery.PermissionCallback callback; 10 | 11 | public static void Initialize( NativeGallery.PermissionCallback callback ) 12 | { 13 | if( instance == null ) 14 | { 15 | instance = new GameObject( "NGPermissionCallbackiOS" ).AddComponent(); 16 | DontDestroyOnLoad( instance.gameObject ); 17 | } 18 | else if( instance.callback != null ) 19 | instance.callback( NativeGallery.Permission.ShouldAsk ); 20 | 21 | instance.callback = callback; 22 | } 23 | 24 | [UnityEngine.Scripting.Preserve] 25 | public void OnPermissionRequested( string message ) 26 | { 27 | NativeGallery.PermissionCallback _callback = callback; 28 | callback = null; 29 | 30 | if( _callback != null ) 31 | _callback( (NativeGallery.Permission) int.Parse( message ) ); 32 | } 33 | } 34 | } 35 | #endif -------------------------------------------------------------------------------- /Plugins/NativeGallery/iOS/NGPermissionCallbackiOS.cs.meta: -------------------------------------------------------------------------------- 1 | fileFormatVersion: 2 2 | guid: bc6d7fa0a99114a45b1a6800097c6eb1 3 | timeCreated: 1519060539 4 | licenseType: Free 5 | MonoImporter: 6 | serializedVersion: 2 7 | defaultReferences: [] 8 | executionOrder: 0 9 | icon: {instanceID: 0} 10 | userData: 11 | assetBundleName: 12 | assetBundleVariant: 13 | -------------------------------------------------------------------------------- /Plugins/NativeGallery/iOS/NativeGallery.mm: -------------------------------------------------------------------------------- 1 | #import 2 | #import 3 | #import 4 | #import 5 | #import 6 | #import 7 | 8 | extern UIViewController* UnityGetGLViewController(); 9 | 10 | #define CHECK_IOS_VERSION( version ) ([[[UIDevice currentDevice] systemVersion] compare:version options:NSNumericSearch] != NSOrderedAscending) 11 | 12 | @interface UNativeGallery:NSObject 13 | + (int)checkPermission:(BOOL)readPermission permissionFreeMode:(BOOL)permissionFreeMode; 14 | + (void)requestPermission:(BOOL)readPermission permissionFreeMode:(BOOL)permissionFreeMode; 15 | + (void)showLimitedLibraryPicker; 16 | + (void)openSettings; 17 | + (int)canPickMultipleMedia; 18 | + (void)saveMedia:(NSString *)path albumName:(NSString *)album isImage:(BOOL)isImage permissionFreeMode:(BOOL)permissionFreeMode; 19 | + (void)pickMedia:(int)mediaType savePath:(NSString *)mediaSavePath permissionFreeMode:(BOOL)permissionFreeMode selectionLimit:(int)selectionLimit; 20 | + (int)isMediaPickerBusy; 21 | + (int)getMediaTypeFromExtension:(NSString *)extension; 22 | + (char *)getImageProperties:(NSString *)path; 23 | + (char *)getVideoProperties:(NSString *)path; 24 | + (char *)getVideoThumbnail:(NSString *)path savePath:(NSString *)savePath maximumSize:(int)maximumSize captureTime:(double)captureTime; 25 | + (char *)loadImageAtPath:(NSString *)path tempFilePath:(NSString *)tempFilePath maximumSize:(int)maximumSize; 26 | @end 27 | 28 | @implementation UNativeGallery 29 | 30 | static NSString *pickedMediaSavePath; 31 | static UIImagePickerController *imagePicker; 32 | API_AVAILABLE(ios(14)) 33 | static PHPickerViewController *imagePickerNew; 34 | static int imagePickerState = 0; // 0 -> none, 1 -> showing (always in this state on iPad), 2 -> finished 35 | static BOOL simpleMediaPickMode; 36 | static BOOL pickingMultipleFiles = NO; 37 | 38 | + (int)checkPermission:(BOOL)readPermission permissionFreeMode:(BOOL)permissionFreeMode 39 | { 40 | // On iOS 11 and later, permission isn't mandatory to fetch media from Photos 41 | if( readPermission && permissionFreeMode && CHECK_IOS_VERSION( @"11.0" ) ) 42 | return 1; 43 | 44 | // Photos permissions has changed on iOS 14 45 | if( @available(iOS 14.0, *) ) 46 | { 47 | // Request ReadWrite permission in 2 cases: 48 | // 1) When attempting to pick media from Photos with PHPhotoLibrary (readPermission=true and permissionFreeMode=false) 49 | // 2) When attempting to write media to a specific album in Photos using PHPhotoLibrary (readPermission=false and permissionFreeMode=false) 50 | PHAuthorizationStatus status = [PHPhotoLibrary authorizationStatusForAccessLevel:( ( readPermission || !permissionFreeMode ) ? PHAccessLevelReadWrite : PHAccessLevelAddOnly )]; 51 | if( status == PHAuthorizationStatusAuthorized ) 52 | return 1; 53 | else if( status == PHAuthorizationStatusRestricted ) 54 | return 3; 55 | else if( status == PHAuthorizationStatusNotDetermined ) 56 | return 2; 57 | else 58 | return 0; 59 | } 60 | else 61 | { 62 | PHAuthorizationStatus status = [PHPhotoLibrary authorizationStatus]; 63 | if( status == PHAuthorizationStatusAuthorized ) 64 | return 1; 65 | else if( status == PHAuthorizationStatusNotDetermined ) 66 | return 2; 67 | else 68 | return 0; 69 | } 70 | } 71 | 72 | + (void)requestPermission:(BOOL)readPermission permissionFreeMode:(BOOL)permissionFreeMode 73 | { 74 | // On iOS 11 and later, permission isn't mandatory to fetch media from Photos 75 | if( readPermission && permissionFreeMode && CHECK_IOS_VERSION( @"11.0" ) ) 76 | UnitySendMessage( "NGPermissionCallbackiOS", "OnPermissionRequested", "1" ); 77 | else if( @available(iOS 14.0, *) ) 78 | { 79 | // Photos permissions has changed on iOS 14. There are 2 permission dialogs now: 80 | // - AddOnly permission dialog: has 2 options: "Allow" and "Don't Allow". This dialog grants permission for save operations only. Unfortunately, 81 | // saving media to a custom album isn't possible with this dialog, media can only be saved to the default Photos album 82 | // - ReadWrite permission dialog: has 3 options: "Allow Access to All Photos" (i.e. full permission), "Select Photos" (i.e. limited access) and 83 | // "Don't Allow". To be able to save media to a custom album, user must grant Full Photos permission. Thus, even when readPermission is false, 84 | // this dialog will be used if PermissionFreeMode is set to false. So, PermissionFreeMode determines whether or not saving to a custom album is supported 85 | PHAuthorizationStatus status = [PHPhotoLibrary authorizationStatusForAccessLevel:( readPermission ? PHAccessLevelReadWrite : PHAccessLevelAddOnly )]; 86 | if( status == PHAuthorizationStatusAuthorized ) 87 | UnitySendMessage( "NGPermissionCallbackiOS", "OnPermissionRequested", "1" ); 88 | else if( status == PHAuthorizationStatusRestricted ) 89 | UnitySendMessage( "NGPermissionCallbackiOS", "OnPermissionRequested", "3" ); 90 | else if( status == PHAuthorizationStatusNotDetermined ) 91 | { 92 | [PHPhotoLibrary requestAuthorizationForAccessLevel:( readPermission ? PHAccessLevelReadWrite : PHAccessLevelAddOnly ) handler:^( PHAuthorizationStatus status ) 93 | { 94 | if( status == PHAuthorizationStatusAuthorized ) 95 | UnitySendMessage( "NGPermissionCallbackiOS", "OnPermissionRequested", "1" ); 96 | else if( status == PHAuthorizationStatusRestricted ) 97 | UnitySendMessage( "NGPermissionCallbackiOS", "OnPermissionRequested", "3" ); 98 | else 99 | UnitySendMessage( "NGPermissionCallbackiOS", "OnPermissionRequested", "0" ); 100 | }]; 101 | } 102 | else 103 | UnitySendMessage( "NGPermissionCallbackiOS", "OnPermissionRequested", "0" ); 104 | } 105 | else 106 | { 107 | // Request permission using Photos framework: https://stackoverflow.com/a/32989022/2373034 108 | PHAuthorizationStatus status = [PHPhotoLibrary authorizationStatus]; 109 | if( status == PHAuthorizationStatusAuthorized ) 110 | UnitySendMessage( "NGPermissionCallbackiOS", "OnPermissionRequested", "1" ); 111 | else if( status == PHAuthorizationStatusNotDetermined ) 112 | { 113 | [PHPhotoLibrary requestAuthorization:^( PHAuthorizationStatus status ) 114 | { 115 | UnitySendMessage( "NGPermissionCallbackiOS", "OnPermissionRequested", ( status == PHAuthorizationStatusAuthorized ) ? "1" : "0" ); 116 | }]; 117 | } 118 | else 119 | UnitySendMessage( "NGPermissionCallbackiOS", "OnPermissionRequested", "0" ); 120 | } 121 | } 122 | 123 | // When Photos permission is set to restricted, allows user to change the permission or change the list of restricted images 124 | // It doesn't support a deterministic callback; for example there is a photoLibraryDidChange event but it won't be invoked if 125 | // user doesn't change the list of restricted images 126 | + (void)showLimitedLibraryPicker 127 | { 128 | if( @available(iOS 14.0, *) ) 129 | { 130 | PHAuthorizationStatus status = [PHPhotoLibrary authorizationStatusForAccessLevel:PHAccessLevelReadWrite]; 131 | if( status == PHAuthorizationStatusNotDetermined ) 132 | [self requestPermission:YES permissionFreeMode:NO]; 133 | else if( status == PHAuthorizationStatusRestricted ) 134 | [[PHPhotoLibrary sharedPhotoLibrary] presentLimitedLibraryPickerFromViewController:UnityGetGLViewController()]; 135 | } 136 | } 137 | 138 | // Credit: https://stackoverflow.com/a/25453667/2373034 139 | + (void)openSettings 140 | { 141 | [[UIApplication sharedApplication] openURL:[NSURL URLWithString:UIApplicationOpenSettingsURLString] options:@{} completionHandler:nil]; 142 | } 143 | 144 | + (int)canPickMultipleMedia 145 | { 146 | if( @available(iOS 14.0, *) ) 147 | return 1; 148 | else 149 | return 0; 150 | } 151 | 152 | // Credit: https://stackoverflow.com/a/39909129/2373034 153 | + (void)saveMedia:(NSString *)path albumName:(NSString *)album isImage:(BOOL)isImage permissionFreeMode:(BOOL)permissionFreeMode 154 | { 155 | // On iOS 14+, permission workflow has changed significantly with the addition of PHAuthorizationStatusRestricted permission. On those versions, 156 | // user must grant Full Photos permission to be able to save to a custom album. Hence, there are 2 workflows: 157 | // - If PermissionFreeMode is enabled, save the media directly to the default album (i.e. ignore 'album' parameter). This will present a simple 158 | // permission dialog stating "The app requires access to Photos to save media to it." and the "Selected Photos" permission won't be listed in the options 159 | // - Otherwise, the more complex "The app requires access to Photos to interact with it." permission dialog will be shown and if the user grants 160 | // Full Photos permission, only then the image will be saved to the specified album. If user selects "Selected Photos" permission, default album will be 161 | // used as fallback 162 | void (^saveToPhotosAlbum)() = ^void() 163 | { 164 | if( isImage ) 165 | { 166 | // Try preserving image metadata (essential for animated gif images) 167 | [[PHPhotoLibrary sharedPhotoLibrary] performChanges: 168 | ^{ 169 | [PHAssetChangeRequest creationRequestForAssetFromImageAtFileURL:[NSURL fileURLWithPath:path]]; 170 | } 171 | completionHandler:^( BOOL success, NSError *error ) 172 | { 173 | if( success ) 174 | { 175 | [[NSFileManager defaultManager] removeItemAtPath:path error:nil]; 176 | UnitySendMessage( "NGMediaSaveCallbackiOS", "OnMediaSaveCompleted", "" ); 177 | } 178 | else 179 | { 180 | NSLog( @"Error creating asset in default Photos album: %@", error ); 181 | 182 | UIImage *image = [UIImage imageWithContentsOfFile:path]; 183 | if( image != nil ) 184 | UIImageWriteToSavedPhotosAlbum( image, self, @selector(image:didFinishSavingWithError:contextInfo:), (__bridge_retained void *) path ); 185 | else 186 | { 187 | NSLog( @"Couldn't create UIImage from file at path: %@", path ); 188 | [[NSFileManager defaultManager] removeItemAtPath:path error:nil]; 189 | UnitySendMessage( "NGMediaSaveCallbackiOS", "OnMediaSaveFailed", "" ); 190 | } 191 | } 192 | }]; 193 | } 194 | else 195 | { 196 | if( UIVideoAtPathIsCompatibleWithSavedPhotosAlbum( path ) ) 197 | UISaveVideoAtPathToSavedPhotosAlbum( path, self, @selector(video:didFinishSavingWithError:contextInfo:), (__bridge_retained void *) path ); 198 | else 199 | { 200 | NSLog( @"Video at path isn't compatible with saved photos album: %@", path ); 201 | [[NSFileManager defaultManager] removeItemAtPath:path error:nil]; 202 | UnitySendMessage( "NGMediaSaveCallbackiOS", "OnMediaSaveFailed", "" ); 203 | } 204 | } 205 | }; 206 | 207 | void (^saveBlock)(PHAssetCollection *assetCollection) = ^void( PHAssetCollection *assetCollection ) 208 | { 209 | [[PHPhotoLibrary sharedPhotoLibrary] performChanges: 210 | ^{ 211 | PHAssetChangeRequest *assetChangeRequest; 212 | if( isImage ) 213 | assetChangeRequest = [PHAssetChangeRequest creationRequestForAssetFromImageAtFileURL:[NSURL fileURLWithPath:path]]; 214 | else 215 | assetChangeRequest = [PHAssetChangeRequest creationRequestForAssetFromVideoAtFileURL:[NSURL fileURLWithPath:path]]; 216 | 217 | PHAssetCollectionChangeRequest *assetCollectionChangeRequest = [PHAssetCollectionChangeRequest changeRequestForAssetCollection:assetCollection]; 218 | [assetCollectionChangeRequest addAssets:@[[assetChangeRequest placeholderForCreatedAsset]]]; 219 | 220 | } 221 | completionHandler:^( BOOL success, NSError *error ) 222 | { 223 | if( success ) 224 | { 225 | [[NSFileManager defaultManager] removeItemAtPath:path error:nil]; 226 | UnitySendMessage( "NGMediaSaveCallbackiOS", "OnMediaSaveCompleted", "" ); 227 | } 228 | else 229 | { 230 | NSLog( @"Error creating asset: %@", error ); 231 | saveToPhotosAlbum(); 232 | } 233 | }]; 234 | }; 235 | 236 | if( permissionFreeMode && CHECK_IOS_VERSION( @"14.0" ) ) 237 | saveToPhotosAlbum(); 238 | else 239 | { 240 | PHFetchOptions *fetchOptions = [[PHFetchOptions alloc] init]; 241 | fetchOptions.predicate = [NSPredicate predicateWithFormat:@"localizedTitle = %@", album]; 242 | PHFetchResult *fetchResult = [PHAssetCollection fetchAssetCollectionsWithType:PHAssetCollectionTypeAlbum subtype:PHAssetCollectionSubtypeAny options:fetchOptions]; 243 | if( fetchResult.count > 0 ) 244 | saveBlock( fetchResult.firstObject); 245 | else 246 | { 247 | __block PHObjectPlaceholder *albumPlaceholder; 248 | [[PHPhotoLibrary sharedPhotoLibrary] performChanges: 249 | ^{ 250 | PHAssetCollectionChangeRequest *changeRequest = [PHAssetCollectionChangeRequest creationRequestForAssetCollectionWithTitle:album]; 251 | albumPlaceholder = changeRequest.placeholderForCreatedAssetCollection; 252 | } 253 | completionHandler:^( BOOL success, NSError *error ) 254 | { 255 | if( success ) 256 | { 257 | PHFetchResult *fetchResult = [PHAssetCollection fetchAssetCollectionsWithLocalIdentifiers:@[albumPlaceholder.localIdentifier] options:nil]; 258 | if( fetchResult.count > 0 ) 259 | saveBlock( fetchResult.firstObject); 260 | else 261 | { 262 | NSLog( @"Error creating album: Album placeholder not found" ); 263 | saveToPhotosAlbum(); 264 | } 265 | } 266 | else 267 | { 268 | NSLog( @"Error creating album: %@", error ); 269 | saveToPhotosAlbum(); 270 | } 271 | }]; 272 | } 273 | } 274 | } 275 | 276 | + (void)image:(UIImage *)image didFinishSavingWithError:(NSError *)error contextInfo:(void *)contextInfo 277 | { 278 | NSString* path = (__bridge_transfer NSString *)(contextInfo); 279 | [[NSFileManager defaultManager] removeItemAtPath:path error:nil]; 280 | 281 | if( error == nil ) 282 | UnitySendMessage( "NGMediaSaveCallbackiOS", "OnMediaSaveCompleted", "" ); 283 | else 284 | { 285 | NSLog( @"Error saving image with UIImageWriteToSavedPhotosAlbum: %@", error ); 286 | UnitySendMessage( "NGMediaSaveCallbackiOS", "OnMediaSaveFailed", "" ); 287 | } 288 | } 289 | 290 | + (void)video:(NSString *)videoPath didFinishSavingWithError:(NSError *)error contextInfo:(void *)contextInfo 291 | { 292 | NSString* path = (__bridge_transfer NSString *)(contextInfo); 293 | [[NSFileManager defaultManager] removeItemAtPath:path error:nil]; 294 | 295 | if( error == nil ) 296 | UnitySendMessage( "NGMediaSaveCallbackiOS", "OnMediaSaveCompleted", "" ); 297 | else 298 | { 299 | NSLog( @"Error saving video with UISaveVideoAtPathToSavedPhotosAlbum: %@", error ); 300 | UnitySendMessage( "NGMediaSaveCallbackiOS", "OnMediaSaveFailed", "" ); 301 | } 302 | } 303 | 304 | // Credit: https://stackoverflow.com/a/10531752/2373034 305 | + (void)pickMedia:(int)mediaType savePath:(NSString *)mediaSavePath permissionFreeMode:(BOOL)permissionFreeMode selectionLimit:(int)selectionLimit 306 | { 307 | pickedMediaSavePath = mediaSavePath; 308 | imagePickerState = 1; 309 | simpleMediaPickMode = permissionFreeMode && CHECK_IOS_VERSION( @"11.0" ); 310 | 311 | if( @available(iOS 14.0, *) ) 312 | { 313 | // PHPickerViewController is used on iOS 14 314 | PHPickerConfiguration *config = simpleMediaPickMode ? [[PHPickerConfiguration alloc] init] : [[PHPickerConfiguration alloc] initWithPhotoLibrary:[PHPhotoLibrary sharedPhotoLibrary]]; 315 | config.preferredAssetRepresentationMode = PHPickerConfigurationAssetRepresentationModeCurrent; 316 | config.selectionLimit = selectionLimit; 317 | pickingMultipleFiles = selectionLimit != 1; 318 | 319 | // mediaType is a bitmask: 320 | // 1: image 321 | // 2: video 322 | // 4: audio (not supported) 323 | if( mediaType == 1 ) 324 | config.filter = [PHPickerFilter anyFilterMatchingSubfilters:[NSArray arrayWithObjects:[PHPickerFilter imagesFilter], [PHPickerFilter livePhotosFilter], nil]]; 325 | else if( mediaType == 2 ) 326 | config.filter = [PHPickerFilter videosFilter]; 327 | else 328 | config.filter = [PHPickerFilter anyFilterMatchingSubfilters:[NSArray arrayWithObjects:[PHPickerFilter imagesFilter], [PHPickerFilter livePhotosFilter], [PHPickerFilter videosFilter], nil]]; 329 | 330 | imagePickerNew = [[PHPickerViewController alloc] initWithConfiguration:config]; 331 | imagePickerNew.delegate = (id) self; 332 | [UnityGetGLViewController() presentViewController:imagePickerNew animated:YES completion:^{ imagePickerState = 0; }]; 333 | } 334 | else 335 | { 336 | // UIImagePickerController is used on previous versions 337 | imagePicker = [[UIImagePickerController alloc] init]; 338 | imagePicker.delegate = (id) self; 339 | imagePicker.allowsEditing = NO; 340 | imagePicker.sourceType = UIImagePickerControllerSourceTypePhotoLibrary; 341 | 342 | // mediaType is a bitmask: 343 | // 1: image 344 | // 2: video 345 | // 4: audio (not supported) 346 | if( mediaType == 1 ) 347 | imagePicker.mediaTypes = [NSArray arrayWithObjects:(NSString *)kUTTypeImage, (NSString *)kUTTypeLivePhoto, nil]; 348 | else if( mediaType == 2 ) 349 | imagePicker.mediaTypes = [NSArray arrayWithObjects:(NSString *)kUTTypeMovie, (NSString *)kUTTypeVideo, nil]; 350 | else 351 | imagePicker.mediaTypes = [NSArray arrayWithObjects:(NSString *)kUTTypeImage, (NSString *)kUTTypeLivePhoto, (NSString *)kUTTypeMovie, (NSString *)kUTTypeVideo, nil]; 352 | 353 | if( mediaType != 1 ) 354 | { 355 | // Don't compress picked videos if possible 356 | imagePicker.videoExportPreset = AVAssetExportPresetPassthrough; 357 | } 358 | 359 | UIViewController *rootViewController = UnityGetGLViewController(); 360 | if( UI_USER_INTERFACE_IDIOM() == UIUserInterfaceIdiomPad ) // iPad 361 | { 362 | imagePicker.modalPresentationStyle = UIModalPresentationPopover; 363 | UIPopoverPresentationController *popover = imagePicker.popoverPresentationController; 364 | if( popover != nil ) 365 | { 366 | popover.sourceView = rootViewController.view; 367 | popover.sourceRect = CGRectMake( rootViewController.view.frame.size.width / 2, rootViewController.view.frame.size.height / 2, 1, 1 ); 368 | popover.permittedArrowDirections = 0; 369 | } 370 | } 371 | 372 | [rootViewController presentViewController:imagePicker animated:YES completion:^{ imagePickerState = 0; }]; 373 | } 374 | } 375 | 376 | + (int)isMediaPickerBusy 377 | { 378 | if( imagePickerState == 2 ) 379 | return 1; 380 | 381 | if( imagePicker != nil ) 382 | { 383 | if( imagePickerState == 1 || [imagePicker presentingViewController] == UnityGetGLViewController() ) 384 | return 1; 385 | else 386 | { 387 | imagePicker = nil; 388 | return 0; 389 | } 390 | } 391 | else if( @available(iOS 14.0, *) ) 392 | { 393 | if( imagePickerNew == nil ) 394 | return 0; 395 | else if( imagePickerState == 1 || [imagePickerNew presentingViewController] == UnityGetGLViewController() ) 396 | return 1; 397 | else 398 | { 399 | imagePickerNew = nil; 400 | return 0; 401 | } 402 | } 403 | else 404 | return 0; 405 | } 406 | 407 | #pragma clang diagnostic push 408 | #pragma clang diagnostic ignored "-Wdeprecated-declarations" 409 | + (void)imagePickerController:(UIImagePickerController *)picker didFinishPickingMediaWithInfo:(NSDictionary *)info 410 | { 411 | NSString *resultPath = nil; 412 | 413 | if( [info[UIImagePickerControllerMediaType] isEqualToString:(NSString *)kUTTypeImage] ) 414 | { 415 | NSLog( @"Picked an image" ); 416 | 417 | // Try to obtain the raw data of the image (which allows picking gifs properly or preserving metadata) 418 | PHAsset *asset = nil; 419 | 420 | // Try fetching the source image via UIImagePickerControllerImageURL 421 | NSURL *mediaUrl = info[UIImagePickerControllerImageURL]; 422 | if( mediaUrl != nil ) 423 | { 424 | NSString *imagePath = [mediaUrl path]; 425 | if( imagePath != nil && [[NSFileManager defaultManager] fileExistsAtPath:imagePath] ) 426 | { 427 | NSError *error; 428 | NSString *newPath = [pickedMediaSavePath stringByAppendingPathExtension:[imagePath pathExtension]]; 429 | 430 | if( ![[NSFileManager defaultManager] fileExistsAtPath:newPath] || [[NSFileManager defaultManager] removeItemAtPath:newPath error:&error] ) 431 | { 432 | if( [[NSFileManager defaultManager] copyItemAtPath:imagePath toPath:newPath error:&error] ) 433 | { 434 | resultPath = newPath; 435 | NSLog( @"Copied source image from UIImagePickerControllerImageURL" ); 436 | } 437 | else 438 | NSLog( @"Error copying image: %@", error ); 439 | } 440 | else 441 | NSLog( @"Error deleting existing image: %@", error ); 442 | } 443 | } 444 | 445 | if( resultPath == nil ) 446 | asset = info[UIImagePickerControllerPHAsset]; 447 | 448 | if( resultPath == nil && !simpleMediaPickMode ) 449 | { 450 | if( asset == nil ) 451 | { 452 | mediaUrl = info[UIImagePickerControllerReferenceURL] ?: info[UIImagePickerControllerMediaURL]; 453 | if( mediaUrl != nil ) 454 | asset = [[PHAsset fetchAssetsWithALAssetURLs:[NSArray arrayWithObject:mediaUrl] options:nil] firstObject]; 455 | } 456 | 457 | resultPath = [self trySavePHAsset:asset atIndex:1]; 458 | } 459 | 460 | if( resultPath == nil ) 461 | { 462 | // Save image as PNG 463 | UIImage *image = info[UIImagePickerControllerOriginalImage]; 464 | if( image != nil ) 465 | { 466 | resultPath = [pickedMediaSavePath stringByAppendingPathExtension:@"png"]; 467 | if( ![self saveImageAsPNG:image toPath:resultPath] ) 468 | { 469 | NSLog( @"Error creating PNG image" ); 470 | resultPath = nil; 471 | } 472 | } 473 | else 474 | NSLog( @"Error fetching original image from picker" ); 475 | } 476 | } 477 | else if( [info[UIImagePickerControllerMediaType] isEqualToString:(NSString *)kUTTypeLivePhoto] ) 478 | { 479 | NSLog( @"Picked a live photo" ); 480 | 481 | // Save live photo as PNG 482 | UIImage *image = info[UIImagePickerControllerOriginalImage]; 483 | if( image != nil ) 484 | { 485 | resultPath = [pickedMediaSavePath stringByAppendingPathExtension:@"png"]; 486 | if( ![self saveImageAsPNG:image toPath:resultPath] ) 487 | { 488 | NSLog( @"Error creating PNG image" ); 489 | resultPath = nil; 490 | } 491 | } 492 | else 493 | NSLog( @"Error fetching live photo's still image from picker" ); 494 | } 495 | else 496 | { 497 | NSLog( @"Picked a video" ); 498 | 499 | NSURL *mediaUrl = info[UIImagePickerControllerMediaURL] ?: info[UIImagePickerControllerReferenceURL]; 500 | if( mediaUrl != nil ) 501 | { 502 | resultPath = [mediaUrl path]; 503 | 504 | // On iOS 13, picked file becomes unreachable as soon as the UIImagePickerController disappears, 505 | // in that case, copy the video to a temporary location 506 | if( @available(iOS 13.0, *) ) 507 | { 508 | NSError *error; 509 | NSString *newPath = [pickedMediaSavePath stringByAppendingPathExtension:[resultPath pathExtension]]; 510 | 511 | if( ![[NSFileManager defaultManager] fileExistsAtPath:newPath] || [[NSFileManager defaultManager] removeItemAtPath:newPath error:&error] ) 512 | { 513 | if( [[NSFileManager defaultManager] copyItemAtPath:resultPath toPath:newPath error:&error] ) 514 | resultPath = newPath; 515 | else 516 | { 517 | NSLog( @"Error copying video: %@", error ); 518 | resultPath = nil; 519 | } 520 | } 521 | else 522 | { 523 | NSLog( @"Error deleting existing video: %@", error ); 524 | resultPath = nil; 525 | } 526 | } 527 | } 528 | } 529 | 530 | imagePicker = nil; 531 | imagePickerState = 2; 532 | UnitySendMessage( "NGMediaReceiveCallbackiOS", "OnMediaReceived", [self getCString:resultPath] ); 533 | 534 | [picker dismissViewControllerAnimated:NO completion:nil]; 535 | } 536 | #pragma clang diagnostic pop 537 | 538 | // Credit: https://ikyle.me/blog/2020/phpickerviewcontroller 539 | +(void)picker:(PHPickerViewController *)picker didFinishPicking:(NSArray *)results API_AVAILABLE(ios(14)) 540 | { 541 | imagePickerNew = nil; 542 | imagePickerState = 2; 543 | 544 | [picker dismissViewControllerAnimated:NO completion:nil]; 545 | 546 | if( results != nil && [results count] > 0 ) 547 | { 548 | NSMutableArray *resultPaths = [NSMutableArray arrayWithCapacity:[results count]]; 549 | NSLock *arrayLock = [[NSLock alloc] init]; 550 | dispatch_group_t group = dispatch_group_create(); 551 | 552 | for( int i = 0; i < [results count]; i++ ) 553 | { 554 | PHPickerResult *result = results[i]; 555 | NSItemProvider *itemProvider = result.itemProvider; 556 | NSString *assetIdentifier = result.assetIdentifier; 557 | __block NSString *resultPath = nil; 558 | 559 | int j = i + 1; 560 | 561 | //NSLog( @"result: %@", result ); 562 | //NSLog( @"%@", result.assetIdentifier); 563 | //NSLog( @"%@", result.itemProvider); 564 | 565 | if( [itemProvider hasItemConformingToTypeIdentifier:(NSString *)kUTTypeImage] ) 566 | { 567 | NSLog( @"Picked an image" ); 568 | 569 | if( !simpleMediaPickMode && assetIdentifier != nil ) 570 | { 571 | PHAsset *asset = [[PHAsset fetchAssetsWithLocalIdentifiers:[NSArray arrayWithObject:assetIdentifier] options:nil] firstObject]; 572 | resultPath = [self trySavePHAsset:asset atIndex:j]; 573 | } 574 | 575 | if( resultPath != nil ) 576 | { 577 | [arrayLock lock]; 578 | [resultPaths addObject:resultPath]; 579 | [arrayLock unlock]; 580 | } 581 | else 582 | { 583 | dispatch_group_enter( group ); 584 | 585 | [itemProvider loadFileRepresentationForTypeIdentifier:(NSString *)kUTTypeImage completionHandler:^( NSURL *url, NSError *error ) 586 | { 587 | if( url != nil ) 588 | { 589 | // Copy the image to a temporary location because the returned image will be deleted by the OS after this callback is completed 590 | resultPath = [url path]; 591 | NSString *newPath = [[NSString stringWithFormat:@"%@%d", pickedMediaSavePath, j] stringByAppendingPathExtension:[resultPath pathExtension]]; 592 | 593 | if( ![[NSFileManager defaultManager] fileExistsAtPath:newPath] || [[NSFileManager defaultManager] removeItemAtPath:newPath error:&error] ) 594 | { 595 | if( [[NSFileManager defaultManager] copyItemAtPath:resultPath toPath:newPath error:&error]) 596 | resultPath = newPath; 597 | else 598 | { 599 | NSLog( @"Error copying image: %@", error ); 600 | resultPath = nil; 601 | } 602 | } 603 | else 604 | { 605 | NSLog( @"Error deleting existing image: %@", error ); 606 | resultPath = nil; 607 | } 608 | } 609 | else 610 | NSLog( @"Error getting the picked image's path: %@", error ); 611 | 612 | if( resultPath != nil ) 613 | { 614 | [arrayLock lock]; 615 | [resultPaths addObject:resultPath]; 616 | [arrayLock unlock]; 617 | } 618 | else 619 | { 620 | if( [itemProvider canLoadObjectOfClass:[UIImage class]] ) 621 | { 622 | dispatch_group_enter( group ); 623 | 624 | [itemProvider loadObjectOfClass:[UIImage class] completionHandler:^( __kindof id object, NSError *error ) 625 | { 626 | if( object != nil && [object isKindOfClass:[UIImage class]] ) 627 | { 628 | resultPath = [[NSString stringWithFormat:@"%@%d", pickedMediaSavePath, j] stringByAppendingPathExtension:@"png"]; 629 | if( ![self saveImageAsPNG:(UIImage *)object toPath:resultPath] ) 630 | { 631 | NSLog( @"Error creating PNG image" ); 632 | resultPath = nil; 633 | } 634 | } 635 | else 636 | NSLog( @"Error generating UIImage from picked image: %@", error ); 637 | 638 | [arrayLock lock]; 639 | [resultPaths addObject:( resultPath != nil ? resultPath : @"" )]; 640 | [arrayLock unlock]; 641 | 642 | dispatch_group_leave( group ); 643 | }]; 644 | } 645 | else 646 | { 647 | NSLog( @"Can't generate UIImage from picked image" ); 648 | 649 | [arrayLock lock]; 650 | [resultPaths addObject:@""]; 651 | [arrayLock unlock]; 652 | } 653 | } 654 | 655 | dispatch_group_leave( group ); 656 | }]; 657 | } 658 | } 659 | else if( [itemProvider hasItemConformingToTypeIdentifier:(NSString *)kUTTypeLivePhoto] ) 660 | { 661 | NSLog( @"Picked a live photo" ); 662 | 663 | if( [itemProvider canLoadObjectOfClass:[UIImage class]] ) 664 | { 665 | dispatch_group_enter( group ); 666 | 667 | [itemProvider loadObjectOfClass:[UIImage class] completionHandler:^( __kindof id object, NSError *error ) 668 | { 669 | if( object != nil && [object isKindOfClass:[UIImage class]] ) 670 | { 671 | resultPath = [[NSString stringWithFormat:@"%@%d", pickedMediaSavePath, j] stringByAppendingPathExtension:@"png"]; 672 | if( ![self saveImageAsPNG:(UIImage *)object toPath:resultPath] ) 673 | { 674 | NSLog( @"Error creating PNG image" ); 675 | resultPath = nil; 676 | } 677 | } 678 | else 679 | NSLog( @"Error generating UIImage from picked live photo: %@", error ); 680 | 681 | [arrayLock lock]; 682 | [resultPaths addObject:( resultPath != nil ? resultPath : @"" )]; 683 | [arrayLock unlock]; 684 | 685 | dispatch_group_leave( group ); 686 | }]; 687 | } 688 | else if( [itemProvider canLoadObjectOfClass:[PHLivePhoto class]] ) 689 | { 690 | dispatch_group_enter( group ); 691 | 692 | [itemProvider loadObjectOfClass:[PHLivePhoto class] completionHandler:^( __kindof id object, NSError *error ) 693 | { 694 | if( object != nil && [object isKindOfClass:[PHLivePhoto class]] ) 695 | { 696 | // Extract image data from live photo 697 | // Credit: https://stackoverflow.com/a/41341675/2373034 698 | NSArray* livePhotoResources = [PHAssetResource assetResourcesForLivePhoto:(PHLivePhoto *)object]; 699 | 700 | PHAssetResource *livePhotoImage = nil; 701 | for( int k = 0; k < [livePhotoResources count]; k++ ) 702 | { 703 | if( livePhotoResources[k].type == PHAssetResourceTypePhoto ) 704 | { 705 | livePhotoImage = livePhotoResources[k]; 706 | break; 707 | } 708 | } 709 | 710 | if( livePhotoImage == nil ) 711 | { 712 | NSLog( @"Error extracting image data from live photo" ); 713 | 714 | [arrayLock lock]; 715 | [resultPaths addObject:@""]; 716 | [arrayLock unlock]; 717 | } 718 | else 719 | { 720 | dispatch_group_enter( group ); 721 | 722 | NSString *originalFilename = livePhotoImage.originalFilename; 723 | if( originalFilename == nil || [originalFilename length] == 0 ) 724 | resultPath = [NSString stringWithFormat:@"%@%d", pickedMediaSavePath, j]; 725 | else 726 | resultPath = [[NSString stringWithFormat:@"%@%d", pickedMediaSavePath, j] stringByAppendingPathExtension:[originalFilename pathExtension]]; 727 | 728 | [[PHAssetResourceManager defaultManager] writeDataForAssetResource:livePhotoImage toFile:[NSURL fileURLWithPath:resultPath] options:nil completionHandler:^( NSError * _Nullable error2 ) 729 | { 730 | if( error2 != nil ) 731 | { 732 | NSLog( @"Error saving image data from live photo: %@", error2 ); 733 | resultPath = nil; 734 | } 735 | 736 | [arrayLock lock]; 737 | [resultPaths addObject:( resultPath != nil ? resultPath : @"" )]; 738 | [arrayLock unlock]; 739 | 740 | dispatch_group_leave( group ); 741 | }]; 742 | } 743 | } 744 | else 745 | { 746 | NSLog( @"Error generating PHLivePhoto from picked live photo: %@", error ); 747 | 748 | [arrayLock lock]; 749 | [resultPaths addObject:@""]; 750 | [arrayLock unlock]; 751 | } 752 | 753 | dispatch_group_leave( group ); 754 | }]; 755 | } 756 | else 757 | { 758 | NSLog( @"Can't convert picked live photo to still image" ); 759 | 760 | [arrayLock lock]; 761 | [resultPaths addObject:@""]; 762 | [arrayLock unlock]; 763 | } 764 | } 765 | else if( [itemProvider hasItemConformingToTypeIdentifier:(NSString *)kUTTypeMovie] || [itemProvider hasItemConformingToTypeIdentifier:(NSString *)kUTTypeVideo] ) 766 | { 767 | NSLog( @"Picked a video" ); 768 | 769 | // Get the video file's path 770 | dispatch_group_enter( group ); 771 | 772 | [itemProvider loadFileRepresentationForTypeIdentifier:([itemProvider hasItemConformingToTypeIdentifier:(NSString *)kUTTypeMovie] ? (NSString *)kUTTypeMovie : (NSString *)kUTTypeVideo) completionHandler:^( NSURL *url, NSError *error ) 773 | { 774 | if( url != nil ) 775 | { 776 | // Copy the video to a temporary location because the returned video will be deleted by the OS after this callback is completed 777 | resultPath = [url path]; 778 | NSString *newPath = [[NSString stringWithFormat:@"%@%d", pickedMediaSavePath, j] stringByAppendingPathExtension:[resultPath pathExtension]]; 779 | 780 | if( ![[NSFileManager defaultManager] fileExistsAtPath:newPath] || [[NSFileManager defaultManager] removeItemAtPath:newPath error:&error] ) 781 | { 782 | if( [[NSFileManager defaultManager] copyItemAtPath:resultPath toPath:newPath error:&error]) 783 | resultPath = newPath; 784 | else 785 | { 786 | NSLog( @"Error copying video: %@", error ); 787 | resultPath = nil; 788 | } 789 | } 790 | else 791 | { 792 | NSLog( @"Error deleting existing video: %@", error ); 793 | resultPath = nil; 794 | } 795 | } 796 | else 797 | NSLog( @"Error getting the picked video's path: %@", error ); 798 | 799 | [arrayLock lock]; 800 | [resultPaths addObject:( resultPath != nil ? resultPath : @"" )]; 801 | [arrayLock unlock]; 802 | 803 | dispatch_group_leave( group ); 804 | }]; 805 | } 806 | else 807 | { 808 | // Unknown media type picked? 809 | NSLog( @"Couldn't determine type of picked media: %@", itemProvider ); 810 | 811 | [arrayLock lock]; 812 | [resultPaths addObject:@""]; 813 | [arrayLock unlock]; 814 | } 815 | } 816 | 817 | dispatch_group_notify( group, dispatch_get_main_queue(), 818 | ^{ 819 | if( !pickingMultipleFiles ) 820 | UnitySendMessage( "NGMediaReceiveCallbackiOS", "OnMediaReceived", [self getCString:resultPaths[0]] ); 821 | else 822 | UnitySendMessage( "NGMediaReceiveCallbackiOS", "OnMultipleMediaReceived", [self getCString:[resultPaths componentsJoinedByString:@">"]] ); 823 | }); 824 | } 825 | else 826 | { 827 | NSLog( @"No media picked" ); 828 | 829 | if( !pickingMultipleFiles ) 830 | UnitySendMessage( "NGMediaReceiveCallbackiOS", "OnMediaReceived", "" ); 831 | else 832 | UnitySendMessage( "NGMediaReceiveCallbackiOS", "OnMultipleMediaReceived", "" ); 833 | } 834 | } 835 | 836 | + (void)imagePickerControllerDidCancel:(UIImagePickerController *)picker 837 | { 838 | NSLog( @"UIImagePickerController cancelled" ); 839 | 840 | imagePicker = nil; 841 | UnitySendMessage( "NGMediaReceiveCallbackiOS", "OnMediaReceived", "" ); 842 | 843 | [picker dismissViewControllerAnimated:NO completion:nil]; 844 | } 845 | 846 | + (NSString *)trySavePHAsset:(PHAsset *)asset atIndex:(int)filenameIndex 847 | { 848 | if( asset == nil ) 849 | return nil; 850 | 851 | __block NSString *resultPath = nil; 852 | 853 | PHImageRequestOptions *options = [[PHImageRequestOptions alloc] init]; 854 | options.synchronous = YES; 855 | options.version = PHImageRequestOptionsVersionCurrent; 856 | 857 | if( @available(iOS 13.0, *) ) 858 | { 859 | [[PHImageManager defaultManager] requestImageDataAndOrientationForAsset:asset options:options resultHandler:^( NSData *imageData, NSString *dataUTI, CGImagePropertyOrientation orientation, NSDictionary *imageInfo ) 860 | { 861 | if( imageData != nil ) 862 | resultPath = [self trySaveSourceImage:imageData withInfo:imageInfo atIndex:filenameIndex]; 863 | else 864 | NSLog( @"Couldn't fetch raw image data" ); 865 | }]; 866 | } 867 | else 868 | { 869 | [[PHImageManager defaultManager] requestImageDataForAsset:asset options:options resultHandler:^( NSData *imageData, NSString *dataUTI, UIImageOrientation orientation, NSDictionary *imageInfo ) 870 | { 871 | if( imageData != nil ) 872 | resultPath = [self trySaveSourceImage:imageData withInfo:imageInfo atIndex:filenameIndex]; 873 | else 874 | NSLog( @"Couldn't fetch raw image data" ); 875 | }]; 876 | } 877 | 878 | return resultPath; 879 | } 880 | 881 | + (NSString *)trySaveSourceImage:(NSData *)imageData withInfo:(NSDictionary *)info atIndex:(int)filenameIndex 882 | { 883 | NSString *filePath = info[@"PHImageFileURLKey"]; 884 | if( filePath != nil ) // filePath can actually be an NSURL, convert it to NSString 885 | filePath = [NSString stringWithFormat:@"%@", filePath]; 886 | 887 | if( filePath == nil || [filePath length] == 0 ) 888 | { 889 | filePath = info[@"PHImageFileUTIKey"]; 890 | if( filePath != nil ) 891 | filePath = [NSString stringWithFormat:@"%@", filePath]; 892 | } 893 | 894 | NSString *resultPath; 895 | if( filePath == nil || [filePath length] == 0 ) 896 | resultPath = [NSString stringWithFormat:@"%@%d", pickedMediaSavePath, filenameIndex]; 897 | else 898 | resultPath = [[NSString stringWithFormat:@"%@%d", pickedMediaSavePath, filenameIndex] stringByAppendingPathExtension:[filePath pathExtension]]; 899 | 900 | NSError *error; 901 | if( ![[NSFileManager defaultManager] fileExistsAtPath:resultPath] || [[NSFileManager defaultManager] removeItemAtPath:resultPath error:&error] ) 902 | { 903 | if( ![imageData writeToFile:resultPath atomically:YES] ) 904 | { 905 | NSLog( @"Error copying source image to file" ); 906 | resultPath = nil; 907 | } 908 | } 909 | else 910 | { 911 | NSLog( @"Error deleting existing image: %@", error ); 912 | resultPath = nil; 913 | } 914 | 915 | return resultPath; 916 | } 917 | 918 | // Credit: https://lists.apple.com/archives/cocoa-dev/2012/Jan/msg00052.html 919 | + (int)getMediaTypeFromExtension:(NSString *)extension 920 | { 921 | CFStringRef fileUTI = UTTypeCreatePreferredIdentifierForTag( kUTTagClassFilenameExtension, (__bridge CFStringRef) extension, NULL ); 922 | 923 | // mediaType is a bitmask: 924 | // 1: image 925 | // 2: video 926 | // 4: audio (not supported) 927 | int result = 0; 928 | if( UTTypeConformsTo( fileUTI, kUTTypeImage ) || UTTypeConformsTo( fileUTI, kUTTypeLivePhoto ) ) 929 | result = 1; 930 | else if( UTTypeConformsTo( fileUTI, kUTTypeMovie ) || UTTypeConformsTo( fileUTI, kUTTypeVideo ) ) 931 | result = 2; 932 | else if( UTTypeConformsTo( fileUTI, kUTTypeAudio ) ) 933 | result = 4; 934 | 935 | CFRelease( fileUTI ); 936 | 937 | return result; 938 | } 939 | 940 | // Credit: https://stackoverflow.com/a/4170099/2373034 941 | + (NSArray *)getImageMetadata:(NSString *)path 942 | { 943 | int width = 0; 944 | int height = 0; 945 | int orientation = -1; 946 | 947 | CGImageSourceRef imageSource = CGImageSourceCreateWithURL( (__bridge CFURLRef) [NSURL fileURLWithPath:path], nil ); 948 | if( imageSource != nil ) 949 | { 950 | NSDictionary *options = [NSDictionary dictionaryWithObject:[NSNumber numberWithBool:NO] forKey:(__bridge NSString *)kCGImageSourceShouldCache]; 951 | CFDictionaryRef imageProperties = CGImageSourceCopyPropertiesAtIndex( imageSource, 0, (__bridge CFDictionaryRef) options ); 952 | CFRelease( imageSource ); 953 | 954 | CGFloat widthF = 0.0f, heightF = 0.0f; 955 | if( imageProperties != nil ) 956 | { 957 | if( CFDictionaryContainsKey( imageProperties, kCGImagePropertyPixelWidth ) ) 958 | CFNumberGetValue( (CFNumberRef) CFDictionaryGetValue( imageProperties, kCGImagePropertyPixelWidth ), kCFNumberCGFloatType, &widthF ); 959 | 960 | if( CFDictionaryContainsKey( imageProperties, kCGImagePropertyPixelHeight ) ) 961 | CFNumberGetValue( (CFNumberRef) CFDictionaryGetValue( imageProperties, kCGImagePropertyPixelHeight ), kCFNumberCGFloatType, &heightF ); 962 | 963 | if( CFDictionaryContainsKey( imageProperties, kCGImagePropertyOrientation ) ) 964 | { 965 | CFNumberGetValue( (CFNumberRef) CFDictionaryGetValue( imageProperties, kCGImagePropertyOrientation ), kCFNumberIntType, &orientation ); 966 | 967 | if( orientation > 4 ) 968 | { 969 | // Landscape image 970 | CGFloat temp = widthF; 971 | widthF = heightF; 972 | heightF = temp; 973 | } 974 | } 975 | 976 | CFRelease( imageProperties ); 977 | } 978 | 979 | width = (int) roundf( widthF ); 980 | height = (int) roundf( heightF ); 981 | } 982 | 983 | return [[NSArray alloc] initWithObjects:[NSNumber numberWithInt:width], [NSNumber numberWithInt:height], [NSNumber numberWithInt:orientation], nil]; 984 | } 985 | 986 | + (char *)getImageProperties:(NSString *)path 987 | { 988 | NSArray *metadata = [self getImageMetadata:path]; 989 | 990 | int orientationUnity; 991 | int orientation = [metadata[2] intValue]; 992 | 993 | // To understand the magic numbers, see ImageOrientation enum in NativeGallery.cs 994 | // and http://sylvana.net/jpegcrop/exif_orientation.html 995 | if( orientation == 1 ) 996 | orientationUnity = 0; 997 | else if( orientation == 2 ) 998 | orientationUnity = 4; 999 | else if( orientation == 3 ) 1000 | orientationUnity = 2; 1001 | else if( orientation == 4 ) 1002 | orientationUnity = 6; 1003 | else if( orientation == 5 ) 1004 | orientationUnity = 5; 1005 | else if( orientation == 6 ) 1006 | orientationUnity = 1; 1007 | else if( orientation == 7 ) 1008 | orientationUnity = 7; 1009 | else if( orientation == 8 ) 1010 | orientationUnity = 3; 1011 | else 1012 | orientationUnity = -1; 1013 | 1014 | return [self getCString:[NSString stringWithFormat:@"%d>%d> >%d", [metadata[0] intValue], [metadata[1] intValue], orientationUnity]]; 1015 | } 1016 | 1017 | + (char *)getVideoProperties:(NSString *)path 1018 | { 1019 | CGSize size = CGSizeZero; 1020 | float rotation = 0; 1021 | long long duration = 0; 1022 | 1023 | AVURLAsset *asset = [AVURLAsset URLAssetWithURL:[NSURL fileURLWithPath:path] options:nil]; 1024 | if( asset != nil ) 1025 | { 1026 | duration = (long long) round( CMTimeGetSeconds( [asset duration] ) * 1000 ); 1027 | CGAffineTransform transform = [asset preferredTransform]; 1028 | NSArray* videoTracks = [asset tracksWithMediaType:AVMediaTypeVideo]; 1029 | if( videoTracks != nil && [videoTracks count] > 0 ) 1030 | { 1031 | size = [[videoTracks objectAtIndex:0] naturalSize]; 1032 | transform = [[videoTracks objectAtIndex:0] preferredTransform]; 1033 | } 1034 | 1035 | rotation = atan2( transform.b, transform.a ) * ( 180.0 / M_PI ); 1036 | } 1037 | 1038 | return [self getCString:[NSString stringWithFormat:@"%d>%d>%lld>%f", (int) roundf( size.width ), (int) roundf( size.height ), duration, rotation]]; 1039 | } 1040 | 1041 | + (char *)getVideoThumbnail:(NSString *)path savePath:(NSString *)savePath maximumSize:(int)maximumSize captureTime:(double)captureTime 1042 | { 1043 | AVAssetImageGenerator *thumbnailGenerator = [[AVAssetImageGenerator alloc] initWithAsset:[[AVURLAsset alloc] initWithURL:[NSURL fileURLWithPath:path] options:nil]]; 1044 | thumbnailGenerator.appliesPreferredTrackTransform = YES; 1045 | thumbnailGenerator.maximumSize = CGSizeMake( (CGFloat) maximumSize, (CGFloat) maximumSize ); 1046 | thumbnailGenerator.requestedTimeToleranceBefore = kCMTimeZero; 1047 | thumbnailGenerator.requestedTimeToleranceAfter = kCMTimeZero; 1048 | 1049 | if( captureTime < 0.0 ) 1050 | captureTime = 0.0; 1051 | else 1052 | { 1053 | AVURLAsset *asset = [AVURLAsset URLAssetWithURL:[NSURL fileURLWithPath:path] options:nil]; 1054 | if( asset != nil ) 1055 | { 1056 | double videoDuration = CMTimeGetSeconds( [asset duration] ); 1057 | if( videoDuration > 0.0 && captureTime >= videoDuration - 0.1 ) 1058 | { 1059 | if( captureTime > videoDuration ) 1060 | captureTime = videoDuration; 1061 | 1062 | thumbnailGenerator.requestedTimeToleranceBefore = CMTimeMakeWithSeconds( 1.0, 600 ); 1063 | } 1064 | } 1065 | } 1066 | 1067 | NSError *error = nil; 1068 | CGImageRef image = [thumbnailGenerator copyCGImageAtTime:CMTimeMakeWithSeconds( captureTime, 600 ) actualTime:nil error:&error]; 1069 | if( image == nil ) 1070 | { 1071 | if( error != nil ) 1072 | NSLog( @"Error generating video thumbnail: %@", error ); 1073 | else 1074 | NSLog( @"Error generating video thumbnail..." ); 1075 | 1076 | return [self getCString:@""]; 1077 | } 1078 | 1079 | UIImage *thumbnail = [[UIImage alloc] initWithCGImage:image]; 1080 | CGImageRelease( image ); 1081 | 1082 | if( ![UIImagePNGRepresentation( thumbnail ) writeToFile:savePath atomically:YES] ) 1083 | { 1084 | NSLog( @"Error saving thumbnail image" ); 1085 | return [self getCString:@""]; 1086 | } 1087 | 1088 | return [self getCString:savePath]; 1089 | } 1090 | 1091 | + (BOOL)saveImageAsPNG:(UIImage *)image toPath:(NSString *)resultPath 1092 | { 1093 | return [UIImagePNGRepresentation( [self scaleImage:image maxSize:16384] ) writeToFile:resultPath atomically:YES]; 1094 | } 1095 | 1096 | + (UIImage *)scaleImage:(UIImage *)image maxSize:(int)maxSize 1097 | { 1098 | CGFloat width = image.size.width; 1099 | CGFloat height = image.size.height; 1100 | 1101 | UIImageOrientation orientation = image.imageOrientation; 1102 | if( width <= maxSize && height <= maxSize && orientation != UIImageOrientationDown && 1103 | orientation != UIImageOrientationLeft && orientation != UIImageOrientationRight && 1104 | orientation != UIImageOrientationLeftMirrored && orientation != UIImageOrientationRightMirrored && 1105 | orientation != UIImageOrientationUpMirrored && orientation != UIImageOrientationDownMirrored ) 1106 | return image; 1107 | 1108 | CGFloat scaleX = 1.0f; 1109 | CGFloat scaleY = 1.0f; 1110 | if( width > maxSize ) 1111 | scaleX = maxSize / width; 1112 | if( height > maxSize ) 1113 | scaleY = maxSize / height; 1114 | 1115 | // Credit: https://github.com/mbcharbonneau/UIImage-Categories/blob/master/UIImage%2BAlpha.m 1116 | CGImageAlphaInfo alpha = CGImageGetAlphaInfo( image.CGImage ); 1117 | BOOL hasAlpha = alpha == kCGImageAlphaFirst || alpha == kCGImageAlphaLast || alpha == kCGImageAlphaPremultipliedFirst || alpha == kCGImageAlphaPremultipliedLast; 1118 | 1119 | CGFloat scaleRatio = scaleX < scaleY ? scaleX : scaleY; 1120 | CGRect imageRect = CGRectMake( 0, 0, width * scaleRatio, height * scaleRatio ); 1121 | UIGraphicsImageRendererFormat *format = [image imageRendererFormat]; 1122 | format.opaque = !hasAlpha; 1123 | format.scale = image.scale; 1124 | UIGraphicsImageRenderer *renderer = [[UIGraphicsImageRenderer alloc] initWithSize:imageRect.size format:format]; 1125 | image = [renderer imageWithActions:^( UIGraphicsImageRendererContext* _Nonnull myContext ) 1126 | { 1127 | [image drawInRect:imageRect]; 1128 | }]; 1129 | 1130 | return image; 1131 | } 1132 | 1133 | + (char *)loadImageAtPath:(NSString *)path tempFilePath:(NSString *)tempFilePath maximumSize:(int)maximumSize 1134 | { 1135 | // Check if the image can be loaded by Unity without requiring a conversion to PNG 1136 | // Credit: https://stackoverflow.com/a/12048937/2373034 1137 | NSString *extension = [path pathExtension]; 1138 | BOOL conversionNeeded = [extension caseInsensitiveCompare:@"jpg"] != NSOrderedSame && [extension caseInsensitiveCompare:@"jpeg"] != NSOrderedSame && [extension caseInsensitiveCompare:@"png"] != NSOrderedSame; 1139 | 1140 | if( !conversionNeeded ) 1141 | { 1142 | // Check if the image needs to be processed at all 1143 | NSArray *metadata = [self getImageMetadata:path]; 1144 | int orientationInt = [metadata[2] intValue]; // 1: correct orientation, [1,8]: valid orientation range 1145 | if( orientationInt == 1 && [metadata[0] intValue] <= maximumSize && [metadata[1] intValue] <= maximumSize ) 1146 | return [self getCString:path]; 1147 | } 1148 | 1149 | UIImage *image = [UIImage imageWithContentsOfFile:path]; 1150 | if( image == nil ) 1151 | return [self getCString:path]; 1152 | 1153 | UIImage *scaledImage = [self scaleImage:image maxSize:maximumSize]; 1154 | if( conversionNeeded || scaledImage != image ) 1155 | { 1156 | if( ![UIImagePNGRepresentation( scaledImage ) writeToFile:tempFilePath atomically:YES] ) 1157 | { 1158 | NSLog( @"Error creating scaled image" ); 1159 | return [self getCString:path]; 1160 | } 1161 | 1162 | return [self getCString:tempFilePath]; 1163 | } 1164 | else 1165 | return [self getCString:path]; 1166 | } 1167 | 1168 | // Credit: https://stackoverflow.com/a/37052118/2373034 1169 | + (char *)getCString:(NSString *)source 1170 | { 1171 | if( source == nil ) 1172 | source = @""; 1173 | 1174 | const char *sourceUTF8 = [source UTF8String]; 1175 | char *result = (char*) malloc( strlen( sourceUTF8 ) + 1 ); 1176 | strcpy( result, sourceUTF8 ); 1177 | 1178 | return result; 1179 | } 1180 | 1181 | @end 1182 | 1183 | extern "C" int _NativeGallery_CheckPermission( int readPermission, int permissionFreeMode ) 1184 | { 1185 | return [UNativeGallery checkPermission:( readPermission == 1 ) permissionFreeMode:( permissionFreeMode == 1 )]; 1186 | } 1187 | 1188 | extern "C" void _NativeGallery_RequestPermission( int readPermission, int permissionFreeMode ) 1189 | { 1190 | [UNativeGallery requestPermission:( readPermission == 1 ) permissionFreeMode:( permissionFreeMode == 1 )]; 1191 | } 1192 | 1193 | extern "C" void _NativeGallery_ShowLimitedLibraryPicker() 1194 | { 1195 | return [UNativeGallery showLimitedLibraryPicker]; 1196 | } 1197 | 1198 | extern "C" void _NativeGallery_OpenSettings() 1199 | { 1200 | [UNativeGallery openSettings]; 1201 | } 1202 | 1203 | extern "C" int _NativeGallery_CanPickMultipleMedia() 1204 | { 1205 | return [UNativeGallery canPickMultipleMedia]; 1206 | } 1207 | 1208 | extern "C" void _NativeGallery_ImageWriteToAlbum( const char* path, const char* album, int permissionFreeMode ) 1209 | { 1210 | [UNativeGallery saveMedia:[NSString stringWithUTF8String:path] albumName:[NSString stringWithUTF8String:album] isImage:YES permissionFreeMode:( permissionFreeMode == 1 )]; 1211 | } 1212 | 1213 | extern "C" void _NativeGallery_VideoWriteToAlbum( const char* path, const char* album, int permissionFreeMode ) 1214 | { 1215 | [UNativeGallery saveMedia:[NSString stringWithUTF8String:path] albumName:[NSString stringWithUTF8String:album] isImage:NO permissionFreeMode:( permissionFreeMode == 1 )]; 1216 | } 1217 | 1218 | extern "C" void _NativeGallery_PickMedia( const char* mediaSavePath, int mediaType, int permissionFreeMode, int selectionLimit ) 1219 | { 1220 | [UNativeGallery pickMedia:mediaType savePath:[NSString stringWithUTF8String:mediaSavePath] permissionFreeMode:( permissionFreeMode == 1 ) selectionLimit:selectionLimit]; 1221 | } 1222 | 1223 | extern "C" int _NativeGallery_IsMediaPickerBusy() 1224 | { 1225 | return [UNativeGallery isMediaPickerBusy]; 1226 | } 1227 | 1228 | extern "C" int _NativeGallery_GetMediaTypeFromExtension( const char* extension ) 1229 | { 1230 | return [UNativeGallery getMediaTypeFromExtension:[NSString stringWithUTF8String:extension]]; 1231 | } 1232 | 1233 | extern "C" char* _NativeGallery_GetImageProperties( const char* path ) 1234 | { 1235 | return [UNativeGallery getImageProperties:[NSString stringWithUTF8String:path]]; 1236 | } 1237 | 1238 | extern "C" char* _NativeGallery_GetVideoProperties( const char* path ) 1239 | { 1240 | return [UNativeGallery getVideoProperties:[NSString stringWithUTF8String:path]]; 1241 | } 1242 | 1243 | extern "C" char* _NativeGallery_GetVideoThumbnail( const char* path, const char* thumbnailSavePath, int maxSize, double captureTimeInSeconds ) 1244 | { 1245 | return [UNativeGallery getVideoThumbnail:[NSString stringWithUTF8String:path] savePath:[NSString stringWithUTF8String:thumbnailSavePath] maximumSize:maxSize captureTime:captureTimeInSeconds]; 1246 | } 1247 | 1248 | extern "C" char* _NativeGallery_LoadImageAtPath( const char* path, const char* temporaryFilePath, int maxSize ) 1249 | { 1250 | return [UNativeGallery loadImageAtPath:[NSString stringWithUTF8String:path] tempFilePath:[NSString stringWithUTF8String:temporaryFilePath] maximumSize:maxSize]; 1251 | } -------------------------------------------------------------------------------- /Plugins/NativeGallery/iOS/NativeGallery.mm.meta: -------------------------------------------------------------------------------- 1 | fileFormatVersion: 2 2 | guid: 953e0b740eb03144883db35f72cad8a6 3 | timeCreated: 1498722774 4 | licenseType: Pro 5 | PluginImporter: 6 | serializedVersion: 2 7 | iconMap: {} 8 | executionOrder: {} 9 | isPreloaded: 0 10 | isOverridable: 0 11 | platformData: 12 | data: 13 | first: 14 | Any: 15 | second: 16 | enabled: 0 17 | settings: {} 18 | data: 19 | first: 20 | Editor: Editor 21 | second: 22 | enabled: 0 23 | settings: 24 | DefaultValueInitialized: true 25 | data: 26 | first: 27 | iPhone: iOS 28 | second: 29 | enabled: 1 30 | settings: {} 31 | userData: 32 | assetBundleName: 33 | assetBundleVariant: 34 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "com.yasirkula.nativegallery", 3 | "displayName": "Native Gallery", 4 | "version": "1.9.1", 5 | "documentationUrl": "https://github.com/yasirkula/UnityNativeGallery", 6 | "changelogUrl": "https://github.com/yasirkula/UnityNativeGallery/releases", 7 | "licensesUrl": "https://github.com/yasirkula/UnityNativeGallery/blob/master/LICENSE.txt", 8 | "description": "This plugin helps you save your images and/or videos to device Gallery on Android and Photos on iOS. It is also possible to pick an image or video from Gallery/Photos." 9 | } 10 | -------------------------------------------------------------------------------- /package.json.meta: -------------------------------------------------------------------------------- 1 | fileFormatVersion: 2 2 | guid: bb661d58611fbb54d99e0a84c81b302e 3 | PackageManifestImporter: 4 | externalObjects: {} 5 | userData: 6 | assetBundleName: 7 | assetBundleVariant: 8 | --------------------------------------------------------------------------------