Delphi Cross Platform File Picker

Als kleines „Abfallprodukt“ meines Projektes http://www.dead-drop.net hier ein wenig Code, der vielleicht anderen helfen kann.

In Delphi gibt es mit der Komponente TOpenDialog einen einfachen weg um den Benutzer Dateien auswählen zu lassen. Leider funktioniert diese Komponente unter Android nicht. das ist ärgerlich, vor allem wenn man nicht für jede Plattform anderen Code schreiben möchte und so in einer Compilerswitchhölle landet wenn man Cross Platform entwickelt.

Daher habe ich eine (genaugenommen 3) Klasse(n), die das Problem lösen und eine einheitliche zum System FilePicker bieten.

Die komplette Demo gibt es unter : https://hermes.simplecrypt,net/down/CodeCorner/Delphi/FilePicker.zip

Die Klasse TAndroidFilePicker übernimmt die eigentliche Arbeit unter Android. Da es bei Android keine Modalen Dialoge gibt muss zum einen ein Intent erzeugt werden. Und zum anderen müssen die ergebnisse in einem Listener abgerufen werden.

unit androidfilepicker;

interface

uses
  System.SysUtils, System.Classes,
{$IFDEF ANDROID}
  Androidapi.JNIBridge, Androidapi.JNI.Net,
{$ENDIF ANDROID}
  System.SysConst, System.IOUtils, System.Messaging;

type
  TGetFileNameListener = procedure (const _ok: Boolean) of object;

  TAndroidFilePicker = class (TObject)
  private
    FLastPickedFileName : String;
    FFileNameListener   : TGetFileNameListener;
    FMessageChooserID   : Integer;
    {$IFDEF ANDROID}
    FLastPickedURI      : JNet_URI;

    function getBytesFromURI(_uri : Jnet_URI) : TBytes;
    function getURIfromMsg (m : TMessage) : Jnet_Uri;
    function trimExtension (_rawExt : string) : string;
    function getMimeType(_extension : string) : string;
    procedure InternalOpenFileDialog(_title, _extension:string);
    procedure HandleActivityMessageforChooser(const Sender: TObject; const M: TMessage);

    property fileNameCallBack : TGetFileNameListener read FFileNameListener;
    {$ENDIF}
  protected
  public
    constructor create(_fileNameCallBack: TGetFileNameListener);
    function getBytesFromlastPick : TBytes;
    {$IFDEF ANDROID}
    procedure OpenFileDialog(_title, _extension:string);
    property lastPickedURI : JNet_URI read FLastPickedURI;
    {$ENDIF}
    property lastPickedFileName : string read FLastPickedFileName;
  end;

implementation

uses
{$IFDEF ANDROID}
  Androidapi.Helpers, Androidapi.JNI.JavaTypes, Androidapi.JNI.GraphicsContentViewText,
  Androidapi.JNI.Webkit, Androidapi.JNI.App,
{$ENDIF}
  System.NetEncoding, System.Rtti, System.TypInfo, System.Math;


const
  FILE_SELECT_CODE = 0;

{$IFDEF ANDROID}

function TAndroidFilePicker.getBytesFromURI(_uri : Jnet_URI) : TBytes;
var
  jis: JInputStream;
  b: TJavaArray<Byte>;
begin
  if _uri <> nil then begin
    jis := TAndroidHelper.Context.getContentResolver.openInputStream(_uri);
    b := TJavaArray<Byte>.Create(jis.available);
    jis.read(b);
    jis.close;
    SetLength(result, b.Length);
    if b.Length > 0 then
      Move(b.Data^, result[0], b.Length);
  end else begin
    result := nil;
  end;
end;

procedure TAndroidFilePicker.HandleActivityMessageforChooser(const Sender: TObject; const M: TMessage);
var
  FileName  : string;
begin
  FLastPickedURI := getURIfromMsg(m);
  if FLastPickedURI = nil then begin
    FLastPickedFileName := '';
    exit;
  end else begin
    FLastPickedFileName := JStringToString(FLastPickedURI.getPath).Trim;
    if Assigned(fileNameCallBack) then
      fileNameCallBack(not FLastPickedFileName.IsEmpty);
  end;
end;

