#!/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.48 2026/05/05 19:24:56 jima Exp jima $

usage() {
  cat <<EOF

This script accesses your smartphone when it's running "SSHelper"

USAGE: $(basename "$0") [OPTIONS] COMMAND args...

Available COMMANDs:
  ssh
  browse (opens in Camera folder)
  sftp
  listpics ...
  copypics ...            
  syncmusic  /path/to/albumsparent

  list [path]        (recursively lists host:path)
    listpics  -> 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 <remrelroot> 
  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
}

# append_rsync_fileargs <userathost> <remrelroot> sources... <dst>
#   <userathost> is USER@HOSTNAME
#   <remrelroot> is the remote directory prefixed to non-absolute remote paths.
#   If <dst> is :path/ then copy sources into path/ on remote
#   If <dst> is path/ then copy every src from remote into (local) path/
#   If <dst> does not end in / then there may be only one source
#     and <dst> is taken to be the target file name rather than a directory.
# Appends result to global array "cmd", omitting the initial
# "rsync" (the caller must supply that before or after)
function append_rsync_fileargs() {
  local userathost="$1"
  local remrelroot="$2"
  shift; shift
  local -a srcs=("${@:1:$#-1}")
  local dst=${@:$#:1}
  local num_srcs=${#srcs[@]}
  local dst_is_remote
  case "$dst" in :*) dst_is_remote=yes ;; *) dst_is_remote="" ;; esac
  case "$dst" in 
    */ | :) ;;
    *) [ $num_srcs -eq 1 ] || { 
         echo "ERROR: Only one source file allowed unless dest is dir/ or :" >&2
         exit 1
       }
    ;;
  esac 
  # 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 resoulation:
  #   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 --copy-links)
  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_are_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_are_dirs=""
    fi
  done
  if [ -n "$dst_is_remote" ] && [ -n "$srcs_are_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")")
}

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")

   cmd=(rsync)
   case "$verb" in
     *move*) cmd+=(--remove-source-files) ;;
   esac
   append_rsync_fileargs "$PhoneUser@$PhoneHost" "$phonepath" "$@"
#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")
   cmd=(rsync)
   # *DELETE* everything not being currently sync'd
   cmd+=(--delete)
   append_rsync_fileargs "$PhoneUser@$PhoneHost" "$phonepath" "$@" ":/"
   ;;
 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
