├── .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 |
--------------------------------------------------------------------------------