procedure TAndroidFilePicker.InternalOpenFileDialog(_title, _extension:string);
var
  Intent, IntentChooser : JIntent;
  sMIMEType, extension  : string;
begin
  extension := trimExtension(_extension);
  sMIMEType := getMimeType(extension);
  Intent := TJIntent.JavaClass.init(TJIntent.JavaClass.ACTION_GET_CONTENT);
  Intent.setType(StringToJString(sMIMEType));
  Intent.addCategory(TJIntent.JavaClass.CATEGORY_OPENABLE);
  IntentChooser := TJIntent.JavaClass.createChooser(Intent, StrToJCharSequence(_title));
  if FMessageChooserID <> 0 then
    TMessageManager.DefaultManager.Unsubscribe(TMessageResultNotification, FMessageChooserID);
  FMessageChooserID := 0;
  FMessageChooserID := TMessageManager.DefaultManager.SubscribeToMessage(
    TMessageResultNotification, HandleActivityMessageforChooser);
  try
    TAndroidHelper.Activity.startActivityForResult(IntentChooser, FILE_SELECT_CODE);
  except
    raise Exception.Create('Could not Find File Manager');
  end;
end;

Procedure TAndroidFilePicker.OpenFileDialog(_title, _extension:string);
begin
  InternalOpenFileDialog(_title, _extension);
end;

function TAndroidFilePicker.trimExtension (_rawExt : string) : string;
begin
  Result := _rawExt;
  if Result.Substring(0,1) = '.' then
    Result := Result.Substring(1);
  if Result  = '*.*' then
    Result := '*/*';
end;

function TAndroidFilePicker.getMimeType(_extension : string) : string;
var
  MIMEType              : JString;
  mime                  : JMimeTypeMap;
  sMIMEType, extension  : string;
begin
      extension := AnsiLowerCase(_extension);
  MIMEType := nil;
  sMIMEType := '';
  if extension = '' then
    sMIMEType := '*/*'
  else begin
    if extension = 'file/*' then
      sMIMEType := 'file/*';
    if extension = '*' then
    begin
      if TOSVersion.Check(4, 4) then
        sMIMEType := '*/*'
      else
        sMIMEType := 'file/*';
    end;
    if extension = '*/*' then
      sMIMEType := '*/*';
    if sMimeType = '' then begin
      mime := TJMimeTypeMap.JavaClass.getSingleton;
      if mime <> nil then
        MIMEType := mime.getMimeTypeFromExtension(StringToJString(extension));
      if MIMEType <> nil then
        sMIMEType := JStringToString(MIMEType).Trim;
    end;
  end;
  if sMIMEType.IsEmpty then
    sMIMEType := '*/*';
  result := sMIMEType;
end;

function TAndroidFilePicker.getURIfromMsg (m : TMessage) : Jnet_Uri;
begin
  Result := nil;
  if (m <> nil) and (M is TMessageResultNotification) then
    if (TMessageResultNotification(M).RequestCode = FILE_SELECT_CODE) then
      if (TMessageResultNotification(M).ResultCode = TJActivity.JavaClass.RESULT_OK) then
        if TMessageResultNotification(M).Value <> nil then
          result := TMessageResultNotification(M).Value.getData;
end;

{$ENDIF ANDROID}

function  TAndroidFilePicker.getBytesFromlastPick : TBytes;
begin
  {$IFDEF ANDROID}
  result := getBytesFromURI(lastPickedURI);
  {$ELSE}
  result :=nil;
  {$ENDIF}

end;

constructor TAndroidFilePicker.create(_fileNameCallBack: TGetFileNameListener);
begin
  inherited create;
  FFileNameListener := _fileNameCallBack;
  FMessageChooserID := 0;
end;


end.

Um diese Aufgabe nun unabhängig vom Betriebssystem aufrufen zu können gibt es noch die Klasse TFilePicker. Diese bindedet je nach Betriebssystem die obige Klasse ein oder verwendet den Standart TOpenDialog für die Aufgabe.

Ich konnte es nicht selber testen, aber laut Doku soll der TOpenDialog unter iOS und MacOS funktionieren.

Die Klasse TFilePicker bietet somit eine einheitliche Schnittstelle für die Aufgabe.

unit filePicker;

