@@ -382,40 +382,17 @@ public void rename(PluginCall call) {
382382 int ti = to .lastIndexOf ('/' );
383383 String fromDir = fi == -1 ? "" : from .substring (0 , fi );
384384 String toDir = ti == -1 ? "" : to .substring (0 , ti );
385+ String fromName = baseName (from );
386+ String targetName = baseName (to );
385387 if (fromDir .equals (toDir )) {
386- Uri renamed = DocumentsContract .renameDocument (
387- resolver (), docUri (tree , src .docId ), baseName (to )
388- );
389- if (renamed == null ) throw new Exception ("Rename refused" );
388+ renameExactly (tree , src .docId , fromName , targetName );
390389 } else {
391390 Entry fromParent = resolve (tree , fromDir );
392391 String toParentId = ensureParentDirs (tree , to );
393- Uri moved = DocumentsContract .moveDocument (
394- resolver (),
395- docUri (tree , src .docId ),
396- docUri (tree , fromParent .docId ),
397- docUri (tree , toParentId )
398- );
399- if (moved == null ) throw new Exception ("Move refused" );
400- String movedId = DocumentsContract .getDocumentId (moved );
401- String targetName = baseName (to );
402- if (!baseName (from ).equals (targetName )) {
403- try {
404- if (DocumentsContract .renameDocument (resolver (), docUri (tree , movedId ), targetName ) == null ) {
405- throw new Exception ("Rename after move refused" );
406- }
407- } catch (Exception renameError ) {
408- // A rename promise must not reject after silently changing parents.
409- try {
410- Uri restored = DocumentsContract .moveDocument (resolver (), docUri (tree , movedId ),
411- docUri (tree , toParentId ), docUri (tree , fromParent .docId ));
412- if (restored == null ) throw new Exception ("Rollback move refused" );
413- } catch (Exception rollbackError ) {
414- throw new Exception ("FOLDER_STATE_UNCERTAIN: Rename failed and could not be restored: "
415- + rollbackError .getMessage (), renameError );
416- }
417- throw renameError ;
418- }
392+ if (fromName .equals (targetName )) {
393+ moveDocument (tree , src .docId , fromParent .docId , toParentId );
394+ } else {
395+ moveRenamed (tree , src .docId , fromParent .docId , toParentId , fromName , targetName );
419396 }
420397 }
421398 invalidateParent (tree , from );
@@ -428,6 +405,111 @@ public void rename(PluginCall call) {
428405 }
429406 }
430407
408+ /** One provider move. The document keeps its display name. */
409+ private String moveDocument (Uri tree , String docId , String fromParentId , String toParentId ) throws Exception {
410+ Uri moved = DocumentsContract .moveDocument (
411+ resolver (), docUri (tree , docId ), docUri (tree , fromParentId ), docUri (tree , toParentId )
412+ );
413+ if (moved == null ) throw new Exception ("Move refused" );
414+ return DocumentsContract .getDocumentId (moved );
415+ }
416+
417+ /**
418+ * Rename and insist on the exact name. The platform file provider does not
419+ * refuse a taken name: it quietly lands on a "name (1)" variant, so the
420+ * caller would believe the rename succeeded while the file sits at a path
421+ * nobody asked for. Such a rename is undone and reported instead.
422+ */
423+ private String renameExactly (Uri tree , String docId , String currentName , String name ) throws Exception {
424+ Uri renamed = DocumentsContract .renameDocument (resolver (), docUri (tree , docId ), name );
425+ if (renamed == null ) throw new Exception ("Rename refused" );
426+ String renamedId = DocumentsContract .getDocumentId (renamed );
427+ String actual = displayName (renamed );
428+ if (actual == null || actual .equals (name )) return renamedId ;
429+ try {
430+ if (DocumentsContract .renameDocument (resolver (), docUri (tree , renamedId ), currentName ) == null ) {
431+ throw new Exception ("Rollback rename refused" );
432+ }
433+ } catch (Exception rollbackError ) {
434+ throw new Exception ("FOLDER_STATE_UNCERTAIN: \" " + name + "\" is taken and the file is now named \" "
435+ + actual + "\" : " + rollbackError .getMessage ());
436+ }
437+ throw new Exception ("\" " + name + "\" already exists" );
438+ }
439+
440+ /**
441+ * A move that also changes the name. moveDocument keeps the display name,
442+ * so moving first parks the file at targetDir/fromName and fails with
443+ * "Already exists" whenever an unrelated file holds that name there, even
444+ * though the destination itself is free. Cloud sync hit this on every
445+ * retry when the desktop trashed Untitled.md as "trash/Untitled 2.md" and
446+ * the phone's trash still held an older Untitled.md (desktop #813). Take
447+ * the name first, inside the source directory, then cross directories: the
448+ * only path that has to be free is the one the caller asked for. When the
449+ * source directory already holds the target name (in any letter case, the
450+ * storage may fold case), travel under a hidden temporary name and take the
451+ * final name after the move.
452+ */
453+ private void moveRenamed (Uri tree , String docId , String fromParentId , String toParentId ,
454+ String fromName , String targetName ) throws Exception {
455+ Map <String , Entry > siblings = listings .get (cacheKey (tree , fromParentId ));
456+ if (siblings == null ) siblings = listChildren (tree , fromParentId );
457+ boolean targetNameTaken = false ;
458+ for (String sibling : siblings .keySet ()) {
459+ if (sibling .equalsIgnoreCase (targetName )) {
460+ targetNameTaken = true ;
461+ break ;
462+ }
463+ }
464+ String travelName = targetNameTaken ? temporaryName (targetName ) : targetName ;
465+ String travelId = renameExactly (tree , docId , fromName , travelName );
466+ String movedId ;
467+ try {
468+ movedId = moveDocument (tree , travelId , fromParentId , toParentId );
469+ } catch (Exception moveError ) {
470+ // A rename promise must not reject after silently changing the name.
471+ try {
472+ if (DocumentsContract .renameDocument (resolver (), docUri (tree , travelId ), fromName ) == null ) {
473+ throw new Exception ("Rollback rename refused" );
474+ }
475+ } catch (Exception rollbackError ) {
476+ throw new Exception ("FOLDER_STATE_UNCERTAIN: Move failed and the name could not be restored: "
477+ + rollbackError .getMessage (), moveError );
478+ }
479+ throw moveError ;
480+ }
481+ if (travelName .equals (targetName )) return ;
482+ try {
483+ renameExactly (tree , movedId , travelName , targetName );
484+ } catch (Exception renameError ) {
485+ // A rename promise must not reject after silently changing parents.
486+ try {
487+ String restoredId = moveDocument (tree , movedId , toParentId , fromParentId );
488+ if (DocumentsContract .renameDocument (resolver (), docUri (tree , restoredId ), fromName ) == null ) {
489+ throw new Exception ("Rollback rename refused" );
490+ }
491+ } catch (Exception rollbackError ) {
492+ throw new Exception ("FOLDER_STATE_UNCERTAIN: Rename failed and could not be restored: "
493+ + rollbackError .getMessage (), renameError );
494+ }
495+ throw renameError ;
496+ }
497+ }
498+
499+ private static String temporaryName (String name ) {
500+ return ".zn-move-" + Long .toHexString (System .nanoTime ()) + "-" + name ;
501+ }
502+
503+ /** The provider's current display name for a document, or null when it cannot be read. */
504+ private String displayName (Uri doc ) {
505+ try (Cursor c = resolver ().query (doc , new String [] { Document .COLUMN_DISPLAY_NAME }, null , null , null )) {
506+ if (c != null && c .moveToFirst () && !c .isNull (0 )) return c .getString (0 );
507+ } catch (Exception ignored ) {
508+ // An unverifiable rename is taken at its word rather than failed.
509+ }
510+ return null ;
511+ }
512+
431513 @ PluginMethod
432514 public void copy (PluginCall call ) {
433515 Uri tree = requireTree (call );
0 commit comments