#!/usr/bin/env bash # License: Public Domain or CC0 # See https://creativecommons.org/publicdomain/zero/1.0/ # The author, Jim Avera (jim.avera at gmail) has waived all copyright and # related or neighboring rights. Attribution is requested but is not required. # $Id: phone,v 1.50 2026/06/24 23:12:30 jima Exp jima $ usage() { cat < same but list relpaths from ${CameraPath}/ listmusic -> same but list relpaths from ${MusicPath}/ copy src1 ... dst (runs rsync, prefixing host: to src*) copypics -> same but phone relpaths from ${CameraPath}/ copymusic -> same but phone relpaths from ${MusicPath}/ move* is like copy* but deletes from src afterwards if the phone allows Note: copy can go the other direction (sending to phone) if dst* is prefixed with ":". More generally: If a config variable "FooPath" is defined: listFoo list contents of phone:FooPath directory copyFoo src1 ... : copy local files to phone below phone:FooPath/ copyFoo src1 ... :subdir/ copy local files to phone below phone:FooPath/subdir/ copyFoo src1 ... destdir copy remote phone:FooPath/src1 ... into local directory destdir/ syncFoo rsync copy local files to phone below phone:FooPath # e.g. syncMusic OPTIONS -n # dry-run -h IPADDR # phone's IP address ($PhoneHost) -p PORT # ($PhonePort) -u USER # (default: ignored) --XxxPath=REMOTEPATH # override XxxPath setting in $conf --conf # config file instead of $conf INITIAL SETUP 1. Create \$HOME/.phone_conf (see example in the script code). 2. Install your public key on the phone (see online 'ssh' docs) EOF } set -u -e qshlist() { # print args quoted for /bin/sh when necessary if [ $# -gt 0 ] ; then local sep="" local arg for arg in "$@" ; do echo -n "$sep" sep=" " case "$arg" in *[!-_:.,/a-zA-Z0-9]*) printf "%s" "'$(echo "$arg" | sed -e "s/'/'\\\\''/g")'" ;; *) printf "%s" "${arg}" ;; esac done fi } verbplace_to_phonepath() { # print path on phone for given Place or verbPlace local verb="$1" local confname case "$verb" in *[A-Z]*) local Name=${verb#${verb%[A-Z]*}} # copyFoo --> Foo confname="${Name}Path" # FooPath e.g. MusicPath ;; *pics) confname="CameraPath" ;; *music) confname="MusicPath" ;; *root*) confname="RootPath" ;; copy|list|move) # This does the same as copyRoot if RootPath is . printf "." # remote homedir return ;; *) echo "Don't know what verb '$verb' means (no implied phonepath)" >&2; exit 1 ;; esac [ -n "${!confname:-}" ] || { echo "CONF ERR: $confname not set (needed for $verb)" >&2; exit 1; } #printf "### verb='%s' phonepath='%s'" "$verb" "${!confname}" >&2 printf "%s" "${!confname}" # (no newline) } conf=$HOME/.phone_conf # # Sample .phone_conf file # PhoneHost=192.168.1.111 # PhonePort=2222 # # There is a symlink in the phone homedir # # SDCard -> /storage/emulated/0 # CameraPath=SDCard/DCIM/Camera # MusicPath=SDCard/Music/jima # DownloadPath=SDCard/Download # ActivePath=SDCard/Download/active # PhoneLsCmd=busybox ls -F # RootPath=. # sshelper homedir; not allowed actual root access quiet= DRYRUN= PhoneHost= PhonePort= PhoneUser=ignored CameraPath= MusicPath= PhoneLsCmd=ls RootPath="/" eval_conf() { eval "$( perl -wE ' #say "set -x;"; use strict; use warnings; use Path::Tiny qw/path/; my $sq = qq{\x{27}}; my $dq = qq{\x{22}}; my $bs = qq{\x{5C}}; use Regexp::Common; my $quoted_re = $RE{delimited}{-delim=>qq{$sq$dq}}; foreach my $arg (@ARGV) { foreach (path($arg)->lines_utf8) { chomp; # convert old-style config files s/^(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/PhoneIP=$1/; s/^PhoneIP=/PhoneHost=/; s/^(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}):(\d+)$/PhoneHost=$1 PhonePort=$2/; # Remove comments and empty lines s/\s*#.*//; next unless /\S/; # extract only cognizable config variables iff values are eval-safe if (! s/^(PhoneHost|PhonePort|PhoneUser|[A-Z]\w*Path|PhoneLsCmd)\s*=\s*(.*?)\s*$/$1=$2/) { warn "WARNING: $arg contains unrecognized setting: «$_»\n"; next; } # Allow entire rhs to be quoted but not any interior escapes s/($quoted_re)\s*$/substr($1,1,length($1)-2)/e; if (! /^(\w+)=([^\\\$\%${sq}${dq}\n]+)$/) { warn "WARNING: $arg contains illegal character: «$_»\n"; next; } print "$1=${sq}$2${sq}\n"; } } ' "$conf" )" } eval_conf getopt_long_arg='dry-run,quiet,conf:,host:,port:,user:,DCIM-path:' # Configure getopt to parse --FooPath=something for any 'Foo' str=" $* " while [[ "$str" =~ (.*)\ --([A-Z]\w*Path)(=|\ [^\ ]) ]] ; do getopt_long_arg+=",${BASH_REMATCH[2]}:" str="${BASH_REMATCH[1]}" done TEMP=`getopt -o +nqh:p:u: --long $getopt_long_arg -n phone -- "$@"` [ $? = 0 ] || { exit 1; } eval set -- "$TEMP" while true ; do case "$1" in -n|--dry-run) DRYRUN=DRYRUN; shift ;; -q|--quiet) quiet="--quiet"; shift ;; -h|--host) PhoneHost=$2; shift 2 ;; -p|--port) PhonePort=$2; shift 2 ;; -u|--user) PhoneUser=$2; shift 2 ;; --conf) conf=$2; shift 2 ; eval_conf ;; --[A-Z]*Path) eval "${1#--}=\$2" # e.g. PhonePath=$2 shift 2 ;; --) shift ; break ;; *) echo "internal bug ($1)" ; exit 1 ;; esac done [ -n "$PhoneHost" ] || { echo "CONF ERR: PhoneHost not set" >&2; exit 1; } [ -n "$PhonePort" ] || { echo "CONF ERR: PhonePort not set" >&2; exit 1; } case $(basename "$0") in phone*|*utils) case "${1-}" in [a-z]*) verb="$1" ; shift ;; "") verb=help ;; *) echo "$0 : First argument should be command verb" >&2 ; verb=help ;; esac ;; *) # Use the name of the script (or symlink) as the command verb verb=$(basename "$0") ;; esac function _remotize_path() { local path="$1" local userathost="$2" local remrelroot="$3" case "$path" in *:*) [ -n "$remrelroot" ] || { echo "remrelroot not set for «$path»" >&2; exit 33; } esac # Note: Everything after : is relative to case "$path" in : ) echo "${userathost}:${remrelroot}" ;; # The dir as a dir :/ ) echo "${userathost}:${remrelroot}/" ;; # Contents of the dir :/\* ) echo "${userathost}:${remrelroot}/*" ;; # Contents excluding .hiddenfiles # FIXME: This is arguably wrong because :/full/path should be relative to remrelroot # unless remrelroot iss empty or / (or something like that) :/*/*) echo "${userathost}$path" ;; # [USER@]HOST:/SPECIFIC/PATH/POSSIBLY DEEPER :/* ) echo "BUG (_remotize_path) '${path}'" >&2; exit 1 ;; # is this ever valid? :* ) echo "${userathost}:${remrelroot}/$(expr "$path" : ':\(.*\)')" ;; # one specific item * ) echo "$path" ;; # no : prefix => a local path esac } # setup_rsync_cmd sources... # is USER@HOSTNAME # is the remote directory prefixed to non-absolute remote paths. # If is :path/ then copy sources into path/ on remote # If is path/ then copy every src from remote into (local) path/ # If does not end in / then there may be only one source # and is taken to be the target file name rather than a directory. # Result is in the global array "cmd". function setup_rsync_cmd() { local userathost="$1" local remrelroot="$2" shift; shift local -a srcs=("${@:1:$#-1}") local dst=${@:$#:1} local num_srcs=${#srcs[@]} case "$dst" in */ | :) ;; *) [ $num_srcs -eq 1 ] || { echo "ERROR: Only one source file allowed unless dest is dir/ or :" >&2 exit 1 } ;; esac local dst_is_remote case "$dst" in :*) dst_is_remote=yes ;; *) dst_is_remote="" ;; esac if [ "${#cmd[*]}" -ne 0 ] ; then echo "BUG: cmd[] is not initially empty" >&2; exit 42; fi if [ -n "$dst_is_remote" ] ; then cmd=("${safe_rsync_cmd_start[@]}") else # Can not use safe_rsync with remote sources cmd=(rsync --copy-links) fi # Android 7.0 (Samsung Galaxy S7, updated) has some problem which # prevents setting mtime! # # Use the -c (checksum) option to detect changes (massive overhead) #cmd+=("--rsh=ssh -p $PhonePort" -s -v -rL -c) #cmd+=("--rsh=ssh -p $PhonePort" -s -vv -rL -c) # # Use -u to transfer only files with newer (not just unequal) timestamps, # which suffices if the clocks are reasonably synchronized. # (seems to not work due to timezone problem???) # # FAT filesystems store mtime with 2-second resolution: # so use --modify-window=1 # (or larger, to allow for unsynchronized local & phone clocks) # NOTE: sshelper docs suggest using --size-only cmd+=("--rsh=ssh -T -p $PhonePort") #cmd+=(--sparse) #cmd+=(--bwlimit=2m) cmd+=(-v -r) # Add --copy-links unless cmd alread has a link-handling option # e.g. --copy-unsafe-links if using safe_rsync case "$cmd " in *links\ *) ;; *) cmd+=(--copy-links) ;; esac if [ -n "$dst_is_remote" ] ; then cmd+=(--update --modify-window=5) cmd+=(--size-only) # cmd+=(--omit-dir-times) # cmd+=(--omit-link-times) # THIS WILL PREVENT REPLACING OBSOLETE FILES: # cmd+=(--no-times --ignore-existing) # avoid *all* unnecessary updates cmd+=(--no-times --ignore-times) else # 2/13/26: --times may be needed to avoid itemize-changes 'T' ...? cmd+=(--times) fi cmd+=(--prune-empty-dirs) # cmd+=(--preallocate) # does not work on android #cmd+=(--stats) # unnecessarily noisy cmd+=(--no-motd) #cmd+=("--dry-run") ###TEMP #echo "###dst=$dst" #echo "###dst_is_remote=$dst_is_remote" local src local srcs_include_dirs=yes for src in "${srcs[@]}" ; do if [ -n "$dst_is_remote" ] ; then # all source args should be local case "$src" in *:*) echo "UNEXPECTED :localsource" >&2; exit 2 ;; esac [ -d "$src" ] || srcs_include_dirs="" fi done if [ -n "$dst_is_remote" ] && [ -n "$srcs_include_dirs" ] ; then # If syncing directories to phone, delete obsolete items # (but if sending individual files, don't delete everything else # in the destination directory!!) cmd+=(--delete) fi for src in "${srcs[@]}" ; do if [ -n "$dst_is_remote" ] ; then # all source args are local cmd+=("$src") else # all source args are remote case "$src" in :*) ;; *) src=":$src" ;; esac cmd+=("$(_remotize_path "$src" "$userathost" "$remrelroot")") userathost= fi shift done cmd+=("$(_remotize_path "$dst" "$userathost" "$remrelroot")") } declare -a safe_rsync_cmd_start if type safe_rsync >/dev/null 2>&1 ; then safe_rsync_cmd_start=(safe_rsync --copy-unsafe-links) else echo "# WARNING: Could not find 'safe_rsync'" >&2 safe_rsync_cmd_start=(rsync --copy-links) fi redirect_outerr= declare -a cmd=() cmd_stdin=""; # A shell command string with internally-quoted args case "$verb" in *ssh) cmd=(ssh -p $PhonePort "$PhoneUser@$PhoneHost" ${1+"$@"}) ;; *list* | ls*) # phone list [-options] [path...] # phone listpics [-options] [subpath below path/to/DCIM/Camera/] # phone listmusic [-options] [subpath below path/to/Music/] cmd=(ssh -T -p $PhonePort "$PhoneUser@$PhoneHost") # First change directory to root of appropriate sub-tree # (except for the plain "list" command) cmd_stdin="(" #cmd_stdin[-1]+="set -x" case "$verb" in *list | *ls) ;; *list* | *ls*) place=$(expr "$verb" : '.*list\(.*\)' || expr "$verb" : '.*ls\(.*\)') phonepath=$(verbplace_to_phonepath "$place") cmd_stdin+="cd "$(qshlist "$phonepath")" ;"; ;; esac cmd_stdin+=" pwd -P >&2;" # for debugging? cmd_stdin+=" $PhoneLsCmd $@" # optional ls opts & paths (allow wildcards, executed on phone) cmd_stdin+=")" ;; *copy* | *move*) # phone copypics src1 src2 ... destdir ('*remotewildcards*' ok) # phone copypics src destpath # By default, src* on the phone are transferred to dst on the computer. # However if the dst arg is prefixed by ":" it is treated as remote # and files are transferred from computer to phone. # # Non-absolute remote paths (e.g. ".") are relative to DCIM/Camera/ # # phone rsync args... # Like copypics but non-absolute remote paths are relative to RootPath phonepath=$(verbplace_to_phonepath "$verb") setup_rsync_cmd "$PhoneUser@$PhoneHost" "$phonepath" "$@" case "$verb" in *move*) cmd=( "${cmd[0]}" --remove-source-files "${cmd[@]:1}" ) ;; esac #echo "##BBcopy cmd=${cmd[*]}" ;; sync*) # phone syncmusic /path/to/parentofalbums/ if [ $# -ne 1 ] || [[ "${1:-}" == *[^/] ]] ; then echo "sync accepts a single argument /path/to/parentofalbums/" >&2; exit 1; fi phonepath=$(verbplace_to_phonepath "$verb") setup_rsync_cmd "$PhoneUser@$PhoneHost" "$phonepath" "$@" ":/" # *DELETE* everything not being currently sync'd cmd=( "${cmd[0]}" --delete "${cmd[@]:1}" ) ;; browse) # First find the absolute path of the "home" directory, since sftp:// URIs # do not support relateive paths (at least not with SSHelper) echo "It may take 5-10 seconds for the phone's sftp server to wake up!" >&2 str="$(echo "@pwd" | sftp -b - -P $PhonePort "$PhoneUser@$PhoneHost" 2>/dev/null)" [[ "$str" =~ [^/]*(/.*) ]] || { echo "BUG: Unexpected '$str'" >&2; exit 3; } remhome=${BASH_REMATCH[1]} #browser=firefox browser=nautilus redirect_outerr=yes # nautilus is noisy in Ubuntu 15.04 cmd=($browser "sftp://$PhoneUser@$PhoneHost:${PhonePort}$remhome") phonepath=$(verbplace_to_phonepath "pics") cmd[-1]+="/$phonepath" ;; sftp|ftp) echo "It may take 5-10 seconds for the phone's sftp server to wake up!" >&2 cmd=(sftp -P $PhonePort "$PhoneUser@$PhoneHost") ;; help) usage exit 0 ;; *) echo "Unknown operation '$verb'" >&2; usage exit 3 ;; esac # If command is rsync, pass dry-run and quiet options if appropriate case "${cmd[0]}" in *rsync) if [ -n "$DRYRUN" ] ; then cmd=( "${cmd[0]}" -n "${cmd[@]:1}" ) DRYRUN="" fi if [ -n "$quiet" ] ; then cmd=( "${cmd[0]}" -q "${cmd[@]:1}" ) else # -q --itemize-changes results in no list of transferred files cmd=( "${cmd[0]}" --itemize-changes "${cmd[@]:1}" ) fi esac [ -n "$redirect_outerr" ] && echo "${DRYRUN}> exec >/dev/null 2>/dev/null" >&2 if [ -n "$cmd_stdin" ] ; then echo "${DRYRUN}> echo \"$cmd_stdin\" |" "$(qshlist "${cmd[@]}")" >&2 else echo "${DRYRUN}>" "$(qshlist "${cmd[@]}")" >&2 fi ##cmd=("prargs" "CMD:" "${cmd[@]}") ###TEMP if [ -n "$DRYRUN" ] ; then echo "" else [ -n "$redirect_outerr" ] && exec >/dev/null 2>/dev/null if [ -n "$cmd_stdin" ] ; then echo "$cmd_stdin" | "${cmd[@]}" else exec "${cmd[@]}" fi fi