interface
uses
{$IFDEF ANDROID}
  Androidapi.Jni.Net,
{$ENDIF}
  System.SysUtils, System.Types, FMX.Dialogs, androidfilepicker, mimetype;

type
  TNotifyPick = procedure(const _picked: Boolean) of object;

  TFilePicker = class (TObject)
  private
    FMimeMap : TMimeMap;
    FPickedFile : boolean;
    FPickedFileName : string;
    FCanReadExternalStorage : boolean;
    FCanWriteExternalStorage : Boolean;
    FOpenDialog: TOpenDialog;
    FDialogTitle : string;
    FNotifyPickCallback : TNotifyPick;
    Fpicker : TAndroidFilePicker;
    procedure GetFileName(const _ok: Boolean);
    function FileToByteArray(const _fileName: WideString): TArray<Byte>;
    function getPickedFileExtension : string;
    function getPickedFileMimeType  : String;
    function getPickedPureFileName  : String;
    function getPureFileName(_fileName : string) : string;
    function getLastSlashPos(_inStr : string) : integer;
  protected
  public
    constructor create(_openDLG : TOpenDialog; _title : string; _notifyPick : TNotifyPick);
    destructor Destroy; override;
    procedure openFileFicker(_extension : string);
    procedure updatePermissions;
    function getPickedFileBytes : TBytes;
    procedure resetPick;
    property hasPickedFile           : boolean            read FPickedFile;
    property pickedFileName          : string             read FPickedFileName;
    property pickedPureFileName      : string             read getPickedPureFileName;
    property pickedFileExtension     : string             read getPickedFileExtension;
    property pickedFileMimeType      : string             read getPickedFileMimeType;
    property canReadExternalStorage  : boolean            read FCanReadExternalStorage;
    property canWriteExternalStorage : boolean            read FCanWriteExternalStorage;
    property picker                  : TAndroidFilePicker read Fpicker;
    property mimeMap                 : TMimeMap           read FMimeMap;
  end;

implementation

uses
{$IFDEF ANDROID}
  Androidapi.Helpers, Androidapi.JNI.Os, Androidapi.Jni.JavaTypes,
{$ENDIF}
  System.Permissions, System.Classes;

//private

function TFilePicker.getLastSlashPos(_inStr : string) : integer;
var
  i: Integer;
begin
  result := 0;
  for i := length(_inStr) downto 1 do
    if _inStr[i] = '/' then begin
      result := i;
      break;
    end;
end;

function TFilePicker.getPickedPureFileName  : String;
begin
  result := getPureFileName(pickedFileName);
end;

function TFilePicker.getPureFileName(_fileName : string) : string;
var
  start, len : integer;
