package Android::ElectricSheep::Automator::AppProperties; use 5.006; use strict; use warnings; our $VERSION = '0.03'; use Mojo::Log; use Config::JSON::Enhanced; use XML::XPath; use XML::LibXML; use Data::Roundtrip qw/perl2dump no-unicode-escape-permanently/; use overload ( '""' => \&toString ); sub new { my $class = $_[0]; my $params = $_[1] // {}; my $parent = ( caller(1) )[3] || "N/A"; my $whoami = ( caller(0) )[3]; my $self = { '_private' => { 'logger-object' => undef, 'verbosity' => 0, 'mother' => 0, }, 'data' => { # both are extracted from pkg=Package{3d33653 com.viber.voip} 'packageName' => '', 'packageId' => '', 'codePath' => '', 'resourcePath' => '', 'applicationInfo' => '', 'dataDir' => '', 'versionCode' => '', 'versionName' => '', 'flags' => [], 'privateFlags' => [], 'pkgFlags' => [], 'usesOptionalLibraries' => [], 'usesLibraryFiles' => [], 'timeStamp' => '', 'firstInstallTime' => '', 'lastUpdateTime' => '', 'signatures' => '', 'requestedPermissions' => [], # requested permissions 'installPermissions' => [], 'runtimePermissions' => [], 'declaredPermissions' => [], 'enabledComponents' => [], # activities # each item is a hash with name, fully qualified name etc. 'MainActivity' => {}, # <<< this is an item as well if found 'MainActivities' => [], # only those marked as .MAIN and .LAUNCHER 'activities' => [], # all of them } }; bless $self => $class; # we need a mother object (Android::ElectricSheep::Automator) if( (! exists $params->{'mother'}) || (! defined $params->{'mother'}) || (! defined $params->{'mother'}->adb()) ){ print STDERR "${whoami} (via $parent), line ".__LINE__." : error, input parameter 'mother' with our parent Android::ElectricSheep::Automator object was not specified.\n"; return undef } $self->{'_private'}->{'mother'} = $params->{'mother'}; # we now have a mother if( exists $params->{'logger-object'} ){ $self->{'_private'}->{'logger-object'} = $params->{'logger-object'} } else { $self->{'_private'}->{'logger-object'} = $self->mother->log } # we now have a log my $log = $self->log; if( exists $params->{'verbosity'} ){ $self->{'_private'}->{'verbosity'} = $params->{'verbosity'} } else { $self->{'_private'}->{'verbosity'} = $self->mother->verbosity } # we now have verbosity my $verbosity = $self->verbosity; # caller can specify some initial data to load # OR caller can specify a string which is the output of a dumpsys (for 1 package) # to parse it # else caller must run ->enquire() to enquire the real device at a later time if( exists $params->{'data'} ){ my $d = $self->{'data'}; my $p = $params->{'data'}; for my $k (sort keys %$d){ if( exists($p->{$k}) && defined($p->{$k}) ){ if( $self->set($k, $p->{$k}) ){ $log->error("${whoami} (via $parent), line ".__LINE__." : error, call to ".'set()'." has failed for input parameter '$k', is its type as expected (".ref($d->{$k}).")?"); return undef } } } } elsif( exists $params->{'package'} ){ # we have a package name, we will enquire about it and we # fill us up with its info if( 1 == $self->enquire({'package' => $params->{'package'}}) ){ $log->error("${whoami} (via $parent), line ".__LINE__." : error, failed to enquire package '".$params->{'package'}."' (call to ".'enquire()'." has failed)."); return undef } } return $self; } # this is a factory method (static) which first does: # adb shell 'dumpsys package packages #<< 1 operation # to find all package names installed on device. # If 'lazy'==1 then it stops there, returning only the package names # (and not AppProperties associated with each package name). # If 'lazy'==0 then for each package installed on device OR each # package the caller specified ('packages'), it creates an # AppProperties object (which does a dumpsys for each package name) # filled up with info, like MainActivity, permissions etc. # If 'packages' it was specified, then a list of ALL packages # installed on device will be returned where those in 'packages' # will have an AppProperties object associated with them. The rest # will have an undef value. # In the case where 'packages' was specified, 'lazy' value # will affect only those packages not in 'packages'. # If no 'packages' were specified then a hash of all installed # packages will be returned. With: # 1. If 'lazy'==1 then the values will be undef. # 2. If 'lazy'==0 then each package will have an AppProperties value # associated with it. This can be slow (a few seconds) because # it does a dumpsys for each package. # Default 'lazy' value is 1. And 'lazy'=0 for 'packages'. # Input parameters: # 'packages' => 'packagename' or regex (e.g. qr//) or [...] or { ...} : # optionally specify one or more packages to load non-lazily, all other # packages will be loaded lazily. Default is to load all packages lazily. # an item in 'packages' can be a string for exact package name match or # a compiled regex (qr//). # NOTE: at first all packages will be listed and no AppProperties will # be created unless 'lazy'==0 # Therefore specifying 'packages' means instantiate their # AppProperties object, irrespective of the 'lazy' parameter. # 'lazy' => 0 or 1 : optionally specify whether to load the 'packages' lazily # which means to have their name in the returned hash but the value # (the AppProperties object) will be undef, leaving instantiation for when needed. # Default is lazy=1 (and lazy=0 for those items in 'packages') # NOTE: lazy will be 0 for the packages specified in 'packages' if any. # So either specify lazy=0 for getting info on all packages installed (takes some time!) # or set 'packages' to include those package names you want non-lazy, letting all other lazy. # It returns undef on failure # It returns %{ AppProperties objects } keyed on the app name (packageName), # on success. The values of the hash can be undef if lazy>0 # meaning that they will be left to be enquired when someone needs them # e.g. in an open_app() call. sub enquire_installed_apps_factory { my $params = $_[0] // {}; my $parent = ( caller(1) )[3] || "N/A"; my $whoami = ( caller(0) )[3]; # we need a mother object (Android::ElectricSheep::Automator) if( (! exists $params->{'mother'}) || (! defined $params->{'mother'}) || (! defined $params->{'mother'}->adb()) ){ print STDERR "${whoami} (via $parent), line ".__LINE__." : error, input parameter 'mother' with our parent Android::ElectricSheep::Automator object was not specified.\n"; return undef } my $mother = $params->{'mother'}; # we now have a mother my $log = $mother->log; my $verbosity = $mother->verbosity; # default is to be lazy (except for 'packages' if any) my $lazy = (exists($params->{'lazy'}) && defined($params->{'lazy'})) ? $params->{'lazy'} : 1; # if the user has specified packages then we will load just those non-lazily my ($packages, @packages_arr); if( exists($params->{'packages'}) && defined($packages=$params->{'packages'}) ){ # packages can be a single package name my $rr = ref($packages); if( ($rr eq '') || ($rr eq 'Regexp') ){ @packages_arr = ($packages) } elsif( $rr eq 'ARRAY' ){ @packages_arr = @$packages } elsif( $rr eq 'HASH' ){ @packages_arr = ( map { $_ } grep { $packages->{$_} > 0 } keys %$packages ) } else { $log->error("${whoami} (via $parent), line ".__LINE__." : error, input parameter 'packages' must be a scalar string (for specifying just one package) or a regexp (Regexp type for compiled (".'qr//'.") regexes or an ARRAYref or a HASHref of package names and not '$rr'."); return undef } } # by now we have packages as a HASHref or undef and they can contain regex or string package names. if( $verbosity > 0 ){ $log->info("${whoami} (via $parent), line ".__LINE__." : called ...") } # NOTE # dumpsys package packages # and # dumpsys package com.example.xyz # will yield different things for the package com.example.xyz # we need to enquire twice, 1 for all the package names # 2 for the full content of the package info, including activities # we will dumpsys specifically for that package # here we could also save to a file on device and then # fetch it locally. We will do that if there are problems # getting the dump from STDOUT my @cmd = ('dumpsys', 'package', 'packages'); my $res = $mother->adb->shell(@cmd); if( ! defined $res ){ $log->error(join(" ", @cmd)."\n${whoami} (via $parent), line ".__LINE__." : error, above shell command has failed, got undefined result, most likely shell command did not run at all, this should not be happening."); return undef } if( $res->[0] != 0 ){ $log->error(join(" ", @cmd)."\n${whoami} (via $parent), line ".__LINE__." : error, above shell command has failed, with:\nsSTDOUT:\n".$res->[1]."\n\nSTDERR:\n".$res->[2]."\nEND."); return undef } my $content = $res->[1]; my %apps; while( $content =~ /^\s{2}Package\s+\[(.+?)\]\s+\(.+?\)\:[\r\n](.+?)(?:[\r\n]\s{2}[^ ]|[\r\n]$|\z)/smg ){ # if you want the package content: #while( $content =~ /^(\s{2}Package\s+\[(.+?)\]\s+\(.+?\)\:[\r\n].+?)(?:[\r\n]\s{2}[^ ]|[\r\n]$|\z)/smg ){ #my $package_contents = $1; and name $2 my $package_name = $1; # we will be lazy my $is_this_lazy = $lazy; if( $lazy == 1 ){ for my $ap (@packages_arr){ if( ref($ap) eq '' ){ if( $package_name eq $ap ){ $is_this_lazy = 0; last } } else { if( $package_name =~ $ap ){ $is_this_lazy = 0; last } } } } if( $is_this_lazy == 0 ){ my $app = Android::ElectricSheep::Automator::AppProperties->new({ 'package' => $package_name, 'mother' => $mother, }); if( ! defined $app ){ $log->error("${whoami} (via $parent), line ".__LINE__." : error, call to ".'Android::ElectricSheep::Automator::AppProperties->new()'." has failed."); return undef } if( $verbosity > 1 ){ $log->info("${whoami} (via $parent), line ".__LINE__." : enquired app '".$app->get('packageName')."' successfully and instantiated its AppProperties object.") } $apps{$package_name} = $app; } else { $apps{$package_name} = undef; # <<< lazy instantiate if&when needed if( $verbosity > 0 ){ $log->info("${whoami} (via $parent), line ".__LINE__." : registered installed app '${package_name}' successfully (but not instantiated AppProperties).") } } } # done if( $verbosity > 0 ){ $log->info("${whoami} (via $parent), line ".__LINE__." : enquired ".scalar(keys %apps)." apps.") } return \%apps; } # does a adb shell dumpsys and reads various things from it # it may also do a adb shell wm density # returns 0 on success, 1 on failure sub enquire { my ($self, $params) = @_; $params //= {}; my $parent = ( caller(1) )[3] || "N/A"; my $whoami = ( caller(0) )[3]; my $log = $self->log; my $verbosity = $self->verbosity; # NOTE # dumpsys package packages # and # dumpsys package com.example.xyz # will yield different things for the package com.example.xyz # we need to enquire twice, 1 for all the package names # 2 for the full content of the package info, including activities # we will dumpsys specifically for that package my $package; if( ! exists($params->{'package'}) || ! defined($package=$params->{'package'}) ){ $log->error("${whoami} (via $parent), line ".__LINE__." : error, input parameter 'package' (the package name e.g. com.example.calendar) was not specified. If you want to enquire all installed apps, use the factory method ".'enquire_installed_apps_factory()'." in current package. A package name is not required if you provide the dumpsys for a specific package via the 'string' parameter."); return 1 } if( $verbosity > 0 ){ $log->info("${whoami} (via $parent), line ".__LINE__." : package '${package}' : called ...") } # here we could also save to a file on device and then # fetch it locally. We will do that if there are problems # getting the dump from STDOUT my @cmd = ('dumpsys', 'package', $package); my $res = $self->adb->shell(@cmd); if( ! defined $res ){ $log->error(join(" ", @cmd)."\n${whoami} (via $parent), line ".__LINE__." : error, above shell command has failed, got undefined result, most likely shell command did not run at all, this should not be happening."); return undef } if( $res->[0] != 0 ){ $log->error(join(" ", @cmd)."\n${whoami} (via $parent), line ".__LINE__." : error, above shell command has failed, with:\nsSTDOUT:\n".$res->[1]."\n\nSTDERR:\n".$res->[2]."\nEND."); return undef } my $content = $res->[1]; if( $content =~ /^\s{2}Package.+?\:[\r\n].+?\s{4}pkg=Package\{(.+?) (.+?)\}[\r\n]/sm ){ $self->set('packageId', $1); $self->set('packageName', $2); } else { $log->error("${whoami} (via $parent), line ".__LINE__." : package '${package}' : error, failed to find 'packageName' and 'packageId'."); return 1 } if( $content =~ /^\s{2}Package.+?\:[\r\n].+?\s{4}codePath=(.+?)[\r\n]/sm ){ $self->set('codePath', $1); } else { $log->error("${whoami} (via $parent), line ".__LINE__." : package '${package}' : error, failed to find 'codePath'."); return 1 } if( $content =~ /^\s{2}Package.+?\:[\r\n].+?\s{4}codePath=(.+?)[\r\n]/sm ){ $self->set('codePath', $1); } else { $log->error("${whoami} (via $parent), line ".__LINE__." : package '${package}' : error, failed to find 'codePath'."); return 1 } if( $content =~ /^\s{2}Package.+?\:[\r\n].+?\s{4}resourcePath=(.+?)[\r\n]/sm ){ $self->set('resourcePath', $1); } else { $log->error("${whoami} (via $parent), line ".__LINE__." : package '${package}' : error, failed to find 'resourcePath'."); return 1 } if( $content =~ /^\s{2}Package.+?\:[\r\n].+?\s{4}codePath=(.+?)[\r\n]/sm ){ $self->set('codePath', $1); } else { $log->error("${whoami} (via $parent), line ".__LINE__." : package '${package}' : error, failed to find 'codePath'."); return 1 } if( $content =~ /^\s{2}Package.+?\:[\r\n].+?\s{4}versionCode=(.+?)[\r\n]/sm ){ $self->set('versionCode', $1); } else { $log->error("${whoami} (via $parent), line ".__LINE__." : package '${package}' : error, failed to find 'versionCode'."); return 1 } if( $content =~ /^\s{2}Package.+?\:[\r\n].+?\s{4}versionName=(.+?)[\r\n]/sm ){ $self->set('versionName', $1); } else { $log->error("${whoami} (via $parent), line ".__LINE__." : package '${package}' : error, failed to find 'versionName'."); return 1 } if( $content =~ /^\s{2}Package.+?\:[\r\n].+?\s{4}flags=\[\s*(.+?)\s*\][\r\n]/sm ){ $self->set('flags', [ split(/\s+/, $1) ]); } else { $log->error("${whoami} (via $parent), line ".__LINE__." : package '${package}' : error, failed to find 'flags'."); return 1 } if( $content =~ /^\s{2}Package.+?\:[\r\n].+?\s{4}privateFlags=\[\s*(.+?)\s*\][\r\n]/sm ){ $self->set('privateFlags', [ split(/\s+/, $1) ]); } else { $log->error("${whoami} (via $parent), line ".__LINE__." : package '${package}' : error, failed to find 'privateFlags'."); return 1 } if( $content =~ /^\s{2}Package.+?\:[\r\n].+?\s{4}pkgFlags=\[\s*(.+?)\s*\][\r\n]/sm ){ $self->set('pkgFlags', [ split(/\s+/, $1) ]); } else { $log->error("${whoami} (via $parent), line ".__LINE__." : package '${package}' : error, failed to find 'pkgFlags'."); return 1 } if( $content =~ /^\s{2}Package.+?\:[\r\n].+?\s{4}usesOptionalLibraries\:[\r\n](.+?)[\r\n]\s{4}[^ ]/sm ){ $self->set('usesOptionalLibraries', [ map { $_ =~ s/\s+//g; $_ } split(/[\r\n]+/, $1) ]); } else { $self->set('usesOptionalLibraries', [] ); } # optional if( $content =~ /^\s{2}Package.+?\:[\r\n].+?\s{4}usesLibraryFiles\:[\r\n](.+?)[\r\n]\s{4}[^ ]/sm ){ $self->set('usesLibraryFiles', [ map { $_ =~ s/\s+//g; $_ } split(/\[\r\n]+/, $1) ]); } else { $self->set('usesLibraryFiles', [] ); } # optional if( $content =~ /^\s{2}Package.+?\:[\r\n].+?\s{4}timeStamp=(.+?)[\r\n]/sm ){ $self->set('timeStamp', $1); } else { $log->error("${whoami} (via $parent), line ".__LINE__." : package '${package}' : error, failed to find 'timeStamp'."); return 1 } if( $content =~ /^\s{2}Package.+?\:[\r\n].+?\s{4}firstInstallTime=(.+?)[\r\n]/sm ){ $self->set('firstInstallTime', $1); } else { $log->error("${whoami} (via $parent), line ".__LINE__." : package '${package}' : error, failed to find 'firstInstallTime'."); return 1 } if( $content =~ /^\s{2}Package.+?\:[\r\n].+?\s{4}lastUpdateTime=(.+?)[\r\n]/sm ){ $self->set('lastUpdateTime', $1); } else { $log->error("${whoami} (via $parent), line ".__LINE__." : package '${package}' : error, failed to find 'lastUpdateTime'."); return 1 } if( $content =~ /^\s{2}Package.+?\:[\r\n].+?\s{4}signatures=(.+?)[\r\n]/sm ){ $self->set('signatures', $1); } else { $log->error("${whoami} (via $parent), line ".__LINE__." : package '${package}' : error, failed to find 'signatures'."); return 1 } if( $content =~ /^\s{2}Package.+?\:[\r\n].+?\s{4}declared permissions\:[\r\n]\s*(.+?)\s*[\r\n]\s{4}[^ ]/sm ){ $self->set('declaredPermissions', [ split(/\s*[\r\n]+\s*/, $1) ]); } else { $self->set('declaredPermissions', [] ); } # optional if( $content =~ /^\s{2}Package.+?\:[\r\n].+?\s{4}requested permissions\:[\r\n]\s*(.+?)\s*[\r\n]\s{4}[^ ]/sm ){ $self->set('requestedPermissions', [ split(/\s*[\r\n]+\s*/, $1) ]); } else { $self->set('requestedPermissions', [] ); } # optional if( $content =~ /^\s{2}Package.+?\:[\r\n].+?\s{4}install permissions\:[\r\n]\s*(.+?)\s*[\r\n]\s{4}[^ ]/sm ){ $self->set('installPermissions', [ split(/\s*[\r\n]+\s*/, $1) ]); } else { $self->set('installPermissions', [] ); } # optional if( $content =~ /^\s{2}Package.+?\:[\r\n].+?\s{4}runtime permissions\:[\r\n]\s*(.+?)\s*[\r\n]\s{4}[^ ]/sm ){ $self->set('runtimePermissions', [ split(/\s*[\r\n]+\s*/, $1) ]); } else { $self->set('runtimePermissions', [] ); } # optional if( $content =~ /^\s{2}Package.+?\:[\r\n].+?\s{4}enabledComponents\:[\r\n]\s*(.+?)(?:[\r\n]\s{6}[^\s]+|[\r\n]{2}|\z)/sm ){ $self->set('enabledComponents', [ split(/\s*[\r\n]+\s*/, $1) ]); } else { $self->set('enabledComponents', [] ); } # optional # now parse all the activities if( $content =~ /^Activity Resolver Table:[\r\n].+?[\r\n]\s{6}android.intent.action.MAIN\:[\r\n](.+?)(?:[\r\n]\s{6}[^ ]|[\r\n]{2}|\z)/sm ){ my $sc = $1; my @acts; while( $sc =~ /^\s{8}(.+?)[\r\n]\s{10}Action: "(.+?)"[\r\n]\s{10}Category: "(.+?)"/gsm ){ my $nameall = $1; my $action = $2; my $category = $3; my ($fullname) = $nameall =~ /^.+?\s+(.+?)\s+/; my ($name) = $fullname =~ /\/(.+?)$/; push @acts, { 'name-fully-qualified' => $fullname, 'name' => $name, 'name-all' => $nameall, 'action' => $action, 'category' => $category } } $self->set('activities', \@acts); } else { $self->set('activities', [] ); } # optional # parse the main activities only (1 or more) if( $content =~ /^Activity Resolver Table\:[\r\n].+?[\r\n]\s{6}android.intent.action.MAIN\:[\r\n](.+?)(?:[\r\n]\s{6}[^ ]|[\r\n]{2}|\z)/sm ){ # we now have # fb2df64 com.viber.voip/.WelcomeActivity filter a91c5cd # Action: "android.intent.action.MAIN" # Category: "android.intent.category.LAUNCHER" # eb6c1f4 com.viber.voip/.settings.ui.SettingsHeadersActivity filter 9a11$ # Action: "android.intent.action.MAIN" # Category: "android.intent.category.NOTIFICATION_PREFERENCES" # and we need the first, not the second my $sc = $1; my @acts; while( $sc =~ /^\s{8}(.+?)[\r\n]\s{10}Action\: "(android\.intent\.action\.MAIN)"[\r\n]\s{10}Category\: "(android\.intent\.category\.LAUNCHER)"/gsm ){ my $nameall = $1; my $action = $2; my $category = $3; my ($fullname) = $nameall =~ /^.+?\s+(.+?)\s+/; my ($name) = $fullname =~ /\/(.+?)$/; push @acts, { 'name-fully-qualified' => $fullname, 'name' => $name, 'name-all' => $nameall, 'action' => $action, 'category' => $category } } $self->set('MainActivities', \@acts); } else { $self->set('MainActivities', [] ); if( $verbosity > 1 ){ $log->warn("${whoami} (via $parent), line ".__LINE__." : package '${package}' : warning, failed to find at least one MainActivity!") } } # choose one of the main activities for being the # one when we open the app. # Heuristic: just pick the shortest activity name which # does not contain dots except the startinh dot: my $ma = $self->get('MainActivities')->[0]; my $l = length $ma->{'name'}; for my $v (@{ $self->get('MainActivities') }){ my $n = $v->{'name'}; my $c = $n =~ tr/\.//; next if $c > 1; $c = length $n; if( $l > $c ){ $l = $c; $ma = $v }; } $self->set('MainActivity', $ma); # done! if( $verbosity > 0 ){ $log->info("${whoami} (via $parent), line ".__LINE__." : package '${package}' : done.") } return 0; # success } sub get { return $_[0]->has($_[1]) ? $_[0]->{'data'}->{$_[1]} : undef } sub set { # set a new value even if it is not in our store, # but if it is, then check the types match if( exists($_[0]->{'data'}->{$_[1]}) && (ref($_[2]) ne ref($_[0]->{'data'}->{$_[1]})) ){ $_[0]->log()->error(__PACKAGE__."::set(), line ".__LINE__." : error, the type of parameter '$_[1]' is '".ref($_[2])."' but '".ref($_[0]->{'data'}->{$_[1]})."' was expected."); return 1 } $_[0]->{'data'}->{$_[1]} = $_[2]; return 0; # success } sub has { exists $_[0]->{'data'}->{$_[1]} } sub toString { # unfortunately as a hash it is unsorted return perl2dump($_[0]->{'data'}, {terse=>1,pretty=>1}); } sub toJSON { return perl2json($_[0]->{'data'}, {pretty=>1}); } sub TO_JSON { return $_[0]->{'data'} } sub log { return $_[0]->{'_private'}->{'logger-object'} } sub verbosity { return $_[0]->{'_private'}->{'verbosity'} } sub mother { return $_[0]->{'_private'}->{'mother'} } sub adb { return $_[0]->mother->adb } # only pod below =pod =head1 NAME Android::ElectricSheep::Automator - The great new Android::ElectricSheep::Automator! =head1 VERSION Version 0.03 =head1 SYNOPSIS Quick summary of what the module does. Perhaps a little code snippet. use Android::ElectricSheep::Automator; my $foo = Android::ElectricSheep::Automator->new(); ... =head1 EXPORT A list of functions that can be exported. You can delete this section if you don't export anything, such as for a purely object-oriented module. =head1 SUBROUTINES/METHODS =head2 function1 =head2 function2 =head1 AUTHOR Andreas Hadjiprocopis, C<< <bliako at cpan.org> >> =head1 BUGS Please report any bugs or feature requests to C<bug-android-adb-automator at rt.cpan.org>, or through the web interface at L<https://rt.cpan.org/NoAuth/ReportBug.html?Queue=Android-ADB-Automator>. I will be notified, and then you'll automatically be notified of progress on your bug as I make changes. =head1 SUPPORT You can find documentation for this module with the perldoc command. perldoc Android::ElectricSheep::Automator You can also look for information at: =over 4 =item * RT: CPAN's request tracker (report bugs here) L<https://rt.cpan.org/NoAuth/Bugs.html?Dist=Android-ADB-Automator> =item * CPAN Ratings L<https://cpanratings.perl.org/d/Android-ADB-Automator> =item * Search CPAN L<https://metacpan.org/release/Android-ADB-Automator> =back =head1 ACKNOWLEDGEMENTS =head1 LICENSE AND COPYRIGHT This software is Copyright (c) 2025 by Andreas Hadjiprocopis. This is free software, licensed under: The Artistic License 2.0 (GPL Compatible) =cut 1; # End of Android::ElectricSheep::Automator