begin
  Result := StringReplace(_fileName, '\', '/', [rfReplaceAll, rfIgnoreCase]);
  start := getLastSlashPos(result);
  len := length(result) - start;
  result := result.Substring(start, len);
end;

function TFilePicker.getPickedFileExtension : string;
begin
  result := '';
  if hasPickedFile then
    result := mimeMap.getExtensionFromFileName(pickedFileName);
end;

function TFilePicker.getPickedFileMimeType  : String;
begin
  result := '';
  if hasPickedFile then
    result := mimeMap.getMimeTypeFromFileName(pickedFileName);
end;

procedure TFilePicker.GetFileName(const _ok: Boolean);
begin
  FPickedFile := _ok;
  FPickedFileName := picker.lastPickedFileName;
  FNotifyPickCallback(_ok);
end;

function TFilePicker.FileToByteArray(const _fileName : WideString): TArray<Byte>;
var
  fs : TFileStream;
begin
  fs:=TFileStream.Create(_fileName,fmOpenRead);
  try
    fs.Seek(0,soFromBeginning);
    setLength(result,fs.Size);
    fs.ReadBuffer(Pointer(result)^, fs.Size);
  finally
    fs.Free;
  end;
end;

// protected

procedure TFilePicker.updatePermissions;
{$IFDEF ANDROID}
var
  FPermissionREAD_EXTERNAL_STORAGE,
  FPermissionWRITE_EXTERNAL_STORAGE: string;
{$ENDIF ANDROID}
begin
  FCanReadExternalStorage := True;
  FCanWriteExternalStorage := True;
{$IFDEF ANDROID}
  FCanReadExternalStorage := False;
  FCanWriteExternalStorage := False;
  FPermissionREAD_EXTERNAL_STORAGE := JStringToString(TJManifest_permission.JavaClass.READ_EXTERNAL_STORAGE);//读取文件
  FPermissionWRITE_EXTERNAL_STORAGE := JStringToString(TJManifest_permission.JavaClass.WRITE_EXTERNAL_STORAGE);//写入文件
  PermissionsService.RequestPermissions([FPermissionWRITE_EXTERNAL_STORAGE, FPermissionREAD_EXTERNAL_STORAGE],
      procedure(const APermissions: TClassicStringDynArray; const AGrantResults: TClassicPermissionStatusDynArray)
      begin
        if (Length(AGrantResults) > 0 ) and (AGrantResults[0] = TPermissionStatus.Granted) then
        begin
          FCanReadExternalStorage := True;
          FCanWriteExternalStorage := True;
        end
        else if (Length(AGrantResults) > 1 ) and (AGrantResults[1] = TPermissionStatus.Granted) then
        begin
          FCanReadExternalStorage := True;
          FCanWriteExternalStorage := False;
        end
        else
        begin
          FCanReadExternalStorage := False;
          FCanWriteExternalStorage := False;
        end;
      end);
{$ENDIF ANDROID}
end;

// public

function TFilePicker.getPickedFileBytes : TBytes;
begin
  result := nil;
  if hasPickedFile then begin
    {$IFDEF ANDROID}
    result := picker.getBytesFromlastPick;
    {$ENDIF ANDROID}
    {$IF DEFINED(MSWINDOWS) OR DEFINED(MACOS)}
      result := FileToByteArray(FPickedFileName)
    {$ENDIF}
  end;
end;

constructor TFilePicker.create(_openDLG : TOpenDialog; _title : string; _notifyPick : TNotifyPick);
begin
  inherited create;
  FMimeMap := TMimeMap.create;;
  FOpenDialog := _openDLG;
  FDialogTitle := _title;
  FNotifyPickCallback := _notifyPick;
  Fpicker := TAndroidFilePicker.create(GetFileName);
  resetPick;;
end;

destructor TFilePicker.destroy;
begin
  FMimeMap.Free;
  Fpicker.Free;
end;

procedure TFilePicker.resetPick;
begin
  FPickedFile := false;
  FPickedFileName := '';
end;

procedure TFilePicker.openFileFicker(_extension : string);
var
  FileExt: string;
begin
  resetPick;
  FileExt := _extension.Trim;
  if FileExt.Substring(0,1) = '.' then
    FileExt := FileExt.Substring(1);
{$IFDEF ANDROID}
  picker.OpenFileDialog('Choose File', FileExt);
{$ENDIF ANDROID}
{$IF DEFINED(MSWINDOWS) OR DEFINED(MACOS)}
{$IF NOT defined(IOS)}
  FOpenDialog.Title := FDialogTitle;
  FOpenDialog.DefaultExt := FileExt;
  FOpenDialog.Filter := '(*.' + FileExt + ')|*.' + FileExt + '| (*.*)|*.*';
  FPickedFile := FOpenDialog.Execute;
  if hasPickedFile then begin
    FPickedFileName := FOpenDialog.FileName;
    FNotifyPickCallback(true);
  end;
{$ENDIF}
{$ENDIF}
end;

end.

Die Klasse TMimeMap is eine einfache Hilfsklasse um vom Mime Type auf die Extension schliessen zu können und umgekeht. Den Code spare ich mir an dieser Stelle. Die Klasse ist aber natürlich im Demo enthalten.

Zu guter letzt noch: Wie verwendet man die Klasse:
Man benötigt eine Instanz der Klasse TFilePicker. Im Demo mache ich das mit einer privaten Variable und erzeuge die Instanz im onCreate der Form.
Um die Instanz erzeugen zu können braucht man auch noch eine Methode vom Typ TNotifyPick = procedure(const _picked: Boolean) of object; Hier kann man die Ergebnisse des abrufen.

Das relativ komplizierte Vorgehen mit der Callback Methode ist zwar nur für Android nötig. Aber auf diese Weise hat mein eine einheitliche Schnittstelle für alle OS.

Hier noch der Code der Beispielanwendung:

unit frmMain;

interface

uses
  System.SysUtils, System.Types, System.UITypes, System.Classes, System.Variants,
  FMX.Types, FMX.Controls, FMX.Forms, FMX.Graphics, FMX.Dialogs, FMX.Memo.Types,
  FMX.StdCtrls, FMX.Controls.Presentation, FMX.ScrollBox, FMX.Memo, filepicker,
  FMX.Layouts, FMX.ExtCtrls, FMX.Menus;

type
  TLoadFileThread=class(TThread)
  private
    FFilePicker    : TFilePicker;
    FImage         : TBitmap;
    FFileContent64 : string;
  protected
    procedure Execute;override;
  public
    constructor Create(_filePicker : TFilePicker);
    destructor Destroy; override;
    property fileContent : string  read FFileContent64;
    property image       : TBitmap read FImage;
  end;

  TfMain = class(TForm)
    StyleBook1: TStyleBook;
    memContent: TMemo;
    btnPick: TButton;
    lblFileName: TLabel;
    dlgOpen: TOpenDialog;
    lblMimeType: TLabel;
    ImageViewer1: TImageViewer;
    AniIndicator1: TAniIndicator;
    procedure FormCreate(Sender: TObject);
    procedure btnPickClick(Sender: TObject);
  private
    FLoadFileThread : TLoadFileThread;
    FFilePicker : TFilePicker;
    procedure listenFilePicker(const _picked : boolean);
    property FilePicker : TFilePicker read FFilePicker;
    procedure OnTerminate(Sender:TObject);
  public
    { Public-Deklarationen }
  end;

var
  fMain: TfMain;

implementation
uses
  System.NetEncoding, System.threading;

{$R *.fmx}

//  TLoadFileThread=class(TThread)
//  private
//  protected
procedure TLoadFileThread.Execute;
var
  ms : TMemoryStream;
  imageBytes : TBytes;
  mime : String;
begin
  if FFilePicker.hasPickedFile then begin
    imageBytes := FFilePicker.getPickedFileBytes;
    FFileContent64 := TNetEncoding.Base64.EncodeBytesToString(imageBytes);
    mime := FFilePicker.pickedFileMimeType;
    if mime.StartsText('image', mime) then begin
      ms := TMemoryStream.Create;
      try
        ms.Write(imageBytes[0], length(imageBytes));
        FImage := TBitmap.Create;
        FImage.LoadFromStream(ms);
      finally
        ms.free;
      end;
    end;
  end;
end;

//  public
constructor TLoadFileThread.Create(_filePicker : TFilePicker);
begin
  FFilePicker := _filePicker;
  inherited Create(True);
end;

destructor TLoadFileThread.destroy;
begin
  if FImage <> nil then
    FreeAndNil(FImage);
  inherited;
end;

procedure TfMain.listenFilePicker(const _picked : boolean);
begin
  if _picked then begin
    AniIndicator1.Enabled := true;
    AniIndicator1.Visible := true;
    lblFileName.Text := FilePicker.pickedFileName;
    lblMimeType.Text := FilePicker.pickedFileExtension +' -- '+ FilePicker.pickedFileMimeType;
    Application.ProcessMessages;
    FLoadFileThread:= TLoadFileThread.Create(FFilePicker);
    FLoadFileThread.OnTerminate:=OnTerminate;
    FLoadFileThread.Resume;
 end;
end;

procedure TfMain.btnPickClick(Sender: TObject);
begin
  FilePicker.openFileFicker('*');
end;

procedure TfMain.FormCreate(Sender: TObject);
begin
  FFilePicker := TFilePicker.create(dlgOpen, '', listenFilePicker);
end;

procedure TfMain.OnTerminate(Sender:TObject);
begin
  memContent.Text := FLoadFileThread.fileContent;
  if FLoadFileThread.image <> nil then begin
    ImageViewer1.Bitmap.Assign(FLoadFileThread.image);
    ImageViewer1.BestFit;
    ImageViewer1.RealignContent;
  end;
  FLoadFileThread := nil;
  AniIndicator1.Enabled := false;
  AniIndicator1.Visible := false;
end;

end.

So, ich hoffe ich kann damit dem einen oder anderen etwas Zeit sparen 😉